diff --git a/.gitignore b/.gitignore index 60709ee..7f9b8de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ design/.DS_Store # Secrets and local environment .env* +!deploy/.env.example +deploy/.env deploy/secrets/ # Build artifacts and dependencies diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..d0e969e --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,80 @@ +# MealQuest production environment. Copy to deploy/.env (never committed) +# and replace the placeholders. The service fails fast: a missing or invalid +# required variable stops it at boot with the variable named. +# +# Secrets are never set here. They live in deploy/secrets/ (never committed), +# one value per file, owned by the container user and readable only by it: +# +# mkdir -p deploy/secrets && chmod 700 deploy/secrets +# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key +# printf '%s\n' '' > deploy/secrets/oidc_client_secret +# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/* +# +# deploy/compose.yaml mounts them at /run/secrets/ and points the +# *_FILE variables at them. + +# --- Compose ----------------------------------------------------------------- + +# Host address the service is published on, as
:8090. Production +# binds the LAN address 10.60.1.14 only; 127.0.0.1 suits a dev machine. +MEALQUEST_BIND_ADDRESS=127.0.0.1 + +# --- Service ----------------------------------------------------------------- + +# Public origin the household opens. Fixes the OIDC redirect URI +# (/auth/callback) and the Secure cookies. Must be https:// (plain +# http:// is accepted only for localhost, 127.0.0.1 and [::1]). +MEALQUEST_PUBLIC_URL=https://mealquest.cubecraftlabs.com + +# How long a graceful shutdown may take before in-flight requests are cut. +MEALQUEST_SHUTDOWN_TIMEOUT=10s + +# Optional. Comma-separated IPs/CIDRs of reverse proxies whose +# X-Forwarded-For/-Proto headers are honoured. Production lists Traefik; +# unset trusts no peer. +MEALQUEST_TRUSTED_PROXY=10.60.1.33 + +# IANA zone the household settings are created with on first boot. Required +# until those settings exist; ignored afterwards (change the zone in +# household settings instead). Confirm the household's zone before the first +# deploy. +MEALQUEST_DEFAULT_TZ=America/New_York + +# --- Authentik OIDC ---------------------------------------------------------- + +# Issuer of the Authentik application "mealquest". Discovered at boot, so it +# must be reachable from the container. Must be https://. +MEALQUEST_OIDC_ISSUER=https://auth.cubecraftlabs.com/application/o/mealquest/ +MEALQUEST_OIDC_CLIENT_ID=mealquest +# Comma-separated Authentik groups whose members may sign in. +MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin + +# Optional session lifetimes. A session ends after IDLE unused and always MAX +# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h. +MEALQUEST_SESSION_IDLE=720h +MEALQUEST_SESSION_MAX=4320h + +# --- Set by deploy/compose.yaml; do not set here ----------------------------- +# +# MEALQUEST_HTTP_ADDR=:8080 +# MEALQUEST_DATA_DIR=/data +# MEALQUEST_SECRET_KEY_FILE=/run/secrets/secret_key +# Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM). +# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret +# The Authentik provider's client secret. + +# --- Planned; not read by the service yet ------------------------------------- +# +# Added to deploy/compose.yaml and this file by the features that use them. +# +# Web Push (Feature 19). Generate the key pair with cmd/vapidgen. +# MEALQUEST_VAPID_PUBLIC_KEY= +# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key +# MEALQUEST_VAPID_SUBJECT=mailto: +# +# Google Calendar OAuth web client (Feature 16). +# MEALQUEST_GOOGLE_CLIENT_ID=.apps.googleusercontent.com +# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret +# +# SearXNG recipe search sidecar on the compose network (Feature 07). +# MEALQUEST_SEARXNG_URL=http://searxng:8080 diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md new file mode 100644 index 0000000..c255c4e --- /dev/null +++ b/deploy/RUNBOOK.md @@ -0,0 +1,886 @@ +# MealQuest deployment runbook + +How MealQuest is deployed on office-srvr (10.60.1.14), how it is reached +through Authentik, Traefik and DNS, how it is updated and rolled back, how +its data is backed up and restored, and how its secrets are rotated. Values +are the ones in use on 2026-10-05. Secrets themselves are never written +here. + +Paths used below: + +| Name | Path | +| --- | --- | +| Checkout on the host | `/home/overseer/mealquest` | +| Data volume (`mealquest-data`) on the host | `/var/lib/docker/volumes/mealquest-data/_data` | +| Same volume inside the containers | `/data` | +| Database | `mealquest.db` (+ `mealquest.db-wal`, `mealquest.db-shm` while open) | +| Media | `media/` in the data volume | +| Backup target (NAS, over SSH) | `joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups` | +| Same target over SFTP (Synology chroot) | `/homes/Joshua/mealquest-backups` | + +## Prerequisites + +On office-srvr (10.60.1.14), as recorded on 2026-10-05: + +| Item | Value | +| --- | --- | +| OS | Ubuntu 26.04.1 LTS | +| Docker Engine / Compose plugin | 29.8.2 / 5.6.0 | +| git, rsync, iptables | 2.53.0, 3.4.1, 1.8.11 (nf_tables) | +| Account | `overseer`, with sudo | +| Network | LAN reachability to Traefik (10.60.1.33), Authentik (`auth.cubecraftlabs.com`) and the NAS (10.60.1.50, SSH) | + +Elsewhere: + +- Admin access to Authentik at . +- SSH access to the Traefik host 10.60.1.33. +- Cloudflare access for the `cubecraftlabs.com` zone, and admin access to + both Pi-holes (10.60.1.101 `dns.ccl`, 10.60.1.85 `dns2.ccl`). +- A NAS account `joshua` on 10.60.1.50 with the backup directory + `/volume1/homes/Joshua/mealquest-backups` (see "Backups"). +- A Gitea token able to read `Maniacs_Mansion/MealQuest`, for the clone. + +## Stack and first deploy + +The compose stack is `deploy/compose.yaml`: the `mealquest` service (built +from the repository `Dockerfile`, runs as uid/gid 10001, listens on 8080 in +the container) and the `litestream` service. Data lives in the named volume +`mealquest-data`. + +### 1. Choose and check the port + +The container's 8080 is published on `10.60.1.14:8090`. Ports 8080 and 8081 +on this host belong to NestQuest, so 8090 was chosen. Before the first +deploy it was checked free with: + +```sh +ss -ltnp | grep 8090 # printed nothing +``` + +After the deploy the same host shows NestQuest on 8080/8081 and MealQuest +on 8090: + +``` +LISTEN 0 4096 10.60.1.14:8080 0.0.0.0:* +LISTEN 0 4096 10.60.1.14:8081 0.0.0.0:* +LISTEN 0 4096 10.60.1.14:8090 0.0.0.0:* +``` + +The published host port is fixed in `deploy/compose.yaml`, in +`deploy/systemd/mealquest-firewall.sh` and in the Traefik route; change all +three together. + +### 2. Clone + +The checkout lives at `/home/overseer/mealquest`, next to NestQuest's +`~/nestquest` (not `/opt/mealquest`). Clone over HTTPS with a Gitea token, +then reset the remote to the clean URL so the token is not stored in +`.git/config`: + +```sh +cd /home/overseer +git clone https://:@code.cubecraftlabs.com/Maniacs_Mansion/MealQuest.git mealquest +cd mealquest +git remote set-url origin https://code.cubecraftlabs.com/Maniacs_Mansion/MealQuest.git +git remote -v # must show no token +git checkout main +``` + +### 3. Environment file + +`deploy/.env` (git-ignored) is copied from `deploy/.env.example` and is +mode 0600, owned by `overseer`: + +```sh +cp deploy/.env.example deploy/.env +chmod 600 deploy/.env +``` + +Then edit `deploy/.env` to the values below. In particular +**`MEALQUEST_BIND_ADDRESS` must be changed**: `.env.example` ships +`127.0.0.1` (a dev machine), and left as is the service listens only on +the host's loopback, which Traefik cannot reach. Set it to the LAN address +and check: + +```sh +sed -i 's/^MEALQUEST_BIND_ADDRESS=.*/MEALQUEST_BIND_ADDRESS=10.60.1.14/' deploy/.env +grep '^MEALQUEST_BIND_ADDRESS=' deploy/.env # MEALQUEST_BIND_ADDRESS=10.60.1.14 +docker compose -f deploy/compose.yaml config | grep -A4 'ports:' +# host_ip: 10.60.1.14, target: 8080, published: "8090" +``` + +After step 5 the listener is `10.60.1.14:8090` (container port 8080): + +```sh +docker compose -f deploy/compose.yaml port mealquest 8080 # 10.60.1.14:8090 +sudo ss -ltnp 'sport = :8090' # 10.60.1.14:8090 only, no 0.0.0.0 or 127.0.0.1 +``` + +Values in use: + +| Variable | Value | +| --- | --- | +| `MEALQUEST_BIND_ADDRESS` | `10.60.1.14` | +| `MEALQUEST_PUBLIC_URL` | `https://mealquest.cubecraftlabs.com` | +| `MEALQUEST_SHUTDOWN_TIMEOUT` | `10s` | +| `MEALQUEST_TRUSTED_PROXY` | `10.60.1.33` | +| `MEALQUEST_DEFAULT_TZ` | `America/New_York` | +| `MEALQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/mealquest/` | +| `MEALQUEST_OIDC_CLIENT_ID` | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret) | +| `MEALQUEST_OIDC_ALLOWED_GROUPS` | `mealquest-admin` | +| `MEALQUEST_SESSION_IDLE` / `MEALQUEST_SESSION_MAX` | `720h` / `4320h` | + +`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR` and the `*_FILE` variables are +set by `deploy/compose.yaml` and must not appear in `deploy/.env`. + +### 4. Secrets + +`deploy/secrets/` (git-ignored) is mode 0700, owned by `overseer`. Each +file holds one value, is owned by `10001:10001` (host user `mealquest`) and +is mode 0600. Compose bind-mounts file secrets with their host owner and +mode, so any other owner or mode stops the container reading them and the +service refuses to start. + +| File | Content | Mounted at | +| --- | --- | --- | +| `secret_key` | base64 of 32 random bytes; encrypts stored secrets (AES-256-GCM) | `/run/secrets/secret_key` | +| `oidc_client_secret` | Client Secret of the Authentik provider `mealquest` | `/run/secrets/oidc_client_secret` | +| `litestream_key` | dedicated SSH private key for the NAS (see "Backups") | `/etc/litestream/sftp_key` | + +```sh +mkdir -p deploy/secrets && chmod 700 deploy/secrets +head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key +printf '%s\n' '' > deploy/secrets/oidc_client_secret +# litestream_key: see "Backups" > "Database — Litestream" +sudo chown 10001:10001 deploy/secrets/* +sudo chmod 600 deploy/secrets/* +sudo ls -la deploy/secrets # three files, mealquest mealquest, -rw------- +``` + +The `mealquest` host user (uid/gid 10001) is created in "Media — nightly +rsync"; until it exists `ls` shows the numeric ids. + +### 5. Firewall, then the stack + +Install the firewall unit and the stack unit first (see "Host firewall" +and "Stack unit"), so port 8090 is never open to the LAN. Create the media +directory (see "Media — nightly rsync"), then build and start: + +```sh +docker compose -f deploy/compose.yaml build +sudo systemctl enable --now mealquest # starts mealquest-firewall first +docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up +curl -fsS http://10.60.1.14:8090/health +curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200 +``` + +### Host checkout state on 2026-10-05 + +The first deploy ran before Feature 07 reached `main`. The checkout is at +`main` `9cddbbb` and the Feature 07 files under `deploy/` were copied in +from the feature branch: `deploy/compose.yaml` and `deploy/.env.example` +are listed in `.git/info/exclude`, the rest show as untracked. Before the +first routine deploy of a `main` that contains `deploy/`, remove those +`.git/info/exclude` lines and the copied files (keep `deploy/.env` and +`deploy/secrets/`), or `git checkout` refuses to overwrite them: + +```sh +cd /home/overseer/mealquest +git status --porcelain --untracked-files=all deploy # the copied files +sed -i '/^deploy\/\(compose.yaml\|.env.example\)$/d' .git/info/exclude +git clean -n deploy # dry run: must not list .env or secrets/ (both git-ignored) +git clean -f deploy +``` + +Then follow "Routine deploy"; the checkout brings the tracked copies back. + +## Authentik + +Sign-in is OpenID Connect against Authentik at +. + +| Object | Setting | +| --- | --- | +| Provider | OAuth2/OpenID provider `mealquest`, client type **Confidential** | +| Redirect URI | `https://mealquest.cubecraftlabs.com/auth/callback` (strict) | +| Signing | RS256, with the instance's signing key | +| Scopes | MealQuest requests `openid profile email groups offline_access`; the provider's scope mappings include openid, profile, email and offline_access, and the `groups` claim is delivered by a property mapping (the default profile mapping carries `groups`) | +| Application | name MealQuest, slug `mealquest`, provider `mealquest` | +| Group | `mealquest-admin`, bound to the application; household members are added to it | +| Client ID | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret; in `deploy/.env` as `MEALQUEST_OIDC_CLIENT_ID`) | +| Client Secret | in `deploy/secrets/oidc_client_secret` only | + +MealQuest refuses a sign-in unless the user's `groups` claim (from the ID +token, or from userinfo when the token has none) contains a group in +`MEALQUEST_OIDC_ALLOWED_GROUPS`. + +Check discovery from the Docker host — the service discovers the issuer +at boot and will not start if it cannot: + +```sh +curl -s -o /dev/null -w "%{http_code}\n" \ + https://auth.cubecraftlabs.com/application/o/mealquest/.well-known/openid-configuration # 200 +``` + +On 2026-10-05 discovery returned 200 with issuer +`https://auth.cubecraftlabs.com/application/o/mealquest/` and +`id_token_signing_alg_values_supported: ["RS256"]`. + +## Reverse proxy (Traefik) + +MealQuest is published at by the +shared Traefik instance on 10.60.1.33 (Feature 07, task 0c1dc172). The +route is a dynamic-configuration file that lives on the Traefik host only, +at `/home/overseer/Code/traefik/config/dynamic/mealquest-ccl.yml`. Its exact +contents: + +```yaml +# MealQuest - https://mealquest.cubecraftlabs.com +# Feature 07 task 0c1dc172. Single Go binary (API + embedded PWA) on +# office-srvr 10.60.1.14:8090. HTTP -> HTTPS is handled globally by +# http-conf.yml, so only a websecure router is defined here. +# This file lives on the Traefik host only; the repo copy is recorded in +# deploy/RUNBOOK.md by task 876134bc. +http: + routers: + mealquest: + entryPoints: + - websecure + rule: "Host(`mealquest.cubecraftlabs.com`)" + priority: 100 + middlewares: + - mealquest-headers + service: mealquest + tls: + certResolver: myresolver + domains: + - main: mealquest.cubecraftlabs.com + + services: + mealquest: + loadBalancer: + servers: + - url: "http://10.60.1.14:8090" + + middlewares: + mealquest-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + frameDeny: true +``` + +Traefik watches the dynamic directory, so saving the file loads the route; +the certificate comes from `certResolver: myresolver` (Let's Encrypt, +DNS-01 through Cloudflare). Check from the LAN: + +```sh +curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200 +``` + +## DNS + +| Where | Record | +| --- | --- | +| Cloudflare, zone `cubecraftlabs.com` | `CNAME mealquest.cubecraftlabs.com` → `cubecraftlabs.com`, **proxied = true** (orange cloud). Because it is proxied, public resolvers answer with Cloudflare's anycast addresses, A and AAAA (AAAA on 2026-10-05: `2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75`), never the origin | +| Pi-hole primary 10.60.1.101 (`dns.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) | +| Pi-hole secondary 10.60.1.85 (`dns2.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) | + +The local record must be on **both** Pi-holes, or clients using the +secondary go out through Cloudflare. Check: + +```sh +for s in 10.60.1.101 10.60.1.85; do dig +short @$s mealquest.cubecraftlabs.com A; done +# 10.60.1.33 from each +``` + +**AAAA caveat.** The Pi-hole local record is A only, so both Pi-holes still +forward AAAA queries and return Cloudflare's IPv6 addresses +(`2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75` on 2026-10-05). An +IPv6-capable LAN client may prefer those and reach MealQuest through +Cloudflare instead of directly through Traefik. It still works (Cloudflare +proxies to the public route), but it is not the LAN path. If that matters, +add a local AAAA override or block AAAA for this name on both Pi-holes. + +## Host firewall + +Docker publishes `10.60.1.14:8090` past the host's INPUT rules, so the +port is restricted in the `DOCKER-USER` chain instead: only Traefik +(10.60.1.33) and the host itself (10.60.1.14) may connect; every other +source is dropped. + +Docker DNATs the published port to the container address and port 8080 +**before** `DOCKER-USER`, so a plain `-p tcp --dport 8090` rule never +matches. The rules match the original destination `10.60.1.14:8090` with +conntrack (`--ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL`) +and are tagged with the comment `mealquest-firewall`. The port is never +open while the rules change: if the chain does not already start with +exactly the three rules, the script first inserts the DROP at position 1, +then deletes its other tagged rules and inserts the two RETURN rules above +the DROP. A re-run on a correct chain changes nothing. NestQuest's +8080/8081 rules below them are left untouched. + +| Repository file | Installed as | +| --- | --- | +| `deploy/systemd/mealquest-firewall.sh` | `/usr/local/sbin/mealquest-firewall` (root, 0755) | +| `deploy/systemd/mealquest-firewall.service` | `/etc/systemd/system/mealquest-firewall.service` (root, 0644) | + +The unit is `Type=oneshot`, `RemainAfterExit=yes`, `Requires=` and +`PartOf=docker.service`, and `WantedBy=multi-user.target docker.service`: +it re-applies the rules whenever Docker starts or restarts (Docker +recreates `DOCKER-USER` empty), and a failure fails this unit only. + +Install (done on 10.60.1.14 on 2026-10-05): + +```sh +cd /home/overseer/mealquest +sudo install -o root -g root -m 0755 deploy/systemd/mealquest-firewall.sh /usr/local/sbin/mealquest-firewall +sudo install -o root -g root -m 0644 deploy/systemd/mealquest-firewall.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now mealquest-firewall +``` + +Check: + +```sh +systemctl is-enabled mealquest-firewall; systemctl is-active mealquest-firewall # enabled, active +sudo iptables -S DOCKER-USER +``` + +Expected head of the chain (recorded 2026-10-05; NestQuest's rules follow): + +``` +-A DOCKER-USER -s 10.60.1.33/32 -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j RETURN +-A DOCKER-USER -s 10.60.1.14/32 -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j RETURN +-A DOCKER-USER -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j DROP +``` + +From any other LAN machine, `curl -m 5 http://10.60.1.14:8090/health` must +time out; from 10.60.1.33 and 10.60.1.14 it returns 200. + +## Stack unit + +`deploy/systemd/mealquest.service` owns the compose stack and is the only +way it starts. Both compose services use `restart: "no"`, so Docker never +starts them on its own; the unit has `Requires=` and +`After=mealquest-firewall.service`, so the stack starts only after the +firewall rules are applied, and not at all if that unit fails. It is +`PartOf=docker.service`, so restarting Docker (which recreates +`DOCKER-USER` empty) stops the stack, re-applies the firewall, then starts +the stack again. `ExecStart` is `docker compose … up -d` and `ExecStop` is +`docker compose … down`; neither builds, so build before starting. + +Containers no longer restart after a crash; `systemctl restart mealquest` +brings them back. + +| Repository file | Installed as | +| --- | --- | +| `deploy/systemd/mealquest.service` | `/etc/systemd/system/mealquest.service` (root, 0644) | + +Install (done on 10.60.1.14 on 2026-10-05): + +```sh +cd /home/overseer/mealquest +sudo install -o root -g root -m 0644 deploy/systemd/mealquest.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now mealquest +``` + +Check: + +```sh +systemctl is-enabled mealquest; systemctl is-active mealquest # enabled, active +systemctl show mealquest -p Requires -p After # both list mealquest-firewall.service +``` + +Start, stop and restart the whole stack with `sudo systemctl start|stop|restart +mealquest`, never with `docker compose up` or `down`. Per-service compose +commands (`stop mealquest`, `up -d --force-recreate litestream`) are fine +while the unit is active, because the firewall is already in place. + +## Routine deploy and rollback + +Production runs `main` (a release); only `overseer` merges `dev` → `main`. +Every deploy builds the image on the host from the checked-out commit. + +### Deploy + +```sh +cd /home/overseer/mealquest +git fetch origin +git rev-parse --short HEAD # the SHA being replaced: note it for rollback +git checkout --detach origin/main # or a release tag +docker compose -f deploy/compose.yaml build +sudo systemctl restart mealquest # applies the firewall, then down + up -d +docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up +curl -fsS http://10.60.1.14:8090/health +curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200 +git rev-parse HEAD # the deployed SHA +``` + +Record the deployed SHA, the previous SHA and the date in the Maestro task +for the release. Migrations run at start-up; the service refuses to start +if the database has an applied migration it does not know. + +If `deploy/.env.example` gained a variable, add it to `deploy/.env` before +the restart; the service fails fast at boot naming any missing required variable. + +### Rollback to a previous SHA + +```sh +cd /home/overseer/mealquest +git checkout --detach +docker compose -f deploy/compose.yaml build +sudo systemctl restart mealquest +docker compose -f deploy/compose.yaml ps +curl -fsS http://10.60.1.14:8090/health +``` + +A rollback across a release that added a migration fails at start-up +(the older binary refuses the newer schema). In that case restore the +database to a point before the upgrade — "Restore the database" with +`-timestamp` set just before the deploy — then start the older SHA. +Changes made since that point are lost; say so to the household. + +## Backups + +### Database — Litestream + +The `litestream` compose service streams every committed change to +`mealquest.db` to the NAS over SFTP (`deploy/litestream.yml`). It takes a +full snapshot daily (compaction level 9) and keeps a week of snapshots +plus the incremental LTX files between them. + +One-time setup (already done on 10.60.1.14): + +1. Generate the dedicated keypair, git-ignored, owned by the container + user: + + ```sh + cd /home/overseer/mealquest + ssh-keygen -t ed25519 -N '' -C mealquest-litestream -f deploy/secrets/litestream_key + # append deploy/secrets/litestream_key.pub to joshua@10.60.1.50:~/.ssh/authorized_keys + rm deploy/secrets/litestream_key.pub + sudo chown 10001:10001 deploy/secrets/litestream_key + sudo chmod 0600 deploy/secrets/litestream_key + ``` + +2. The NAS host key is pinned in `deploy/litestream.yml` (`host-key`). + +Check replication — list every LTX file at every level. Level 9 holds the +full snapshots; the default listing (`-level 0`) shows only L0 files: + +```sh +docker compose -f deploy/compose.yaml exec -T litestream \ + litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db +``` + +### Media — nightly rsync + +`mealquest-media-backup.timer` runs `mealquest-media-backup.service` at +03:30 every night. The service runs `/usr/local/sbin/mealquest-media-backup` +(`deploy/systemd/mealquest-media-backup.sh`) as the unprivileged host user +`mealquest` (uid/gid 10001, the app's own user) with no capabilities. +systemd bind-mounts the data volume read-only at `/run/mealquest-data` and +passes the Litestream key in as the credential `nas_key`; uid 10001 cannot +otherwise reach `/var/lib/docker` or `deploy/secrets/`. The script refuses a +`media` path that is a symlink and fails (exit 1) if `media` is missing. + +One-time setup (already done on 10.60.1.14): + +```sh +cd /home/overseer/mealquest +# Host user for the backup; ssh needs a passwd entry for its uid. +sudo groupadd --system --gid 10001 mealquest +sudo useradd --system --uid 10001 --gid 10001 --no-create-home \ + --home-dir /nonexistent --shell /usr/sbin/nologin mealquest +# Pinned NAS host keys, root-owned and world-readable. +sudo install -d -o root -g root -m 0755 /etc/mealquest +sudo install -o root -g root -m 0644 deploy/known_hosts /etc/mealquest/known_hosts +# The media directory; the backup fails if it is ever missing. +sudo install -d -o 10001 -g 10001 -m 0755 /var/lib/docker/volumes/mealquest-data/_data/media +# Script and units. +sudo install -o root -g root -m 0755 deploy/systemd/mealquest-media-backup.sh /usr/local/sbin/mealquest-media-backup +sudo install -o root -g root -m 0644 deploy/systemd/mealquest-media-backup.service \ + deploy/systemd/mealquest-media-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now mealquest-media-backup.timer +``` + +Run it by hand and read the result: + +```sh +sudo systemctl start mealquest-media-backup.service +journalctl -u mealquest-media-backup --no-pager -n 30 +``` + +To restore media, follow "Restore photos (media)" — never copy back while +the timer or the service can still run. + +## Restore the database + +SQLite in WAL mode keeps recent commits in `mealquest.db-wal`, indexed by +`mealquest.db-shm`. **A restored database must never sit beside an old WAL +or shared-memory file**: on open, SQLite would replay the old WAL's pages +onto the restored database and corrupt it or mix in state from the +database it replaced. So all three files are moved aside together, with +Litestream's local state directory `.mealquest.db-litestream`, before the +restore — and the restore runs into an empty volume. + +Run from `/home/overseer/mealquest`: + +```sh +D=/var/lib/docker/volumes/mealquest-data/_data +A=/var/tmp/mealquest-restore-aside-$(date +%Y%m%d%H%M) + +# 1. Stop the whole stack (app and Litestream). Nothing may hold the +# database open; a clean close checkpoints the WAL into mealquest.db. +sudo systemctl stop mealquest + +# 2. Move the database, its -wal and -shm files and Litestream's state +# aside — all of them, every time. Keep the directory until the +# restored stack is verified. +sudo mkdir "$A" +for f in mealquest.db mealquest.db-wal mealquest.db-shm .mealquest.db-litestream; do + if sudo test -e "$D/$f"; then sudo mv "$D/$f" "$A/"; fi +done +sudo ls -la "$D" # must list only media/ + +# 3. Restore from the NAS. --no-deps keeps the app stopped so it cannot +# create a fresh, empty database first. +docker compose -f deploy/compose.yaml run --rm --no-deps litestream \ + restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db +sudo ls -la "$D" # mealquest.db only: no -wal, no -shm + +# 4. Check the restored file before starting the app (see "Count check"). + +# 5. Start the stack and check health. +sudo systemctl start mealquest +docker compose -f deploy/compose.yaml ps +curl -s -o /dev/null -w "health %{http_code}\n" http://10.60.1.14:8090/health +``` + +To restore to a point in time, add `-timestamp 2026-10-05T23:00:00Z` (or +`-txid`) to the `restore` command. + +### Count check + +`/tmp/mqcount.py` prints row counts for key tables, the number of +application tables, a SHA-256 over every application row (first 16 hex +digits; Litestream's own `_litestream*` tables are excluded) and +`PRAGMA integrity_check`. Run it on a **copy** of the database, never on the +live file; copy `-wal`/`-shm` along with it when the app is running. + +```python +import sqlite3, sys, hashlib +c = sqlite3.connect(sys.argv[1]) +tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")] +for t in ("dinners", "week_plans", "schema_migrations"): + print(t, c.execute(f"select count(*) from {t}").fetchone()[0]) +h = hashlib.sha256() +for t in tabs: + for row in c.execute(f"select * from \"{t}\" order by rowid"): + h.update(repr(row).encode()) +print("tables", len(tabs), "content-sha256", h.hexdigest()[:16]) +print("integrity", c.execute("pragma integrity_check").fetchone()[0]) +``` + +```sh +rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck +sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/" && sudo chown -R "$USER" /tmp/mqcheck +python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db +``` + +Before and after must match exactly. + +## Restore photos (media) + +Recipe and dinner photos live in `media/` in the data volume and are +backed up nightly to `joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/` +(see "Media — nightly rsync"). The backup service mirrors with +`rsync --delete`. **An `rsync --delete` run must never see an empty or +partial `media/`**: it would delete the NAS copy you are about to restore +from. Stopping the timer is not enough — it does not stop a run that is +already in flight, and the service can still be started by hand. So stop +**both** the timer and the service, confirm no rsync is running, and do +not start the service until the restore is verified. + +Run from `/home/overseer/mealquest`: + +```sh +M=/var/lib/docker/volumes/mealquest-data/_data/media +NAS=/volume1/homes/Joshua/mealquest-backups/media +SSH="ssh -i deploy/secrets/litestream_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/mealquest/known_hosts -o StrictHostKeyChecking=yes" + +# 1. Stop the nightly mirror: the timer AND any run in flight. +sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service + +# 2. Pre-check — all three must hold before media/ is touched. If any +# fails, stop here and find out why. +systemctl is-active mealquest-media-backup.timer # inactive +systemctl is-active mealquest-media-backup.service # inactive (or failed) +pgrep -a -u mealquest rsync || echo "no rsync" # no rsync +sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l" +# ^ note this NAS file count; it must be non-zero and is the target below. + +# 3. Stop the app (no writes into media/ meanwhile). +docker compose -f deploy/compose.yaml stop mealquest + +# 4. Recreate the directory if it is gone, owned by the app user. +sudo install -d -o 10001 -g 10001 -m 0755 "$M" + +# 5. Copy back from the NAS as uid 10001's files. No --delete in this +# direction: the NAS copy is the source and is only read. +sudo rsync -a --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/" + +# 6. Post-condition — before anything can mirror again: +sudo find "$M" -type f | wc -l # >= the NAS count from step 2 +sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l" # unchanged from step 2 + +# 7. Start the app, then the timer again. Only now may the service run. +docker compose -f deploy/compose.yaml up -d +curl -fsS http://10.60.1.14:8090/health +sudo systemctl start mealquest-media-backup.timer +systemctl is-active mealquest-media-backup.timer # active +``` + +The local count may exceed the NAS count only if `media/` still held +files the NAS never received (photos added since the last nightly run); +the next nightly run then copies them up. If the local count is lower, +the copy did not complete: leave the timer stopped and re-run step 5. + +When the database is restored too, restore it first ("Restore the +database"), then the media, so every photo the database refers to is +present. This media restore has not been run against production yet; the +backup direction was checked on 2026-10-05 (see "Media backup checks"). + +## Restore drill record — 2026-10-05 + +Drill on 10.60.1.14 against the live (pre-launch, empty-household) +database, Litestream 0.5.17. Commands as in "Restore the database"; +output trimmed only of compose progress lines. + +Replica before the drill (`ltx -level all`; level 9 is the full snapshot): + +``` +level min_txid max_txid size created +0 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z +1 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z +1 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z +2 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z +2 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z +3 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z +9 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z +``` + +1. `docker compose -f deploy/compose.yaml down` — both containers stopped + and removed. The volume then held `mealquest.db`, + `.mealquest.db-litestream/` and `media/`; the clean close had + checkpointed and removed `-wal` and `-shm`. +2. Before (copy of the stopped database): + + ``` + dinners 0 + week_plans 0 + schema_migrations 7 + tables 26 content-sha256 836e230e16c58dc7 + integrity ok + ``` + +3. Moved aside to `/var/tmp/mealquest-restore-aside-20261005`: + `mealquest.db` and `.mealquest.db-litestream` (no `-wal`/`-shm` existed + after the clean stop; the loop moves them whenever they do). Volume + afterwards: `media/` only. +4. `docker compose -f deploy/compose.yaml run --rm --no-deps litestream restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db` + — volume afterwards: `mealquest.db` (258048 bytes) and `media/`; no + `-wal`, no `-shm`. +5. After (copy of the restored file, before the app started): + + ``` + dinners 0 + week_plans 0 + schema_migrations 7 + tables 26 content-sha256 836e230e16c58dc7 + integrity ok + ``` + +6. `docker compose -f deploy/compose.yaml up -d` — `mealquest-mealquest-1` + healthy, `mealquest-litestream-1` up; `GET /health` → `200`. Counts on a + copy of the running database (`mealquest.db`, `-wal`, `-shm`) were + identical to the above. +7. Litestream resumed against the restored database: + + ``` + level=INFO msg="detected database behind replica" db=mealquest.db db_txid=0000000000000000 replica_txid=0000000000000003 + level=INFO msg="fetched latest L0 file from replica" db=mealquest.db min_txid=0000000000000003 max_txid=0000000000000003 + level=INFO msg="compaction complete" db=mealquest.db level=1 txid.min=0000000000000004 txid.max=0000000000000004 size=14110 + ``` + + Replica after the drill (`ltx -level all`): + + ``` + level min_txid max_txid size created + 0 0000000000000004 0000000000000004 14110 2026-10-05T23:27:21Z + 1 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z + 1 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z + 1 0000000000000004 0000000000000004 14110 2026-10-05T23:27:21Z + 2 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z + 2 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z + 3 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z + 9 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z + ``` + +The first drill earlier the same day (before `-level all` was recorded) +gave the same before/after result: checksum `836e230e16c58dc7`, 26 tables, +integrity ok. + +### Media backup checks — 2026-10-05 + +`sudo systemctl start mealquest-media-backup.service` on 10.60.1.14: + +| Case | Result | +| --- | --- | +| Normal run | `Result=success`; journal: `synced /run/mealquest-data/media to joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/ as uid 10001` | +| A test file added | `Number of regular files transferred: 1`; removing it then gave `Number of deleted files: 1 (reg: 1)` | +| `media` replaced by a symlink to `/etc` | `Result=exit-code`, status 1; `/run/mealquest-data/media is a symlink to /etc; refusing to back it up` | +| `media` removed | `Result=exit-code`, status 1; `media directory /run/mealquest-data/media is missing` | +| `sudo -u mealquest` writing into the volume via `/var/lib/docker/...` | `Permission denied` — uid 10001 cannot traverse `/var/lib/docker` (mode 0710 root) | + +## Secret rotation + +Rotate a secret when it may have leaked, when someone with access leaves, +or on the owner's schedule. Never paste a secret into a ticket, chat or +this file. + +### App key (`deploy/secrets/secret_key`) + +The app key encrypts every stored secret: the `_ct` columns +`ai_settings.api_key_ct` (LLM API key), `recipe_search_settings.api_key_ct` +(recipe-search API key), `integrations_google.refresh_token_ct` (Google +Calendar refresh token) and `chat_settings.webhook_url_ct` (Google Chat +webhook URL). **Rotating it re-encrypts nothing**: a value encrypted with +the old key can no longer be read. A `mealquest rotate-key` helper that +re-encrypts in place is planned but does not exist yet. + +**What is recoverable today.** The service currently exposes only +household settings; no shipped flow writes or re-enters any of these four +secrets. The re-entry flows are planned: the LLM key in Settings → AI +assistant (the AI settings feature), the recipe-search key (Feature 10), +Google Calendar reconnect (Feature 16) and the Google Chat webhook +(Feature 19). So today the rotation is safe **only if no `_ct` value is +stored**, which the pre-check below proves. If the pre-check finds a +stored value, do not rotate: it could not be re-entered afterwards. Keep +the current key and wait for the matching flow (or the `rotate-key` +helper). + +**The pre-check and the key replacement run with the whole stack +stopped**, from the pre-check until the new key is in place. A raw copy of +`mealquest.db` and `mealquest.db-wal` taken while the stack runs is **not** +a consistent snapshot: the two files are copied one after the other, and a +checkpoint (by the app or by Litestream) between the two copies can move +committed pages out of the WAL into `mealquest.db` after `mealquest.db` was +already copied, then reset the WAL before it is copied. The copy then +lacks those commits — possibly a stored `_ct` value — and the pre-check +reports a false 0. A running app could also store a `_ct` value after a +correct pre-check and before the key is replaced. Stopping both containers +removes every writer (Litestream also writes and checkpoints), and the +clean close checkpoints the WAL into `mealquest.db`, so the file alone is +complete. + +Run from `/home/overseer/mealquest`: + +```sh +D=/var/lib/docker/volumes/mealquest-data/_data + +# 1. Stop the whole stack (app and Litestream) and leave it stopped until +# step 4. Nothing may hold the database open. +sudo systemctl stop mealquest +sudo ls -la "$D" # mealquest.db and media/ only: no -wal, no -shm; if + # either exists, the close was not clean — stop here + +# 2. Pre-check, on a copy of the now-quiescent database. +rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck +sudo cp -a "$D/mealquest.db" /tmp/mqcheck/ && sudo chown -R "$USER" /tmp/mqcheck +python3 - /tmp/mqcheck/mealquest.db <<'PY' +import sqlite3, sys +c = sqlite3.connect(sys.argv[1]) +for t, col in (("ai_settings", "api_key_ct"), ("recipe_search_settings", "api_key_ct"), + ("integrations_google", "refresh_token_ct"), ("chat_settings", "webhook_url_ct")): + print(t, col, c.execute(f"select count(*) from {t} where {col} is not null").fetchone()[0]) +PY +rm -rf /tmp/mqcheck +# every count must be 0; otherwise start the stack again on the current +# key (sudo systemctl start mealquest) and stop here + +# 3. Replace the key, still with the stack stopped. +sudo cp -a deploy/secrets/secret_key /var/tmp/secret_key.old # keep until verified, then shred +head -c 32 /dev/urandom | base64 | sudo tee deploy/secrets/secret_key >/dev/null +sudo chown 10001:10001 deploy/secrets/secret_key +sudo chmod 600 deploy/secrets/secret_key + +# 4. Start the stack on the new key and check health. +sudo systemctl start mealquest +docker compose -f deploy/compose.yaml ps +curl -fsS http://10.60.1.14:8090/health +``` + +Then sign in and open household settings to confirm the service runs on +the new key. Finally `sudo shred -u /var/tmp/secret_key.old`. Once the +planned flows ship, this section gains a step per secret to re-enter it +and confirm it works. + +### OIDC client secret (`deploy/secrets/oidc_client_secret`) + +In Authentik, open the provider `mealquest` and regenerate the Client +Secret. Write it to the file (same owner and mode as above) and recreate +the service: + +```sh +printf '%s\n' '' | sudo tee deploy/secrets/oidc_client_secret >/dev/null +sudo chown 10001:10001 deploy/secrets/oidc_client_secret +sudo chmod 600 deploy/secrets/oidc_client_secret +docker compose -f deploy/compose.yaml up -d --force-recreate mealquest +``` + +Sign in at to confirm; sign-ins +fail between regenerating the secret in Authentik and recreating the +service. + +### LLM API key + +Stored encrypted in the database, not in `deploy/`. In the app, open +**Settings → AI assistant**, choose **Replace**, paste the new key and +**Verify**, and check the verified state is shown. Then revoke the +old key in the provider's console. (The AI assistant settings page is +delivered by its own feature; until it ships there is no stored LLM key +to rotate.) + +### VAPID keys (Web Push) + +Planned with Web Push (Feature 19): the public key goes in +`deploy/.env` as `MEALQUEST_VAPID_PUBLIC_KEY`, the private key in +`deploy/secrets/vapid_private_key` (owner `10001:10001`, mode 0600), and +the pair is generated with `cmd/vapidgen`. **Changing the VAPID pair +invalidates every push subscription**: each device must turn +notifications on again (`/settings/notifications`) before it receives +pushes. Rotate only when the private key may have leaked, write both +halves, recreate the service with `--force-recreate mealquest`, and tell +the household to re-enable notifications on each device. + +### Litestream / NAS key (`deploy/secrets/litestream_key`) + +Generate a new keypair as in "Database — Litestream", add the new public +key to `joshua@10.60.1.50:~/.ssh/authorized_keys`, replace the file +(owner `10001:10001`, mode 0600), run +`docker compose -f deploy/compose.yaml up -d --force-recreate litestream` +and `sudo systemctl start mealquest-media-backup.service`, check both +(`litestream ltx` and the journal), then remove the old public key from +the NAS. + +## Context records + +The confirmed values in this runbook (port 8090, checkout path, Authentik +group `mealquest-admin`, DNS, firewall, backup target) are also recorded in +the CTXD project `mealquest`: `RUNBOOKS.MD` (deployment procedure) and the +open questions in `CONTEXT.MD`, updated for Feature 07 task 876134bc. diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..c5bae84 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,100 @@ +# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host, +# then start through systemd: +# +# docker compose -f deploy/compose.yaml build +# sudo systemctl restart mealquest +# +# The services use restart: "no" on purpose: Docker must not start them +# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service +# owns start and stop and requires mealquest-firewall.service first. +# +# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets +# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets +# with their host owner and mode, so each file in deploy/secrets/ must be +# owned by the container user 10001:10001 with mode 0600. +name: mealquest + +services: + mealquest: + build: + context: .. + dockerfile: Dockerfile + user: "10001:10001" + restart: "no" + env_file: + - path: .env + required: true + # Fixed by this file's container shape (listen port, volume, secret + # mounts); deploy/.env must not set them. + environment: + MEALQUEST_HTTP_ADDR: ":8080" + MEALQUEST_DATA_DIR: /data + MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key + MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret + secrets: + - secret_key + - oidc_client_secret + volumes: + - mealquest-data:/data + ports: + - "${MEALQUEST_BIND_ADDRESS:?set MEALQUEST_BIND_ADDRESS in deploy/.env}:8090:8080" + networks: + - mealquest + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + # Litestream streams the database to the Synology NAS over SFTP + # (config: deploy/litestream.yml). The SFTP private key + # deploy/secrets/litestream_key is a dedicated keypair, owned by + # 10001:10001 with mode 0600 like the other secrets. + # + # The data volume is mounted read-write on purpose: Litestream writes its + # tracking tables into the database and checkpoints the WAL. It runs as + # the app's own user (owner of the volume) with a read-only root + # filesystem, no privilege escalation and no Linux capabilities. + # + # Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the + # stack, move the database with its -wal and -shm aside, restore, check). + litestream: + image: litestream/litestream:0.5.17 + user: "10001:10001" + restart: "no" + command: ["replicate", "-config", "/etc/litestream.yml"] + depends_on: + mealquest: + condition: service_healthy + volumes: + - mealquest-data:/data + - ./litestream.yml:/etc/litestream.yml:ro + - ./secrets/litestream_key:/etc/litestream/sftp_key:ro + networks: + - mealquest + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +secrets: + secret_key: + file: ./secrets/secret_key + oidc_client_secret: + file: ./secrets/oidc_client_secret + +volumes: + mealquest-data: + name: mealquest-data + +networks: + mealquest: + name: mealquest diff --git a/deploy/known_hosts b/deploy/known_hosts new file mode 100644 index 0000000..1b17352 --- /dev/null +++ b/deploy/known_hosts @@ -0,0 +1,6 @@ +# Host keys of the Synology NAS (10.60.1.50), the backup target. Installed +# root-owned with mode 0644 as /etc/mealquest/known_hosts and pinned by the +# media backup (UserKnownHostsFile, StrictHostKeyChecking=yes). The ECDSA +# key is also pinned in deploy/litestream.yml (host-key). +10.60.1.50 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKHxIfgjavBABMRE5GW/EFY4C4gO6mo0sKbhkUW2NcAo +10.60.1.50 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY= diff --git a/deploy/litestream.yml b/deploy/litestream.yml new file mode 100644 index 0000000..a825cea --- /dev/null +++ b/deploy/litestream.yml @@ -0,0 +1,31 @@ +# Litestream continuous backup of the MealQuest SQLite database (Feature +# 07). The ``litestream`` compose service mounts the same named volume as +# the app and streams every committed change to the Synology NAS over +# SFTP, so the backup lives outside the Docker host. +# +# Nothing here is secret. The SFTP private key is a dedicated, git-ignored +# keypair (deploy/secrets/litestream_key) mounted read-only by compose at +# /etc/litestream/sftp_key; its public half is in +# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the NAS's +# ECDSA host key, the algorithm Litestream's SSH client negotiates with +# this NAS. +# +# The Synology SFTP server is chrooted to the shared folders, so the +# directory seen over SSH as /volume1/homes/Joshua/mealquest-backups is +# /homes/Joshua/mealquest-backups over SFTP. + +# Take a full snapshot daily and keep a week of snapshots plus the +# incremental changes between them (point-in-time restore window). +snapshot: + interval: 24h + retention: 168h + +dbs: + - path: /data/mealquest.db + replicas: + - type: sftp + host: 10.60.1.50 + user: joshua + path: /homes/Joshua/mealquest-backups + key-path: /etc/litestream/sftp_key + host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY= diff --git a/deploy/systemd/mealquest-firewall.service b/deploy/systemd/mealquest-firewall.service new file mode 100644 index 0000000..c7c5f8e --- /dev/null +++ b/deploy/systemd/mealquest-firewall.service @@ -0,0 +1,22 @@ +# Applies the MealQuest DOCKER-USER rules (/usr/local/sbin/mealquest-firewall). +# +# PartOf= re-runs this unit whenever docker.service is stopped or restarted, +# and WantedBy=docker.service starts it whenever docker.service starts. Both +# are one-way: a failure here fails this unit visibly and never fails +# docker.service. +# +# Start MealQuest only after this unit has succeeded: +# systemctl start mealquest-firewall && docker compose up -d +[Unit] +Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER) +After=docker.service +Requires=docker.service +PartOf=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/mealquest-firewall + +[Install] +WantedBy=multi-user.target docker.service diff --git a/deploy/systemd/mealquest-firewall.sh b/deploy/systemd/mealquest-firewall.sh new file mode 100755 index 0000000..788baa8 --- /dev/null +++ b/deploy/systemd/mealquest-firewall.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Restrict the MealQuest published host port (10.60.1.14:8090) to Traefik +# (10.60.1.33) and the Docker host itself (10.60.1.14); drop every other +# source. +# +# DOCKER-USER runs after Docker's DNAT, so the destination there is the +# container address and port, not the published ones. Match the original +# destination address and port with conntrack instead of -d/--dport. +# +# The rules must sit at the top of DOCKER-USER, ahead of any other project's +# rules (an earlier RETURN or DROP would decide MealQuest traffic first). +# The port is never left open while the rules are updated: +# +# 1. If the chain already starts with exactly the three rules below and +# holds no other mealquest-firewall rule, change nothing. +# 2. Otherwise insert the DROP at position 1 first, so the port is closed +# to every source (including Traefik, briefly) from here on. +# 3. Delete every other rule tagged mealquest-firewall, by rule number, +# highest first. +# 4. Insert the two RETURN rules above the DROP. +# +# Re-runs leave the same ordered rules at positions 1-3 and no duplicates. +# Installed as /usr/local/sbin/mealquest-firewall. +set -eu + +chain=DOCKER-USER +tag="-m comment --comment mealquest-firewall" +match="-m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL" + +# The three rules as iptables -S prints them, in their required order. +want="-A $chain -s 10.60.1.33/32 $match $tag -j RETURN +-A $chain -s 10.60.1.14/32 $match $tag -j RETURN +-A $chain $match $tag -j DROP" + +rules=$(iptables -w -S "$chain" | grep -e '^-A ' || true) +head=$(printf '%s\n' "$rules" | head -n 3) +tagged=$(printf '%s\n' "$rules" | grep -c -e '--comment mealquest-firewall ' || true) +if [ "$head" = "$want" ] && [ "$tagged" -eq 3 ]; then + exit 0 +fi + +iptables -w -I "$chain" 1 $match $tag -j DROP + +iptables -w -S "$chain" | grep -e '^-A ' | + awk '/--comment mealquest-firewall / { print NR }' | + sort -rn | while read -r num; do + if [ "$num" -ne 1 ]; then + iptables -w -D "$chain" "$num" + fi +done + +iptables -w -I "$chain" 1 -s 10.60.1.14/32 $match $tag -j RETURN +iptables -w -I "$chain" 1 -s 10.60.1.33/32 $match $tag -j RETURN diff --git a/deploy/systemd/mealquest-media-backup.service b/deploy/systemd/mealquest-media-backup.service new file mode 100644 index 0000000..9f5a61c --- /dev/null +++ b/deploy/systemd/mealquest-media-backup.service @@ -0,0 +1,29 @@ +# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup). +# Triggered by mealquest-media-backup.timer; run by hand with +# systemctl start mealquest-media-backup +# and read the result with +# journalctl -u mealquest-media-backup +# +# The script runs as the app's user (10001, host user "mealquest") with no +# capabilities. systemd, as root, only bind-mounts the data volume's root +# (a root-owned path the app cannot replace) read-only at +# /run/mealquest-data and copies the NAS key into the credentials directory. +[Unit] +Description=Back up MealQuest media to the Synology NAS +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/mealquest-media-backup +User=mealquest +Group=mealquest +LoadCredential=nas_key:/home/overseer/mealquest/deploy/secrets/litestream_key +BindReadOnlyPaths=/var/lib/docker/volumes/mealquest-data/_data:/run/mealquest-data +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +NoNewPrivileges=yes +CapabilityBoundingSet= +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX diff --git a/deploy/systemd/mealquest-media-backup.sh b/deploy/systemd/mealquest-media-backup.sh new file mode 100755 index 0000000..f4ee14d --- /dev/null +++ b/deploy/systemd/mealquest-media-backup.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Nightly copy of the MealQuest media directory (recipe and dinner photos) +# to the Synology NAS. The database itself is replicated continuously by +# Litestream; this covers the files beside it. +# +# Runs as the unprivileged app user (10001, host user "mealquest"), never +# root: mealquest-media-backup.service bind-mounts the data volume +# read-only at /run/mealquest-data and hands the NAS key in as a systemd +# credential. Anything the app could plant in the volume is therefore read +# with the app's own permissions only. +# +# The script changes into the media directory and checks that the physical +# path is the expected one, so a media path that is a symlink is refused. +# rsync then copies "./" — the directory the shell is already in — so +# swapping the path afterwards cannot redirect it. Symlinks inside the +# directory are copied as links (-a), never followed. +# +# The media directory is created at deploy time (deploy/RUNBOOK.md). If it +# is missing the job fails; it is never treated as "nothing to back up". +# +# rsync mirrors into .../mealquest-backups/media/ only: the target ends in +# a slash, so --delete removes files from that directory and nothing +# outside it. Re-runs transfer only what changed. Output goes to the +# journal via mealquest-media-backup.service. +set -eu + +src=/run/mealquest-data/media +dest=joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/ +key=$CREDENTIALS_DIRECTORY/nas_key +known_hosts=/etc/mealquest/known_hosts + +if ! cd "$src"; then + echo "mealquest-media-backup: media directory $src is missing" >&2 + exit 1 +fi +if [ "$(pwd -P)" != "$src" ]; then + echo "mealquest-media-backup: $src is a symlink to $(pwd -P); refusing to back it up" >&2 + exit 1 +fi + +rsync -a --delete --stats \ + -e "ssh -F none -i $key -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=$known_hosts -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes" \ + ./ "$dest" +echo "mealquest-media-backup: synced $src to $dest as uid $(id -u)" diff --git a/deploy/systemd/mealquest-media-backup.timer b/deploy/systemd/mealquest-media-backup.timer new file mode 100644 index 0000000..62fe968 --- /dev/null +++ b/deploy/systemd/mealquest-media-backup.timer @@ -0,0 +1,11 @@ +# Nightly trigger for mealquest-media-backup.service. Persistent= runs a +# missed backup at the next boot. +[Unit] +Description=Nightly MealQuest media backup + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/mealquest.service b/deploy/systemd/mealquest.service new file mode 100644 index 0000000..4db5d55 --- /dev/null +++ b/deploy/systemd/mealquest.service @@ -0,0 +1,22 @@ +# Owns the MealQuest compose stack (deploy/compose.yaml). The services use +# restart: "no", so Docker never starts them on its own; this unit is the +# only start path. +# +# Requires= and After=mealquest-firewall.service start the stack only after +# the DOCKER-USER rules are applied, and not at all if that unit fails. +# PartOf=docker.service propagates a Docker stop or restart here, so a +# Docker restart re-applies the firewall before the stack comes back. +[Unit] +Description=MealQuest compose stack +Requires=mealquest-firewall.service +After=mealquest-firewall.service docker.service +PartOf=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/docker compose -f /home/overseer/mealquest/deploy/compose.yaml up -d +ExecStop=/usr/bin/docker compose -f /home/overseer/mealquest/deploy/compose.yaml down + +[Install] +WantedBy=multi-user.target diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fc277a5..fbbfdd5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -371,6 +371,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. hard-coded hex. Self-hosted Bagel Fat One and Figtree. - Tests: Vitest, Testing Library, jsdom. - Offline cache for the current plan, shopping list and recipes. +- Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage", + "cache"`, wiping the offline marker, the service worker and its caches; the + worker leaves that request to the browser, as it does every network-only one. | Route | Screen | | --- | --- | diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d82d33d..d227ad8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -327,8 +327,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { // logout ends the session, clears the cookie and sends the browser to the // provider's end_session_endpoint to end the provider session as well. +// Clear-Site-Data wipes this origin's storage and caches — the PWA's +// verified-session marker in localStorage and the service worker's cached API +// and media responses — so an offline visit after logout cannot reopen +// household data. func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Clear-Site-Data", `"storage", "cache"`) if err := a.sessions.EndSession(w, r); err != nil { a.logger.ErrorContext(r.Context(), "end session", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 3f86f02..aff9f76 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -440,6 +440,9 @@ func TestLogout(t *testing.T) { if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") { t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw) } + if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` { + t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got) + } rows := h.rows(t) if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) { @@ -461,6 +464,9 @@ func TestLogoutWithoutSession(t *testing.T) { t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location")) } assertSessionCookieAttributes(t, sessionSetCookie(t, resp)) + if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` { + t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got) + } } func TestLogoutIsPostOnly(t *testing.T) { diff --git a/web/index.html b/web/index.html index 46ee35a..b6b78f5 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,8 @@ + + MealQuest diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..f9d92d4 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "MealQuest", + "short_name": "MealQuest", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#5a358c", + "background_color": "#f9f6f1", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/package-lock.json b/web/package-lock.json index 28508c6..4b63c0c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "jsdom": "^30.1.1", "typescript": "^7.0.2", "vite": "^8.3.2", + "vite-plugin-pwa": "^2.0.0", "vitest": "^5.0.3" } }, @@ -34,6 +35,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", @@ -82,6 +100,300 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -92,6 +404,1181 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -102,6 +1589,54 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -273,6 +1808,38 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -283,6 +1850,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -301,6 +1879,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@oxc-project/types": { "version": "0.152.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", @@ -591,6 +2189,532 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", + "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", + "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", + "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", + "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", + "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", + "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", + "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", + "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", + "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", + "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", + "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", + "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", + "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", + "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", + "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", + "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", + "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", + "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", + "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", + "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", + "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", + "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", + "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", + "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", + "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tanstack/query-core": { "version": "5.104.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", @@ -701,6 +2825,32 @@ } } }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -753,6 +2903,20 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1161,6 +3325,36 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1194,6 +3388,45 @@ "dequal": "^2.0.3" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1204,6 +3437,114 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", @@ -1214,6 +3555,131 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -1224,6 +3690,30 @@ "node": ">=18" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1237,6 +3727,48 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1294,6 +3826,78 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1301,6 +3905,52 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1328,6 +3978,44 @@ "dev": true, "license": "MIT" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, "node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", @@ -1341,6 +4029,114 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1348,6 +4144,66 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1358,6 +4214,29 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1368,6 +4247,37 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1386,6 +4296,95 @@ } } }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1401,6 +4400,280 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-encoding-sniffer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", @@ -1414,6 +4687,13 @@ "node": "^22.13.0 || >=24.0.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -1424,6 +4704,268 @@ "node": ">=8" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1431,6 +4973,222 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1478,6 +5236,72 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1751,6 +5575,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -1796,6 +5627,16 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -1813,6 +5654,39 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -1832,6 +5706,60 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -1846,6 +5774,32 @@ "node": ">=12.20.0" } }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -1859,6 +5813,40 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1879,6 +5867,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1908,6 +5906,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -1997,6 +6008,108 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.5.0.tgz", + "integrity": "sha512-1s+CxXjJ71i7LNVy8nYwg47XV4y6lAnOEhsMtSKq3WpmPfhl9LuKlTqKIn/3yUpE8lBz+W+TT88Bxyhnr0U4yw==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.3.0.tgz", + "integrity": "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.2.tgz", + "integrity": "sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.3.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2007,6 +6120,28 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rolldown": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -2041,6 +6176,107 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, + "node_modules/rollup": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", + "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.0", + "@rollup/rollup-android-arm64": "4.64.0", + "@rollup/rollup-darwin-arm64": "4.64.0", + "@rollup/rollup-darwin-x64": "4.64.0", + "@rollup/rollup-freebsd-arm64": "4.64.0", + "@rollup/rollup-freebsd-x64": "4.64.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", + "@rollup/rollup-linux-arm-musleabihf": "4.64.0", + "@rollup/rollup-linux-arm64-gnu": "4.64.0", + "@rollup/rollup-linux-arm64-musl": "4.64.0", + "@rollup/rollup-linux-loong64-gnu": "4.64.0", + "@rollup/rollup-linux-loong64-musl": "4.64.0", + "@rollup/rollup-linux-ppc64-gnu": "4.64.0", + "@rollup/rollup-linux-ppc64-musl": "4.64.0", + "@rollup/rollup-linux-riscv64-gnu": "4.64.0", + "@rollup/rollup-linux-riscv64-musl": "4.64.0", + "@rollup/rollup-linux-s390x-gnu": "4.64.0", + "@rollup/rollup-linux-x64-gnu": "4.64.0", + "@rollup/rollup-linux-x64-musl": "4.64.0", + "@rollup/rollup-openbsd-x64": "4.64.0", + "@rollup/rollup-openharmony-arm64": "4.64.0", + "@rollup/rollup-win32-arm64-msvc": "4.64.0", + "@rollup/rollup-win32-ia32-msvc": "4.64.0", + "@rollup/rollup-win32-x64-gnu": "4.64.0", + "@rollup/rollup-win32-x64-msvc": "4.64.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2060,12 +6296,213 @@ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -2076,6 +6513,27 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/std-env": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", @@ -2083,6 +6541,133 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -2096,6 +6681,67 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tinybench": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", @@ -2179,6 +6825,96 @@ "node": ">=20" } }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2214,6 +6950,25 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", @@ -2224,6 +6979,115 @@ "node": ">=22.19.0" } }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/vite": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", @@ -2302,6 +7166,37 @@ } } }, + "node_modules/vite-plugin-pwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz", + "integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0 || ^2.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", @@ -2433,6 +7328,111 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -2446,6 +7446,226 @@ "node": ">=20.11" } }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -2462,6 +7682,13 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" } } } diff --git a/web/package.json b/web/package.json index 9ce06bc..58ec7d2 100644 --- a/web/package.json +++ b/web/package.json @@ -5,9 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts", + "build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts", "preview": "vite preview", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json", "test": "npm run lint:colors && vitest run", "lint:colors": "node scripts/lint-colors.mjs", "check:dist-fonts": "node scripts/check-dist-fonts.mjs" @@ -29,6 +29,7 @@ "jsdom": "^30.1.1", "typescript": "^7.0.2", "vite": "^8.3.2", + "vite-plugin-pwa": "^2.0.0", "vitest": "^5.0.3" } } diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..136ee87 Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000..7baf625 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 0000000..5c7b9d2 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/icon-maskable-512.png b/web/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..47d1da1 Binary files /dev/null and b/web/public/icons/icon-maskable-512.png differ diff --git a/web/scripts/generate-icons/main.go b/web/scripts/generate-icons/main.go new file mode 100644 index 0000000..9734c33 --- /dev/null +++ b/web/scripts/generate-icons/main.go @@ -0,0 +1,186 @@ +// Command generate-icons writes the PWA icons in web/public from the +// MealQuest logo. Run it from the repository root: +// +// go run ./web/scripts/generate-icons +// +// The logo fills the regular icons edge to edge. The maskable icon shrinks it +// onto the colour of its own parchment, so the banner stays inside the +// circular safe zone (radius 40% of the icon) that launchers may crop to. +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "slices" + + "golang.org/x/image/draw" +) + +const ( + source = "design/logos/logo_v1_2Oct2026.jpg" + public = "web/public" + maskableScale = 0.66 +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "generate-icons:", err) + os.Exit(1) + } +} + +func run() error { + logo, err := load(source) + if err != nil { + return err + } + if b := logo.Bounds(); b.Dx() != b.Dy() { + return fmt.Errorf("%s is %dx%d; the logo must be square", source, b.Dx(), b.Dy()) + } + outputs := []struct { + path string + image *image.RGBA + }{ + {"icons/icon-192.png", resized(logo, 192)}, + {"icons/icon-512.png", resized(logo, 512)}, + {"icons/icon-maskable-512.png", maskable(logo, 512)}, + {"apple-touch-icon.png", resized(logo, 180)}, + } + for _, out := range outputs { + path := filepath.Join(public, out.path) + if err := save(path, out.image); err != nil { + return err + } + fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy()) + } + return nil +} + +// load decodes the logo. The file is a PNG despite its .jpg name, so the +// format is sniffed rather than taken from the extension. +func load(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode %s: %w", path, err) + } + return img, nil +} + +func save(path string, img image.Image) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(f, img); err != nil { + f.Close() + return fmt.Errorf("encode %s: %w", path, err) + } + return f.Close() +} + +func resized(logo image.Image, size int) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil) + return dst +} + +func maskable(logo image.Image, size int) *image.RGBA { + // The padding is the median colour of the logo's top edge, which is bare + // parchment; a feathered edge hides the seam. + b := logo.Bounds() + canvas := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.Draw(canvas, canvas.Rect, image.NewUniform(medianColour(logo, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+b.Dy()/40))), image.Point{}, draw.Src) + inner := int(math.Round(float64(size) * maskableScale)) + offset := (size - inner) / 2 + margin := inner / 24 + at := image.Rect(offset, offset, offset+inner, offset+inner) + draw.DrawMask(canvas, at, resized(logo, inner), image.Point{}, feather(inner, margin), image.Point{}, draw.Over) + return canvas +} + +// medianColour is the per-channel median of the pixels in r. +func medianColour(img image.Image, r image.Rectangle) color.RGBA { + var channels [3][]uint8 + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) + channels[0] = append(channels[0], c.R) + channels[1] = append(channels[1], c.G) + channels[2] = append(channels[2], c.B) + } + } + var median [3]uint8 + for i, values := range channels { + slices.Sort(values) + median[i] = values[len(values)/2] + } + return color.RGBA{R: median[0], G: median[1], B: median[2], A: 0xff} +} + +// feather is an opaque square inset by margin on a size×size mask, its edge +// softened by a Gaussian blur with a standard deviation of margin/2. +func feather(size, margin int) *image.Alpha { + values := make([]float64, size*size) + for y := margin; y <= size-margin && y < size; y++ { + for x := margin; x <= size-margin && x < size; x++ { + values[y*size+x] = 255 + } + } + kernel := gaussian(float64(margin) / 2) + values = blur(values, size, kernel, 1, size) + values = blur(values, size, kernel, size, 1) + mask := image.NewAlpha(image.Rect(0, 0, size, size)) + for i, v := range values { + mask.Pix[i] = uint8(math.Round(min(max(v, 0), 255))) + } + return mask +} + +// gaussian is a normalised kernel reaching three standard deviations. +func gaussian(sigma float64) []float64 { + radius := int(math.Ceil(3 * sigma)) + kernel := make([]float64, 2*radius+1) + var sum float64 + for i := range kernel { + d := float64(i - radius) + kernel[i] = math.Exp(-d * d / (2 * sigma * sigma)) + sum += kernel[i] + } + for i := range kernel { + kernel[i] /= sum + } + return kernel +} + +// blur convolves each line of a size×size grid with kernel: step is the +// distance between neighbours along a line and stride between lines. Samples +// beyond the edge repeat the edge. +func blur(values []float64, size int, kernel []float64, step, stride int) []float64 { + radius := len(kernel) / 2 + out := make([]float64, len(values)) + for line := range size { + for i := range size { + var v float64 + for k, w := range kernel { + j := min(max(i+k-radius, 0), size-1) + v += w * values[line*stride+j*step] + } + out[line*stride+i*step] = v + } + } + return out +} diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index ed77cf6..8626068 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,23 +1,28 @@ -import { render, screen } from '@testing-library/react' +import { act, render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createApiClient } from './api/index.ts' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApiClient, createVerifiedSession } from './api/index.ts' +import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' import App, { routes } from './App.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') + vi.restoreAllMocks() vi.unstubAllGlobals() }) function renderAt(path: string, location = fakeLocation(path)) { - render( + return { location, ...renderAppAt(path, location) } +} + +function renderAppAt(path: string, location = fakeLocation(path)) { + return render( - + , ) - return location } describe('App routes', () => { @@ -70,7 +75,7 @@ describe('App bootstrap', () => { it('sends a signed-out visitor to sign-in and never renders the routes', async () => { const fetchMock = stubFetch(errorResponse(401, 'unauthenticated')) - const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1')) + const { location } = renderAt('/discover', fakeLocation('/discover', '?swipe=1')) await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1')) expect(fetchMock).toHaveBeenCalledTimes(1) @@ -80,7 +85,7 @@ describe('App bootstrap', () => { it('shows the not-in-this-household screen on a 403', async () => { const fetchMock = stubFetch(errorResponse(403, 'forbidden')) - const location = renderAt('/') + const { location } = renderAt('/') expect(await screen.findByText(/not in this household/)).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument() @@ -99,3 +104,93 @@ describe('App bootstrap', () => { expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() }) }) + +describe('App offline bootstrap', () => { + let onLine: boolean + + function goOffline() { + onLine = false + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + } + + // visitOnline is an earlier online visit on this device: /me answers with a + // member, then the app is closed. + async function visitOnline() { + stubFetch(jsonResponse(200, ME)) + const { unmount } = renderAt('/') + expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + unmount() + } + + beforeEach(() => { + mockViewport(390) + onLine = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) + }) + + it.each([ + ['/plan', 'Plan'], + ['/dinner/01J9ZK', 'Dinner'], + ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { + await visitOnline() + goOffline() + renderAt(path) + + expect(await screen.findByRole('heading', { level: 1, name: title })).toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('opens the saved pages when the server is unreachable but the browser reports online', async () => { + await visitOnline() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + renderAt('/plan') + + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') + }) + + it('reports the failure offline when this device never verified a session', async () => { + goOffline() + renderAt('/plan') + + expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + }) + + it('reports the failure offline after the session was refused', async () => { + await visitOnline() + stubFetch(errorResponse(401, 'unauthenticated')) + const { location, unmount } = renderAt('/plan') + await vi.waitFor(() => expect(location.assign).toHaveBeenCalled()) + unmount() + + goOffline() + renderAt('/plan') + expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + }) + + it('loads /me again on reconnect and refuses writes until it has', async () => { + await visitOnline() + goOffline() + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + , + ) + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) + + const fetchMock = stubFetch(jsonResponse(200, ME)) + onLine = true + act(() => { + window.dispatchEvent(new Event('online')) + }) + await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })) + await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) + expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx index 13cdf10..687c59f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,8 @@ import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { OfflinePill } from './pwa/OfflinePill.tsx' +import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AppShell } from './shell/AppShell.tsx' // Every route the app serves, with the heading its placeholder page shows. @@ -40,11 +42,17 @@ export function AppRoutes() { export type AppProps = { api: ApiClient + // navigator.serviceWorker, or undefined where there is none (and in tests). + serviceWorker: UpdateContainer | undefined +} + +function reloadPage() { + window.location.reload() } // App expects to be rendered inside a router (BrowserRouter in main.tsx, // MemoryRouter in tests). -export default function App({ api }: AppProps) { +export default function App({ api, serviceWorker }: AppProps) { const [queryClient] = useState(createQueryClient) return ( @@ -52,6 +60,8 @@ export default function App({ api }: AppProps) { + + ) diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx index 53f8661..d9c1aee 100644 --- a/web/src/AuthBootstrap.tsx +++ b/web/src/AuthBootstrap.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore, type ReactNode } from 'react' +import { useEffect, useSyncExternalStore, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { ApiError, ME_PATH, useApi } from './api/index.ts' import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx' @@ -11,13 +11,29 @@ export const meQueryKey = [ME_PATH] as const // AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is // already navigating to sign-in, so the skeleton stays up; an account outside // the household sees only the "not in this household" screen. +// +// When /me cannot reach the server and it last answered with a member on this +// device, the saved pages open read-only: the service worker answers their +// reads from its cache, and every write is refused for want of a CSRF token. +// /me runs even while the browser reports offline, so that case fails fast +// instead of waiting, and it is fetched again when the browser is back online. export function AuthBootstrap({ children }: { children: ReactNode }) { const api = useApi() const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) - const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity }) + const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' }) + // fetch rejects with a TypeError only when the network fails. + const savedPages = me.isError && me.error instanceof TypeError && api.session.verified() + const { refetch } = me + + useEffect(() => { + if (!savedPages) return + return api.network.subscribe(() => { + if (!api.network.offline()) void refetch() + }) + }, [savedPages, api, refetch]) if (forbidden) return - if (me.isSuccess) return children + if (me.isSuccess || savedPages) return children if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) { return (
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index f6fa5be..c9bdb84 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,15 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts' -import { ApiError, createApiClient, CSRF_HEADER } from './index.ts' +import { ApiError, createApiClient, createVerifiedSession, CSRF_HEADER, VERIFIED_SESSION_KEY } from './index.ts' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() }) describe('apiFetch', () => { it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => { const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 }) @@ -29,7 +32,7 @@ describe('apiFetch', () => { it('refuses a non-GET request before /me has loaded', async () => { const fetchMock = stubFetch() - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) expect(fetchMock).not.toHaveBeenCalled() }) @@ -47,7 +50,7 @@ describe('apiFetch', () => { 'api/v1/me', ])('refuses %s without sending it or the CSRF token', async (path) => { const fetchMock = stubFetch(jsonResponse(200, ME)) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow( @@ -60,7 +63,7 @@ describe('apiFetch', () => { it('rejects with a typed ApiError from the error body', async () => { stubFetch(errorResponse(503, 'unavailable', true)) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e) expect(error).toBeInstanceOf(ApiError) expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true }) @@ -68,14 +71,14 @@ describe('apiFetch', () => { it('rejects a response without the API error body', async () => { stubFetch(new Response('bad gateway', { status: 502 })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON') }) it('on 401 navigates to sign-in with the current path and search to return to', async () => { stubFetch(errorResponse(401, 'unauthenticated')) const location = fakeLocation('/plan/shopping', '?week=2026-10-05') - const api = createApiClient(location) + const api = createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage)) await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 }) expect(location.assign).toHaveBeenCalledExactlyOnceWith( @@ -90,7 +93,7 @@ describe('apiFetch', () => { jsonResponse(200, { ...ME, csrfToken: 'token-2' }), jsonResponse(201, { id: 'j1' }), ) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({ @@ -111,7 +114,7 @@ describe('apiFetch', () => { jsonResponse(200, { ...ME, csrfToken: 'token-2' }), errorResponse(403, 'csrf'), ) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({ @@ -124,7 +127,7 @@ describe('apiFetch', () => { it('marks any other 403 as not in this household and notifies subscribers', async () => { const fetchMock = stubFetch(errorResponse(403, 'forbidden')) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const listener = vi.fn() api.subscribe(listener) @@ -136,14 +139,14 @@ describe('apiFetch', () => { it('does not take a 403 without the API error body as not in this household', async () => { stubFetch(new Response('denied by proxy', { status: 403 })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON') expect(api.forbidden()).toBe(false) }) it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => { const fetchMock = stubFetch(errorResponse(403, 'csrf')) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' }) expect(fetchMock).toHaveBeenCalledTimes(1) }) @@ -152,7 +155,74 @@ describe('apiFetch', () => { describe('loadMe', () => { it('rejects a /me response without a CSRF token', async () => { stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token') }) }) + +describe('verified session', () => { + it('is remembered when /me answers with a member', async () => { + stubFetch(jsonResponse(200, ME)) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + expect(api.session.verified()).toBe(false) + await api.loadMe() + expect(api.session.verified()).toBe(true) + expect(localStorage.getItem(VERIFIED_SESSION_KEY)).toBe('1') + }) + + it('stores nothing from the /me response', async () => { + stubFetch(jsonResponse(200, ME)) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + expect(Object.keys(localStorage)).toEqual([VERIFIED_SESSION_KEY]) + expect(JSON.stringify({ ...localStorage })).not.toContain(ME.csrfToken) + }) + + it('is not remembered for a /me response that is not a member', async () => { + stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + await expect(api.loadMe()).rejects.toThrow() + expect(api.session.verified()).toBe(false) + }) + + it('is forgotten on a 401', async () => { + stubFetch(jsonResponse(200, ME), errorResponse(401, 'unauthenticated')) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 401 }) + expect(api.session.verified()).toBe(false) + }) + + it('is forgotten when the account is not in this household', async () => { + stubFetch(jsonResponse(200, ME), errorResponse(403, 'forbidden')) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 403 }) + expect(api.session.verified()).toBe(false) + }) + + it('is kept when a request fails with a network error', async () => { + stubFetch(jsonResponse(200, ME)) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await expect(api.loadMe()).rejects.toThrow(TypeError) + expect(api.session.verified()).toBe(true) + }) +}) + +describe('network status', () => { + it('counts a response the service worker answered from its cache as offline', async () => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) + const cached = jsonResponse(200, { id: '01J9ZR' }) + cached.headers.set(cacheFallbackHeader, '1') + stubFetch(cached, jsonResponse(200, { id: '01J9ZR' })) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + + await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' }) + expect(api.network.offline()).toBe(true) + + await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' }) + expect(api.network.offline()).toBe(false) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a4f840e..cbfc0f0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,7 @@ +import type { NetworkStatus } from '../pwa/networkStatus.ts' +import { cacheFallbackHeader } from '../sw/routes.ts' import { ApiError, isRecord, parseApiError } from './errors.ts' +import type { VerifiedSession } from './verifiedSession.ts' // CSRF_HEADER carries the session's CSRF token on every non-GET request. export const CSRF_HEADER = 'X-MealQuest-CSRF' @@ -36,7 +39,7 @@ export type ApiLocation = Pick void): () => void + // network learns of every request that failed with a network error or was + // answered from the service worker's cache, and of every request the + // server answered. + network: NetworkStatus + // session remembers, on this device, whether /me last answered with a + // member; offline, AuthBootstrap opens the saved pages only if it did. + session: VerifiedSession } // createApiClient builds the app's API client. location is window.location in // the app: a 401 sends it to /auth/login with the current path to return to. -export function createApiClient(location: ApiLocation): ApiClient { +export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient { let csrfToken: string | null = null let refreshing: Promise | null = null let forbidden = false @@ -70,12 +80,28 @@ export function createApiClient(location: ApiLocation): ApiClient { } headers[CSRF_HEADER] = csrfToken } - return fetch(path, { - method, - headers, - credentials: 'same-origin', - body: body === undefined ? undefined : JSON.stringify(body), - }) + // Serialize before fetching so a body that cannot be sent is its own error + // and never counts as a network failure. + let json: string | undefined + try { + json = body === undefined ? undefined : JSON.stringify(body) + } catch (error) { + throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error }) + } + let response: Response + try { + response = await fetch(path, { method, headers, credentials: 'same-origin', body: json }) + } catch (error) { + // fetch rejects with a TypeError when the network fails; an abort is a + // DOMException and says nothing about the network. + if (error instanceof TypeError) network.requestFailed() + throw error + } + // The service worker marks a response it answered from its cache because + // the server could not be reached in time. + if (response.headers.has(cacheFallbackHeader)) network.requestFailed() + else network.requestSucceeded() + return response } // refreshMe shares one in-flight /me request between every request that @@ -90,6 +116,11 @@ export function createApiClient(location: ApiLocation): ApiClient { async function apiFetch(path: string, request: ApiRequest = {}): Promise { const method = request.method ?? 'GET' const url = apiURL(location.origin, method, path) + // fetch refuses a GET with a body by rejecting with a TypeError, the same + // error as a network failure, so refuse it here before anything is sent. + if (method === 'GET' && request.body !== undefined) { + throw new Error(`GET ${path}: a GET request cannot carry a body`) + } let response = await send(method, url, request.body) if (response.status === 403) { const error = await parseForbidden(method, path, response) @@ -113,12 +144,14 @@ export function createApiClient(location: ApiLocation): ApiClient { function markForbidden() { if (forbidden) return + session.forget() forbidden = true listeners.forEach((listener) => listener()) } async function decode(method: string, path: string, response: Response): Promise { if (response.status === 401) { + session.forget() location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`) } if (!response.ok) throw await parseApiError(method, path, response) @@ -140,6 +173,7 @@ export function createApiClient(location: ApiLocation): ApiClient { throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`) } csrfToken = me.csrfToken + session.remember() return me as Me } @@ -151,5 +185,7 @@ export function createApiClient(location: ApiLocation): ApiClient { listeners.add(listener) return () => listeners.delete(listener) }, + network, + session, } } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index ea7a989..cae3e1f 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -2,4 +2,5 @@ export { ApiProvider, useApi } from './ApiContext.tsx' export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts' export { ApiError } from './errors.ts' export { createQueryClient, shouldRetry } from './queryClient.ts' +export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts' export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts' diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx index f93ce20..21d1359 100644 --- a/web/src/api/useJob.test.tsx +++ b/web/src/api/useJob.test.tsx @@ -2,7 +2,8 @@ import { act, renderHook } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts' -import { ApiProvider, createApiClient, useJob } from './index.ts' +import { ApiProvider, createApiClient, createVerifiedSession, useJob } from './index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' type Draft = { dinners: string[] } @@ -11,7 +12,7 @@ function job(status: string, result: Draft | null, error: string | null = null) } function renderJob(jobId: string | null) { - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const wrapper = ({ children }: { children: ReactNode }) => {children} return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) } diff --git a/web/src/api/verifiedSession.ts b/web/src/api/verifiedSession.ts new file mode 100644 index 0000000..c3fa7c3 --- /dev/null +++ b/web/src/api/verifiedSession.ts @@ -0,0 +1,24 @@ +// VERIFIED_SESSION_KEY is the localStorage key marking that GET /api/v1/me +// last answered with a household member on this device. It holds only "1" — +// never the /me response, its CSRF token or anything from /auth. +export const VERIFIED_SESSION_KEY = 'mealquest.verified-session' + +export type VerifiedSession = { + // verified reports whether /me last answered with a member on this device. + verified(): boolean + // remember records a /me that answered with a member. + remember(): void + // forget records that the server refused the session (401) or the account + // (not in this household). + forget(): void +} + +// createVerifiedSession keeps the marker in storage (window.localStorage in +// the app). A storage failure is thrown to the caller. +export function createVerifiedSession(storage: Storage): VerifiedSession { + return { + verified: () => storage.getItem(VERIFIED_SESSION_KEY) === '1', + remember: () => storage.setItem(VERIFIED_SESSION_KEY, '1'), + forget: () => storage.removeItem(VERIFIED_SESSION_KEY), + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index fe00a99..63cd1b1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,19 +4,20 @@ import { BrowserRouter } from 'react-router' import './styles/tokens.css' import './styles/base.css' import App from './App.tsx' -import { createApiClient } from './api/index.ts' +import { createApiClient, createVerifiedSession } from './api/index.ts' +import { createNetworkStatus } from './pwa/networkStatus.ts' const root = document.getElementById('root') if (!root) { throw new Error('MealQuest: #root element missing from index.html') } -const api = createApiClient(window.location) +const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage)) createRoot(root).render( - + , ) diff --git a/web/src/pwa.test.ts b/web/src/pwa.test.ts new file mode 100644 index 0000000..dda3d6a --- /dev/null +++ b/web/src/pwa.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment node +// Builds the app with the production config and serves the result, so the +// assertions cover what ships in dist: the web manifest, the service worker, +// every icon, and the service worker registration that only builds include. +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite' +import tokens from './styles/tokens.css?raw' + +const outDir = 'node_modules/.tmp/pwa-test-dist' + +const icons = [ + { path: '/icons/icon-192.png', size: 192 }, + { path: '/icons/icon-512.png', size: 512 }, + { path: '/icons/icon-maskable-512.png', size: 512 }, + { path: '/apple-touch-icon.png', size: 180 }, +] + +function token(name: string): string { + const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1] + if (!value) throw new Error(`${name} is not defined in tokens.css`) + return value +} + +// Width and height from a PNG's IHDR chunk, which follows the 8-byte +// signature, the chunk length and the chunk type. +// The precache list vite-plugin-pwa injects into sw.js: a JSON array of +// { revision, url } entries. +function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> { + const start = sw.indexOf('[{"revision":') + expect(start, 'injected precache list').toBeGreaterThanOrEqual(0) + return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2)) +} + +function pngSize(bytes: Uint8Array): { width: number; height: number } { + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + expect(Array.from(bytes.subarray(0, 8))).toEqual(signature) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + return { width: view.getUint32(16), height: view.getUint32(20) } +} + +describe('production build', () => { + let server: PreviewServer + let origin: string + + async function get(path: string): Promise { + const response = await fetch(new URL(path, origin)) + expect(response.status, path).toBe(200) + return response + } + + beforeAll(async () => { + await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } }) + server = await preview({ + logLevel: 'error', + build: { outDir }, + preview: { port: 0, strictPort: false, host: '127.0.0.1' }, + }) + const url = server.resolvedUrls?.local[0] + if (!url) throw new Error('preview server did not report a URL') + origin = url + }, 60_000) + + afterAll(async () => { + await server?.close() + }) + + it('emits manifest.webmanifest with the MealQuest identity', async () => { + const manifest = await (await get('/manifest.webmanifest')).json() + expect(manifest).toMatchObject({ + name: 'MealQuest', + short_name: 'MealQuest', + start_url: '/', + scope: '/', + display: 'standalone', + // --color-accent and --color-bg hold the manifest colours; hex colours + // may not be written outside tokens.css. + theme_color: token('--color-accent'), + background_color: token('--color-bg'), + }) + expect(manifest.icons).toEqual([ + { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { + src: '/icons/icon-maskable-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ]) + }) + + it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => { + const bytes = new Uint8Array(await (await get(path)).arrayBuffer()) + expect(pngSize(bytes)).toEqual({ width: size, height: size }) + }) + + it('emits sw.js with the precache list injected', async () => { + const sw = await (await get('/sw.js')).text() + expect(sw).not.toContain('self.__WB_MANIFEST') + expect(sw).toContain('"url":"index.html"') + for (const { path } of icons.slice(0, 3)) { + expect(sw).toContain(`"url":"${path.slice(1)}"`) + } + }) + + it('precaches the shell, JS, CSS and every self-hosted font', async () => { + const entries = precacheEntries(await (await get('/sw.js')).text()) + const urls = entries.map((entry) => entry.url) + // index.html keeps its URL across builds, so it carries a content + // revision; the other assets have content hashes in their names. + expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/) + expect(urls.some((url) => url.endsWith('.js'))).toBe(true) + expect(urls.some((url) => url.endsWith('.css'))).toBe(true) + for (const font of [ + 'BagelFatOne-LatinExt', + 'BagelFatOne-Latin', + 'Figtree-Regular', + 'Figtree-SemiBold', + 'Figtree-Bold', + ]) { + expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true) + } + }) + + it('links the manifest, theme colour and apple-touch-icon from index.html', async () => { + const html = await (await get('/')).text() + expect(html.match(//g)).toHaveLength(1) + expect(html).toContain(``) + expect(html).toContain('') + }) + + it('registers /sw.js at scope / from index.html', async () => { + const html = await (await get('/')).text() + expect(html).toContain('') + const register = await (await get('/registerSW.js')).text() + expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })") + }) +}) + +describe('dev server', () => { + let server: ViteDevServer + + beforeAll(async () => { + server = await createServer({ logLevel: 'error', server: { middlewareMode: true } }) + }) + + afterAll(async () => { + await server?.close() + }) + + it('does not register a service worker', async () => { + const html = await server.transformIndexHtml('/', '') + expect(html).not.toContain('registerSW') + expect(html).not.toContain('serviceWorker') + expect(html).not.toContain('rel="manifest"') + }) +}) diff --git a/web/src/pwa/InstallHint.module.css b/web/src/pwa/InstallHint.module.css new file mode 100644 index 0000000..74e9fc2 --- /dev/null +++ b/web/src/pwa/InstallHint.module.css @@ -0,0 +1,18 @@ +.hint { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.icon { + color: var(--color-accent); +} + +.message { + flex: 1; + margin: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 600; + line-height: 1.35; +} diff --git a/web/src/pwa/InstallHint.test.tsx b/web/src/pwa/InstallHint.test.tsx new file mode 100644 index 0000000..628ad96 --- /dev/null +++ b/web/src/pwa/InstallHint.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx' + +const IPHONE_SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1' +const IPAD_SAFARI = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15' +const MAC_SAFARI = IPAD_SAFARI +const IPHONE_CHROME = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1' +const IPHONE_FIREFOX = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15' +const IPHONE_EDGE = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15' +const IPHONE_OPERA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0' +const IPHONE_MQQBROWSER = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1' +const ANDROID_CHROME = + 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36' +const DESKTOP_CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' + +const copy = 'Tap Share, then Add to Home Screen to get notifications' + +type Device = { + userAgent: string + maxTouchPoints?: number + // display-mode: standalone + displayStandalone?: boolean + // navigator.standalone (Safari only; undefined elsewhere) + navigatorStandalone?: boolean +} + +// mockDevice pretends to be a browser with the given user agent and +// display mode; jsdom has no matchMedia, navigator.maxTouchPoints or +// navigator.standalone. +function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints }) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone }) + vi.stubGlobal('matchMedia', (query: string) => { + if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`) + return { matches: displayStandalone, media: query } as MediaQueryList + }) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + Reflect.deleteProperty(navigator, 'maxTouchPoints') + Reflect.deleteProperty(navigator, 'standalone') + localStorage.clear() +}) + +describe('InstallHint', () => { + it('shows on iPhone Safari in a browser tab', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + }) + + it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => { + mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + }) + + it('is hidden when navigator.standalone is true', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true }) + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it('is hidden when display-mode is standalone', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true }) + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it.each([ + ['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }], + ['desktop Chrome', { userAgent: DESKTOP_CHROME }], + ['desktop Safari on a Mac', { userAgent: MAC_SAFARI }], + ['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }], + ['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }], + ['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }], + ['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }], + ['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }], + ])('is hidden on %s', (_name, device: Device) => { + mockDevice(device) + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it('stays dismissed on this device', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const first = render() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByRole('note')).toBeNull() + expect(localStorage.getItem(DISMISSED_KEY)).toBe('1') + + first.unmount() + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it('stays up and reports the error when the dismissal cannot be saved', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota exceeded', 'QuotaExceededError') + }) + + render() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.getByRole('note')).toHaveTextContent(copy) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded', + ) + + setItem.mockRestore() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(screen.queryByRole('note')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(localStorage.getItem(DISMISSED_KEY)).toBe('1') + }) + + it('shows and reports the error when storage cannot be read', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('storage blocked', 'SecurityError') + }) + + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked', + ) + + getItem.mockRestore() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(screen.getByRole('note')).toHaveTextContent(copy) + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/web/src/pwa/InstallHint.tsx b/web/src/pwa/InstallHint.tsx new file mode 100644 index 0000000..92b30d2 --- /dev/null +++ b/web/src/pwa/InstallHint.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { Share, X } from 'lucide-react' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import styles from './InstallHint.module.css' + +// DISMISSED_KEY is the localStorage key remembering, on this device, that the +// household dismissed the hint. +export const DISMISSED_KEY = 'mealquest.installHint.dismissed' + +// Safari's user agent ends "(KHTML, like Gecko) Version/ Mobile/ +// Safari/" (iPadOS leaves out Mobile/). Every other iOS browser and in-app +// web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA, +// ...) or drops Version/ or Safari/, so only this exact tail is Safari. +const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/ + +// isIosSafari reports whether the page is open in Safari on iPhone, iPod or +// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so +// a "Macintosh" with a touch screen is an iPad. +export function isIosSafari(nav: Pick): boolean { + const ua = nav.userAgent + const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1) + return ios && SAFARI_TAIL.test(ua) +} + +// isStandalone reports whether the app is running from the Home Screen. +// Safari sets navigator.standalone; other browsers answer the display-mode +// media query. +export function isStandalone(): boolean { + const legacy = (navigator as Navigator & { standalone?: boolean }).standalone + return legacy === true || window.matchMedia('(display-mode: standalone)').matches +} + +// HintState is whether the household dismissed the hint on this device, and +// the storage failure, if any, that stopped us reading or saving that. +type HintState = { + dismissed: boolean + failure: { action: 'read' | 'write'; message: string } | null +} + +// Storage can be unavailable (blocked by the browser or privacy settings), in +// which case using it throws. The failure is reported, never treated as a +// remembered dismissal. +function readDismissed(): HintState { + try { + return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null } + } catch (err) { + return { dismissed: false, failure: { action: 'read', message: String(err) } } + } +} + +function writeDismissed(): HintState { + try { + window.localStorage.setItem(DISMISSED_KEY, '1') + return { dismissed: true, failure: null } + } catch (err) { + return { dismissed: false, failure: { action: 'write', message: String(err) } } + } +} + +const failureText = { + read: 'Could not check whether this hint was dismissed on this device', + write: 'Could not remember on this device that this hint was dismissed', +} + +// InstallHint tells iOS and iPadOS Safari users how to install the app, which +// iOS requires before it delivers Web Push. It shows only in a Safari browser +// tab, never once installed, and stays dismissed on this device. If storage +// fails, the hint stays up with an error saying so. Used by onboarding step 3 +// and the Notifications screen. +export function InstallHint() { + const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone()) + const [state, setState] = useState(() => + eligible ? readDismissed() : { dismissed: false, failure: null }, + ) + if (!eligible || state.dismissed) return null + + const { failure } = state + return ( + <> + + +

Tap Share, then Add to Home Screen to get notifications

+ setState(writeDismissed())}> + + +
+ {failure && ( + setState(failure.action === 'read' ? readDismissed() : writeDismissed())}> + {failureText[failure.action]}: {failure.message} + + )} + + ) +} diff --git a/web/src/pwa/OfflinePill.module.css b/web/src/pwa/OfflinePill.module.css new file mode 100644 index 0000000..51b6b0e --- /dev/null +++ b/web/src/pwa/OfflinePill.module.css @@ -0,0 +1,22 @@ +.pill { + position: fixed; + top: calc(var(--space-3) + env(safe-area-inset-top)); + left: 50%; + z-index: 20; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: var(--space-2); + margin: 0; + padding: 6px 14px; + border-radius: var(--radius-pill); + background: var(--color-neutral-800); + color: var(--on-accent); + box-shadow: var(--shadow-md); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; +} diff --git a/web/src/pwa/OfflinePill.test.tsx b/web/src/pwa/OfflinePill.test.tsx new file mode 100644 index 0000000..5413a8a --- /dev/null +++ b/web/src/pwa/OfflinePill.test.tsx @@ -0,0 +1,150 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts' +import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { createNetworkStatus } from './networkStatus.ts' +import { OfflinePill } from './OfflinePill.tsx' + +const copy = 'Offline — showing saved data' + +let onLine: boolean + +beforeEach(() => { + onLine = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function goOffline() { + onLine = false + window.dispatchEvent(new Event('offline')) +} + +function goOnline() { + onLine = true + window.dispatchEvent(new Event('online')) +} + +function renderPill(): ApiClient { + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + , + ) + return api +} + +describe('OfflinePill', () => { + it('is hidden while online', () => { + renderPill() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows while the browser is offline when it renders', () => { + onLine = false + renderPill() + expect(screen.getByRole('status')).toHaveTextContent(copy) + }) + + it('appears on the offline event and disappears on the online event', () => { + renderPill() + act(goOffline) + expect(screen.getByRole('status')).toHaveTextContent(copy) + act(goOnline) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('appears when a request fails with a network error', async () => { + const api = renderPill() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError)) + expect(screen.getByRole('status')).toHaveTextContent(copy) + + act(goOnline) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('disappears when a later request gets a response', async () => { + const api = renderPill() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError)) + expect(screen.getByRole('status')).toBeInTheDocument() + + stubFetch(jsonResponse(200, {})) + await act(() => api.apiFetch('/api/v1/plan')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows while online when the service worker answers from its cache', async () => { + const api = renderPill() + const cached = jsonResponse(200, { id: '01J9ZR' }) + cached.headers.set(cacheFallbackHeader, '1') + stubFetch(cached, jsonResponse(200, { id: '01J9ZR' })) + + await act(() => api.apiFetch('/api/v1/recipes/01J9ZR')) + expect(navigator.onLine).toBe(true) + expect(screen.getByRole('status')).toHaveTextContent(copy) + + // The server answers again. + await act(() => api.apiFetch('/api/v1/recipes/01J9ZR')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('ignores a request body that cannot be serialized', async () => { + const api = renderPill() + const fetchMock = stubFetch(jsonResponse(200, ME)) + await act(() => api.loadMe()) + const body: Record = {} + body.self = body + + await act(() => + expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow( + 'PUT /api/v1/plan: request body is not serializable as JSON', + ), + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('ignores a GET request with a body, which fetch would refuse to send', async () => { + const api = renderPill() + const fetchMock = stubFetch() + + await act(() => + expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow( + 'GET /api/v1/plan: a GET request cannot carry a body', + ), + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('ignores an aborted request', async () => { + const api = renderPill() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'))) + await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('removes its online and offline listeners on unmount', () => { + const add = vi.spyOn(window, 'addEventListener') + const remove = vi.spyOn(window, 'removeEventListener') + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + const { unmount } = render( + + + , + ) + const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline') + expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online']) + + unmount() + for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener) + }) +}) diff --git a/web/src/pwa/OfflinePill.tsx b/web/src/pwa/OfflinePill.tsx new file mode 100644 index 0000000..b1d2060 --- /dev/null +++ b/web/src/pwa/OfflinePill.tsx @@ -0,0 +1,19 @@ +import { useSyncExternalStore } from 'react' +import { WifiOff } from 'lucide-react' +import { useApi } from '../api/index.ts' +import { Icon } from '../ui/Icon.tsx' +import styles from './OfflinePill.module.css' + +// OfflinePill tells the household that what they see came from the cache +// while the browser is offline or the server cannot be reached. +export function OfflinePill() { + const { network } = useApi() + const offline = useSyncExternalStore(network.subscribe, network.offline) + if (!offline) return null + return ( +

+ + Offline — showing saved data +

+ ) +} diff --git a/web/src/pwa/UpdatePrompt.module.css b/web/src/pwa/UpdatePrompt.module.css new file mode 100644 index 0000000..ad2f014 --- /dev/null +++ b/web/src/pwa/UpdatePrompt.module.css @@ -0,0 +1,22 @@ +.toast { + position: fixed; + left: 50%; + bottom: calc(110px + env(safe-area-inset-bottom)); + z-index: 20; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--space-3); + width: max-content; + max-width: calc(100vw - 2 * var(--space-4)); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); + box-shadow: var(--shadow-lg); +} + +.message { + margin: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 600; + line-height: 1.35; +} diff --git a/web/src/pwa/UpdatePrompt.test.tsx b/web/src/pwa/UpdatePrompt.test.tsx new file mode 100644 index 0000000..c869209 --- /dev/null +++ b/web/src/pwa/UpdatePrompt.test.tsx @@ -0,0 +1,165 @@ +import { act, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SKIP_WAITING, UpdatePrompt, type UpdateContainer } from './UpdatePrompt.tsx' + +// FakeWorker is a ServiceWorker whose state the test moves along. +class FakeWorker extends EventTarget { + state: ServiceWorkerState = 'installing' + postMessage = vi.fn() + setState(state: ServiceWorkerState) { + this.state = state + this.dispatchEvent(new Event('statechange')) + } +} + +class FakeRegistration extends EventTarget { + installing: FakeWorker | null = null + waiting: FakeWorker | null = null + // install starts a new worker, as the browser does on finding an update. + install(): FakeWorker { + const worker = new FakeWorker() + this.installing = worker + this.dispatchEvent(new Event('updatefound')) + return worker + } + // finishInstall moves the installing worker to waiting. + finishInstall(worker: FakeWorker) { + this.installing = null + this.waiting = worker + worker.setState('installed') + } +} + +class FakeContainer extends EventTarget { + controller: object | null = {} + registration = new FakeRegistration() + ready = Promise.resolve(this.registration) + // takeOver makes the waiting worker the controller. + takeOver() { + const worker = this.registration.waiting + this.registration.waiting = null + this.controller = worker + worker?.setState('activated') + this.dispatchEvent(new Event('controllerchange')) + } +} + +async function renderPrompt(container: FakeContainer | undefined) { + const reload = vi.fn() + const view = render() + await act(async () => { + await container?.ready + }) + return { reload, ...view } +} + +const copy = 'New version ready · Reload' + +describe('UpdatePrompt', () => { + it('shows nothing without a service worker', async () => { + await renderPrompt(undefined) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows nothing while no worker is waiting', async () => { + const container = new FakeContainer() + await renderPrompt(container) + act(() => { + container.registration.install() + }) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows nothing for a waiting worker when the page has no controller', async () => { + const container = new FakeContainer() + container.controller = null + container.registration.waiting = new FakeWorker() + await renderPrompt(container) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows the toast for a worker already waiting when the page loads', async () => { + const container = new FakeContainer() + container.registration.waiting = new FakeWorker() + await renderPrompt(container) + expect(screen.getByRole('status')).toHaveTextContent(copy) + }) + + it('shows the toast when an update finishes installing', async () => { + const container = new FakeContainer() + await renderPrompt(container) + act(() => container.registration.finishInstall(container.registration.install())) + expect(screen.getByRole('status')).toHaveTextContent(copy) + }) + + it('sends SKIP_WAITING on Reload and reloads once the new worker controls the page', async () => { + const container = new FakeContainer() + const { reload } = await renderPrompt(container) + const worker = container.registration.install() + act(() => container.registration.finishInstall(worker)) + + act(() => screen.getByRole('button', { name: 'Reload' }).click()) + expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith(SKIP_WAITING) + expect(screen.queryByRole('status')).toBeNull() + expect(reload).not.toHaveBeenCalled() + + act(() => container.takeOver()) + expect(reload).toHaveBeenCalledOnce() + // A later controller change (another tab's update) does not reload again. + act(() => container.dispatchEvent(new Event('controllerchange'))) + expect(reload).toHaveBeenCalledOnce() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('does not reappear for the worker the household chose to reload into', async () => { + const container = new FakeContainer() + container.registration.waiting = new FakeWorker() + await renderPrompt(container) + act(() => screen.getByRole('button', { name: 'Reload' }).click()) + // The worker reports another state change before it takes over. + act(() => container.registration.waiting?.setState('installed')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('hides when dismissed until the next update is waiting', async () => { + const container = new FakeContainer() + const { reload } = await renderPrompt(container) + const first = container.registration.install() + act(() => container.registration.finishInstall(first)) + + act(() => screen.getByRole('button', { name: 'Dismiss' }).click()) + expect(screen.queryByRole('status')).toBeNull() + expect(first.postMessage).not.toHaveBeenCalled() + act(() => first.setState('installed')) + expect(screen.queryByRole('status')).toBeNull() + + act(() => container.registration.finishInstall(container.registration.install())) + expect(screen.getByRole('status')).toHaveTextContent(copy) + expect(reload).not.toHaveBeenCalled() + }) + + it('hides when another tab activates the waiting worker', async () => { + const container = new FakeContainer() + container.registration.waiting = new FakeWorker() + const { reload } = await renderPrompt(container) + act(() => container.takeOver()) + expect(screen.queryByRole('status')).toBeNull() + expect(reload).not.toHaveBeenCalled() + }) + + it('removes its listeners on unmount', async () => { + const container = new FakeContainer() + const worker = new FakeWorker() + container.registration.waiting = worker + const { reload, unmount } = await renderPrompt(container) + const removeRegistration = vi.spyOn(container.registration, 'removeEventListener') + const removeWorker = vi.spyOn(worker, 'removeEventListener') + act(() => screen.getByRole('button', { name: 'Reload' }).click()) + + unmount() + expect(removeRegistration).toHaveBeenCalledWith('updatefound', expect.any(Function)) + expect(removeWorker).toHaveBeenCalledWith('statechange', expect.any(Function)) + container.takeOver() + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/pwa/UpdatePrompt.tsx b/web/src/pwa/UpdatePrompt.tsx new file mode 100644 index 0000000..c0d69b5 --- /dev/null +++ b/web/src/pwa/UpdatePrompt.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import styles from './UpdatePrompt.module.css' + +// UpdateContainer is the part of navigator.serviceWorker the prompt uses. +export type UpdateContainer = Pick + +export type UpdatePromptProps = { + // navigator.serviceWorker, or undefined where the browser has none. + container: UpdateContainer | undefined + // Reloads the page once the new worker controls it. + reload: () => void +} + +// SKIP_WAITING is the message src/sw.ts answers by activating the waiting +// worker. +export const SKIP_WAITING = { type: 'SKIP_WAITING' } as const + +// UpdatePrompt offers to reload when a new service worker is installed and +// waiting to replace the one controlling the page. Reload asks the waiting +// worker to activate and reloads once it controls the page; dismissing hides +// the prompt until a different worker is waiting. +export function UpdatePrompt({ container, reload }: UpdatePromptProps) { + const [waiting, setWaiting] = useState(null) + const [dismissed, setDismissed] = useState(null) + const [accepted, setAccepted] = useState(null) + const reloadListener = useRef<(() => void) | null>(null) + + useEffect(() => { + if (container === undefined) return + const sw = container + let registration: ServiceWorkerRegistration | null = null + let unmounted = false + const watched = new Set() + + // A waiting worker only replaces something when the page has a + // controller; without one there is no controllerchange to reload on. + function check() { + if (registration === null) return + setWaiting(sw.controller === null ? null : registration.waiting) + } + function watch(worker: ServiceWorker | null) { + if (worker === null || watched.has(worker)) return + watched.add(worker) + worker.addEventListener('statechange', check) + } + function updateFound() { + watch(registration?.installing ?? null) + } + + void sw.ready.then((ready) => { + if (unmounted) return + registration = ready + ready.addEventListener('updatefound', updateFound) + watch(ready.installing) + watch(ready.waiting) + check() + }) + return () => { + unmounted = true + registration?.removeEventListener('updatefound', updateFound) + watched.forEach((worker) => worker.removeEventListener('statechange', check)) + if (reloadListener.current !== null) { + sw.removeEventListener('controllerchange', reloadListener.current) + reloadListener.current = null + } + } + }, [container]) + + if (container === undefined || waiting === null || waiting === dismissed || waiting === accepted) return null + + function update() { + if (container === undefined || waiting === null || reloadListener.current !== null) return + const listener = () => { + reloadListener.current = null + reload() + } + reloadListener.current = listener + container.addEventListener('controllerchange', listener, { once: true }) + setAccepted(waiting) + waiting.postMessage(SKIP_WAITING) + } + + return ( + +

+ New version ready +

+ {/* The space keeps the copy "New version ready · Reload" in the text; + a flex container does not render it. */}{' '} + + setDismissed(waiting)}> + + +
+ ) +} diff --git a/web/src/pwa/networkStatus.ts b/web/src/pwa/networkStatus.ts new file mode 100644 index 0000000..8f124c1 --- /dev/null +++ b/web/src/pwa/networkStatus.ts @@ -0,0 +1,51 @@ +// NetworkWindow is the part of window the network status reads: the browser's +// online flag and its online/offline events. +export type NetworkWindow = Pick + +export type NetworkStatus = { + // offline reports whether the browser says it is offline or the last API + // request failed with a network error or was answered from the cache. + offline(): boolean + // subscribe is notified when offline may have changed. It listens for the + // window's online/offline events until the returned function is called. + subscribe(listener: () => void): () => void + // requestFailed records a fetch that rejected with a network error or that + // the service worker answered from its cache. + requestFailed(): void + // requestSucceeded records a fetch the server answered. + requestSucceeded(): void +} + +// createNetworkStatus tracks whether MealQuest can reach the server. A failed +// request counts as offline until a later request gets a response or the +// browser reports that it is back online. +export function createNetworkStatus(win: NetworkWindow): NetworkStatus { + let failed = false + const listeners = new Set<() => void>() + + function setFailed(value: boolean) { + if (failed === value) return + failed = value + listeners.forEach((listener) => listener()) + } + + return { + offline: () => !win.navigator.onLine || failed, + subscribe(listener) { + const online = () => { + failed = false + listener() + } + listeners.add(listener) + win.addEventListener('online', online) + win.addEventListener('offline', listener) + return () => { + listeners.delete(listener) + win.removeEventListener('online', online) + win.removeEventListener('offline', listener) + } + }, + requestFailed: () => setFailed(true), + requestSucceeded: () => setFailed(false), + } +} diff --git a/web/src/sw.ts b/web/src/sw.ts new file mode 100644 index 0000000..ca712ff --- /dev/null +++ b/web/src/sw.ts @@ -0,0 +1,115 @@ +// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with +// the build's precache list (shell, JS, CSS and fonts, each with a revision). +// Every other request goes through the route src/sw/routes.ts decides. +// A network-only request is left to the browser: the worker never answers it, +// so it reaches the network exactly as the page sent it. That matters for +// POST /auth/logout, whose Clear-Site-Data header wipes the worker and its +// caches — Chrome stalls that navigation when the worker answers it. +// Fonts are precached too, but their route is registered ahead of the +// precache route, so a font fetch goes through the expiring CacheFirst +// strategy and falls back to the precached copy when the network fails. + +import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { ExpirationPlugin } from 'workbox-expiration' +import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching' +import { registerRoute } from 'workbox-routing' +import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies' +import { createApiStrategy } from './sw/apiStrategy' +import { + cacheNames, + cacheVersion, + routeFor, + staleCaches, + type Route, +} from './sw/routes' + +declare const self: ServiceWorkerGlobalScope & { + __WB_MANIFEST: Array<{ url: string; revision: string | null }> +} + +// The precache becomes mealquest-precache-v1. +setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion }) + +const day = 24 * 60 * 60 + +const strategies = { + [cacheNames.api]: createApiStrategy(), + [cacheNames.media]: new CacheFirst({ + cacheName: cacheNames.media, + plugins: [ + new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }), + ], + }), + [cacheNames.fonts]: new CacheFirst({ + cacheName: cacheNames.fonts, + plugins: [ + new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }), + // Offline with no runtime copy yet: the font from the precache. + { handlerDidError: async ({ request }) => matchPrecache(request.url) }, + ], + }), +} +const navigation = new NetworkOnly({ + plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })], +}) + +type AnsweredRoute = Exclude + +function strategyFor(route: AnsweredRoute): Strategy { + switch (route.strategy) { + case 'network-first': + case 'cache-first': + return strategies[route.cacheName] + case 'navigation-fallback': + return navigation + } +} + +function routeOf({ request, url }: { request: Request; url: URL }): Route { + return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin) +} + +// answered matches every request the worker answers: all but network-only. +function answered(options: { request: Request; url: URL }): boolean { + return routeOf(options).strategy !== 'network-only' +} + +function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise { + const route = routeOf({ request, url }) + if (route.strategy === 'network-only') { + throw new Error(`the worker never answers network-only ${request.method} ${url.href}`) + } + return strategyFor(route).handle({ request, event }) +} + +function isFont(options: { request: Request; url: URL }): boolean { + const route = routeOf(options) + return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts +} + +// Workbox uses the first route that matches, so the font route comes before +// the precache route, which would otherwise answer every precached font. +registerRoute(isFont, handle, 'GET') +precacheAndRoute(self.__WB_MANIFEST) + +// Workbox routes are per method; every method goes through routeFor, which +// sends every non-GET to the network only, so only GETs are ever answered. +for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) { + registerRoute(answered, handle, method) +} + +self.addEventListener('activate', (event) => { + const current = [workboxCacheNames.precache, ...Object.values(cacheNames)] + event.waitUntil( + caches + .keys() + .then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))), + ) +}) + +// The update prompt (src/pwa/UpdatePrompt.tsx) asks a waiting worker to take +// over when the household chooses Reload. +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting() +}) diff --git a/web/src/sw/apiStrategy.test.ts b/web/src/sw/apiStrategy.test.ts new file mode 100644 index 0000000..46a7f5e --- /dev/null +++ b/web/src/sw/apiStrategy.test.ts @@ -0,0 +1,165 @@ +// Runs the real Workbox network-first strategy against an in-memory cache +// storage: jsdom has none, and the worker itself needs service worker globals. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { createApiClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { fakeLocation, jsonResponse } from '../test/fetchStub.ts' +import { createApiStrategy } from './apiStrategy' +import { cacheFallbackHeader, cacheNames, routeFor } from './routes' + +const origin = 'https://mealquest.test' + +// FakeCache keys entries by URL, which is all the API cache needs. +class FakeCache { + readonly entries = new Map() + async match(request: RequestInfo | URL): Promise { + return this.entries.get(new Request(request).url)?.clone() + } + async put(request: RequestInfo | URL, response: Response): Promise { + this.entries.set(new Request(request).url, response) + } +} + +// FakeExtendableEvent stands in for the worker-only ExtendableEvent, +// collecting the promises the strategy waits on. +class FakeExtendableEvent extends Event { + readonly pending: Promise[] = [] + waitUntil(promise: Promise) { + this.pending.push(promise) + } +} + +let cache: FakeCache + +beforeEach(() => { + cache = new FakeCache() + // Workbox checks its arguments against these worker-only globals. + vi.stubGlobal('FetchEvent', class FetchEvent {}) + vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) + const only = (name: string | undefined) => { + if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) + return cache + } + vi.stubGlobal('caches', { + open: async (name: string) => only(name), + match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => + only(options?.cacheName).match(request), + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// handle sends a GET through the API strategy, as src/sw.ts does for a +// network-first route. +async function handle(path: string): Promise { + return answer(new Request(new URL(path, origin))) +} + +async function answer(request: Request): Promise { + expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + const event = new FakeExtendableEvent('fetch') + const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) + await Promise.all(event.pending) + return response +} + +function recipe(): Response { + return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('API strategy', () => { + it('answers from the network while the server is reachable, unmarked', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(recipe())) + const response = await handle('/api/v1/recipes/01J9ZR') + expect(response.headers.has(cacheFallbackHeader)).toBe(false) + expect(await response.json()).toMatchObject({ id: '01J9ZR' }) + expect(cache.entries.has(`${origin}/api/v1/recipes/01J9ZR`)).toBe(true) + }) + + it('opens a previously viewed recipe offline, marked as a cache fallback', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(recipe())) + await handle('/api/v1/recipes/01J9ZR') + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + const response = await handle('/api/v1/recipes/01J9ZR') + expect(response.headers.get(cacheFallbackHeader)).toBe('1') + expect(await response.json()).toEqual({ id: '01J9ZR', name: 'Chicken tikka masala' }) + }) + + it('fails offline for a recipe never viewed', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow() + }) +}) + +describe('previously viewed plan and recipe reads', () => { + const plan = { week: '2026-W40', days: [{ date: '2026-10-05', dinner: 'Chicken tikka masala' }] } + const recipe = { id: '01J9ZR', name: 'Chicken tikka masala', steps: ['Marinate', 'Simmer'] } + const server: Record = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe } + let reachable: boolean + + // The page's fetch goes through the worker's API strategy; the strategy's + // own fetch (always a Request) is the network. The API client fetches a + // path string, so the two are told apart by the argument's type. As in a + // browser, a worker that cannot answer is a network error to the page. + beforeEach(() => { + reachable = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + if (!(input instanceof Request)) { + return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => { + throw new TypeError('Failed to fetch', { cause: error }) + }) + } + if (!reachable) throw new TypeError('Failed to fetch') + const body = server[new URL(input.url).pathname] + if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`) + return jsonResponse(200, body) + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function client() { + return createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + } + + it('serves the plan and recipe fetched online once the network is gone', async () => { + const online = client() + expect(await online.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan) + expect(await online.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe) + expect(online.network.offline()).toBe(false) + + reachable = false + const offline = client() + expect(await offline.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan) + expect(await offline.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe) + expect(offline.network.offline()).toBe(true) + }) + + it('shows the offline indicator when the cached copy answers while the browser reports online', async () => { + await client().apiFetch('/api/v1/plans/2026-W40') + + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) + reachable = false + const api = client() + expect(await api.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan) + expect(api.network.offline()).toBe(true) + }) + + it('fails offline for a plan never fetched', async () => { + reachable = false + await expect(client().apiFetch('/api/v1/plans/2026-W41')).rejects.toThrow(TypeError) + }) +}) diff --git a/web/src/sw/apiStrategy.ts b/web/src/sw/apiStrategy.ts new file mode 100644 index 0000000..0000b43 --- /dev/null +++ b/web/src/sw/apiStrategy.ts @@ -0,0 +1,16 @@ +// The network-first strategy for API reads, kept apart from src/sw.ts so the +// tests can run it without a service worker. +import type { WorkboxPlugin } from 'workbox-core/types' +import { NetworkFirst } from 'workbox-strategies' +import { cacheNames, markCacheFallback, networkTimeoutSeconds } from './routes' + +// NetworkFirst reads its cache only when the network fails or times out, so +// every cached response it uses is a fallback. +const markFallback: WorkboxPlugin = { + cachedResponseWillBeUsed: async ({ cachedResponse }) => + cachedResponse === undefined ? undefined : markCacheFallback(cachedResponse), +} + +export function createApiStrategy(): NetworkFirst { + return new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds, plugins: [markFallback] }) +} diff --git a/web/src/sw/routes.test.ts b/web/src/sw/routes.test.ts new file mode 100644 index 0000000..9b57d20 --- /dev/null +++ b/web/src/sw/routes.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes' + +const origin = 'https://mealquest.test' + +function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route { + return routeFor({ method, url: new URL(path, origin), mode }, origin) +} + +const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api } +const networkOnly: Route = { strategy: 'network-only' } + +describe('routeFor', () => { + it.each([ + '/api/v1/plans', + '/api/v1/plans/2026-W40', + '/api/v1/shopping/current', + '/api/v1/dinners', + '/api/v1/dinners/01J9ZK?include=recipe', + '/api/v1/recipes', + '/api/v1/recipes/01J9ZR', + '/api/v1/home', + ])('GET %s is network-first in the API cache', (path) => { + expect(route(path)).toEqual(apiCache) + }) + + it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])( + 'GET %s is cache-first in the media cache', + (path) => { + expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media }) + }, + ) + + it('GET of a font file is cache-first in the fonts cache', () => { + expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({ + strategy: 'cache-first', + cacheName: cacheNames.fonts, + }) + }) + + it.each([ + '/auth/login', + '/auth/callback?code=x&state=y', + '/auth/logout', + '/auth/me', + '/api/v1/settings', + '/api/v1/settings/ai', + '/api/v1/jobs', + '/api/v1/jobs/01J9ZK', + ])('GET %s is network-only', (path) => { + expect(route(path)).toEqual(networkOnly) + }) + + it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])( + 'a navigation to %s is network-only with no offline fallback', + (path) => { + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }, + ) + + it.each([ + '/%61uth/login', + '/auth%2Fcallback?code=x&state=y', + '/%61pi/v1/settings/household', + '/api/v1/%73ettings/ai', + '/api/v1/settings%2Fai', + '/%61pi/v1/jobs/01J9ZK', + '/api/v1/j%6Fbs', + '/api/v1/plans%2F..%2Fsettings', + '/api/v1/recipes%2F..%2Fsettings%2Fai', + '/api/v1/recipes/..%2F..%2F..%2Fauth%2Flogin', + '/plan%2F..%2Fauth%2Flogin', + ])('an encoded reserved path %s is network-only, navigated or not', (path) => { + expect(route(path)).toEqual(networkOnly) + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }) + + it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])( + 'a malformed encoding in %s is network-only, navigated or not', + (path) => { + expect(route(path)).toEqual(networkOnly) + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }, + ) + + it('classifies other API paths by their decoded form', () => { + expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache) + expect(route('/%61pi/v1/recipes/01J9ZR')).toEqual(apiCache) + expect(route('/%61pi/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly) + }) + + it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])( + '%s is network-only on every URL class', + (method) => { + for (const path of [ + '/api/v1/plans/2026-W40', + '/api/v1/shopping/current', + '/api/v1/dinners/01J9ZK', + '/api/v1/recipes/01J9ZR', + '/api/v1/home', + '/media/photos/abc.jpg', + '/assets/Figtree-Bold-a1b2c3.woff2', + '/auth/logout', + '/plan', + ]) { + expect(route(path, method), `${method} ${path}`).toEqual(networkOnly) + expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly) + } + }, + ) + + it('matches whole path segments only', () => { + expect(route('/api/v1/plansx')).toEqual(networkOnly) + expect(route('/api/v1/homepage')).toEqual(networkOnly) + expect(route('/api/v1/recipesx')).toEqual(networkOnly) + expect(route('/mediax/a.jpg')).toEqual(networkOnly) + expect(route('/authors')).toEqual(networkOnly) + expect(route('/api/v1/settingsx')).toEqual(networkOnly) + }) + + it('leaves other API calls network-only', () => { + expect(route('/api/v1/me')).toEqual(networkOnly) + expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly) + }) + + it('leaves cross-origin requests network-only', () => { + for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) { + const url = new URL(path, 'https://cdn.example') + expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly) + } + }) + + it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])( + 'a navigation to the app at %s falls back to the precached index.html', + (path) => { + expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' }) + }, + ) + + it('leaves other same-origin GETs network-only', () => { + expect(route('/health')).toEqual(networkOnly) + }) +}) + +describe('cache names', () => { + it('all start with mealquest-', () => { + for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/) + }) +}) + +describe('staleCaches', () => { + it('deletes only MealQuest caches that are not current', () => { + const current = ['mealquest-precache-v1', ...Object.values(cacheNames)] + const names = [ + ...current, + 'mealquest-precache', + 'mealquest-api-v0', + 'workbox-precache-v2-https://mealquest.test/', + 'other-app-cache', + 'my-mealquest-cache', + ] + expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0']) + }) +}) + +describe('markCacheFallback', () => { + it('copies the cached response with the cache fallback header set', async () => { + const cached = new Response('{"id":"01J9ZR"}', { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + const marked = markCacheFallback(cached) + expect(marked.status).toBe(200) + expect(marked.statusText).toBe('OK') + expect(marked.headers.get('Content-Type')).toBe('application/json') + expect(marked.headers.get(cacheFallbackHeader)).toBe('1') + expect(await marked.json()).toEqual({ id: '01J9ZR' }) + }) +}) diff --git a/web/src/sw/routes.ts b/web/src/sw/routes.ts new file mode 100644 index 0000000..895fd40 --- /dev/null +++ b/web/src/sw/routes.ts @@ -0,0 +1,108 @@ +// Decides how the service worker handles each request. This module is pure — +// no Workbox, no service worker globals — so the tests can assert every URL +// class without a worker; src/sw.ts maps each route to a Workbox strategy. +// +// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every +// non-GET request are network-only and never reach a cache. Paths are matched +// as the server sees them — percent-decoded, before and after dot segments are +// resolved — so /%61uth/login is /auth/login; a malformed encoding is +// network-only. + +export type Route = + // Network with a 3 s timeout, then the last cached response. + | { strategy: 'network-first'; cacheName: typeof cacheNames.api } + // Cached response when there is one, else the network; entries expire. + | { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts } + // Network only, nothing cached: the worker leaves the request to the browser. + | { strategy: 'network-only' } + // Network only, nothing cached; offline, the precached index.html. + | { strategy: 'navigation-fallback' } + +export interface RouteRequest { + method: string + url: URL + mode: RequestMode +} + +// Bumping the version renames every cache; activate then deletes the old ones. +export const cacheVersion = 'v1' +export const cachePrefix = 'mealquest-' + +export const cacheNames = { + api: `${cachePrefix}api-${cacheVersion}`, + media: `${cachePrefix}media-${cacheVersion}`, + fonts: `${cachePrefix}fonts-${cacheVersion}`, +} as const + +export const networkTimeoutSeconds = 3 + +// cacheFallbackHeader marks a network-first response answered from the cache +// because the server could not be reached in time, so the app can show the +// offline indicator even while the browser reports that it is online. +export const cacheFallbackHeader = 'X-MealQuest-Cache-Fallback' + +// markCacheFallback copies a cached response with cacheFallbackHeader set. +export function markCacheFallback(cached: Response): Response { + const headers = new Headers(cached.headers) + headers.set(cacheFallbackHeader, '1') + return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers }) +} + +const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs'] +const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/recipes', '/api/v1/home'] + +// True when path is prefix itself or a path below it: /api/v1/plans matches +// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx. +function under(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}/`) +} + +// The pathname percent-decoded, as the server's router sees it, or undefined +// when the encoding is malformed. +function decodePath(pathname: string): string | undefined { + try { + return decodeURIComponent(pathname) + } catch { + return undefined + } +} + +// Resolves "." and ".." segments the way the server's path.Clean does, so a +// decoded %2F cannot carry a request out of a reserved prefix: +// /api/v1/plans/../settings cleans to /api/v1/settings. +function cleanPath(path: string): string { + const segments: string[] = [] + for (const segment of path.split('/')) { + if (segment === '' || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + return `/${segments.join('/')}` +} + +const networkOnly: Route = { strategy: 'network-only' } + +export function routeFor(request: RouteRequest, origin: string): Route { + const { method, url, mode } = request + if (method !== 'GET') return networkOnly + if (url.origin !== origin) return networkOnly + const path = decodePath(url.pathname) + if (path === undefined) return networkOnly + const cleaned = cleanPath(path) + const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix) + if (neverCached.some(reserved)) return networkOnly + if (networkFirst.some((prefix) => under(path, prefix))) { + return { strategy: 'network-first', cacheName: cacheNames.api } + } + if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media } + if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts } + if (reserved('/api')) return networkOnly + if (mode === 'navigate') return { strategy: 'navigation-fallback' } + return networkOnly +} + +// The caches activate deletes: MealQuest caches that are no longer current. +// Caches without the mealquest- prefix belong to someone else and are kept. +export function staleCaches(names: readonly string[], current: readonly string[]): string[] { + return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name)) +} diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts new file mode 100644 index 0000000..215b9cb --- /dev/null +++ b/web/src/sw/worker.test.ts @@ -0,0 +1,45 @@ +// The service worker itself needs worker globals, so these tests read its +// source: they pin the parts routes.ts cannot express — the API cache's +// strategy, the font strategy's expiration, the font route's place ahead of +// the precache route and network-only requests left to the browser. +import { describe, expect, it } from 'vitest' +import source from '../sw.ts?raw' + +describe('service worker source', () => { + it('serves the API cache with the strategy that marks cache fallbacks', () => { + expect(source).toContain('[cacheNames.api]: createApiStrategy(),') + }) + + it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => { + expect(source).toMatch( + /\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/, + ) + }) + + it('falls back to the precached font when the network fails', () => { + expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)') + }) + + it('registers the font route before the precache route', () => { + const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')") + const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)') + expect(fontRoute).toBeGreaterThanOrEqual(0) + expect(precache).toBeGreaterThan(fontRoute) + }) + + it('leaves network-only requests to the browser instead of answering them', () => { + // Chrome stalls a navigation whose Clear-Site-Data response (POST + // /auth/logout) the worker answered, so the worker must not match it. + expect(source).toMatch( + /function answered\(options: \{ request: Request; url: URL \}\): boolean \{\s*return routeOf\(options\)\.strategy !== 'network-only'/, + ) + expect(source).toContain('registerRoute(answered, handle, method)') + expect(source).not.toContain('registerRoute(() => true') + }) + + it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => { + expect(source).toMatch( + /self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/, + ) + }) +}) diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index e87ca3f..6d0998a 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -4,4 +4,7 @@ import { afterEach } from 'vitest' afterEach(() => { cleanup() + // The verified-session marker and other device state must not leak between + // tests. Tests in the node environment (src/pwa.test.ts) have no storage. + if (typeof localStorage !== 'undefined') localStorage.clear() }) diff --git a/web/tsconfig.json b/web/tsconfig.json index 74f6c16..95c28ed 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -13,9 +13,11 @@ "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true, "isolatedModules": true, + "resolveJsonModule": true, "allowImportingTsExtensions": true, "skipLibCheck": true, "noEmit": true }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "manifest.json"], + "exclude": ["src/sw.ts"] } diff --git a/web/tsconfig.sw.json b/web/tsconfig.sw.json new file mode 100644 index 0000000..a71dd06 --- /dev/null +++ b/web/tsconfig.sw.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "types": [] + }, + "include": ["src/sw.ts"], + "exclude": [] +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 09250bb..48a76c4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,30 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa' +import manifest from './manifest.json' with { type: 'json' } const backend = 'http://localhost:8080' export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + // The plugin writes manifest.webmanifest and sw.js, links the manifest + // from index.html and adds the registerSW.js script. It does both only + // in production builds: dev options stay off, so neither `vite` nor + // Vitest registers a service worker. + VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + injectRegister: 'script', + // Precache the shell, JS, CSS and the self-hosted fonts. + injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] }, + // The web manifest lives in manifest.json: its theme and background + // colours are --color-accent and --color-bg from src/styles/tokens.css, + // and hex colours may not be written in TypeScript. + manifest: manifest as Partial, + }), + ], server: { proxy: { '/api': backend,