diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..6c4ec9f --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,84 @@ +--- +name: verify +description: Build/launch/drive recipe for manually verifying TrackingLink (api + web) end-to-end +--- + +# Verifying TrackingLink locally + +This is a pnpm workspace with two apps that must run together for a real +end-to-end check: `packages/api` (Hono Worker on Cloudflare D1) and +`packages/web` (Vite/React SPA). + +## 1. Local D1 + API + +```sh +cd packages/api +npx wrangler d1 execute trackinglink-db --local --file=./schema.sql # idempotent +npx wrangler dev --local # serves on http://localhost:8789 +``` + +`.dev.vars` already has `ADMIN_PASSWORD=dev-admin-password` and +`JWT_SECRET` for local dev — no setup needed. The local D1 file lives +under `packages/api/.wrangler/state` and **persists across sessions** +(it's gitignored), so previously-seeded test projects/QR codes will +still be there next time — check `GET /projects` before assuming a +clean slate. + +Get a session token for API testing without the browser: + +```sh +curl -s -X POST http://localhost:8789/auth/login \ + -H "Content-Type: application/json" \ + -d '{"password":"dev-admin-password"}' +# -> { "token": "..." } +``` + +## 2. Web dev server against the local API + +```sh +cd packages/web +VITE_API_URL=http://localhost:8789 npx vite --port 5173 +``` + +(Don't use the default `pnpm dev`, which points at the production +`VITE_API_URL` baked into `.env.local`.) + +## 3. Driving the browser + +Playwright's own browser download (`npx playwright install`) is +**blocked in this sandbox** — `cdn.playwright.dev` doesn't resolve. +Instead, reuse the system Chrome and only install `playwright-core` +(pure JS, comes from the npm registry which *is* reachable): + +```sh +mkdir -p /pw && cd /pw +npm init -y && npm install playwright-core +``` + +```js +const { chromium } = require('playwright-core'); +const browser = await chromium.launch({ + executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + headless: true, +}); +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); +``` + +Gotcha: the layout renders **both** a mobile card list (`md:hidden`) +and a desktop table (`hidden md:block`) for the same data, so a plain +`text=...` locator matches twice (one hidden). Force viewport width +≥768px (md breakpoint) and scope locators to `td:has-text(...)` / +`tr:has-text(...)` for the desktop table to avoid picking the hidden +mobile node. + +Login flow: go to `/login`, fill `#password`, click the submit +button, `waitForURL('**/links')`. + +## Notes + +- `pnpm --filter @tracking-link/web dev` / `wrangler dev` (no + `--local`) both talk to production — never use them for + verification. +- Background wrangler/vite processes: don't pipe through `| tail` + when backgrounding — output buffers until the process exits and + you'll see nothing until it's too late to be useful. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4548e1e..5d9ac8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,61 @@ jobs: - run: pnpm typecheck - run: pnpm test - run: pnpm build + + # Behavioural smoke test against a real Worker + local D1. + # + # Gates on behaviour, not numbers: a shared runner cannot produce stable latency + # figures, so thresholds here are loose on timing and strict on things like "the + # scan endpoint returns 302 with no-store" and "pagination does not repeat rows". + # The heavier scenarios (S1–S5) are deliberately NOT in CI — see + # docs/load-testing.md. + smoke: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/api + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + working-directory: . + - uses: grafana/setup-k6-action@v1 + + - name: Apply schema to a local D1 + run: pnpm exec wrangler d1 execute trackinglink-db --local --file=./schema.sql + + - name: Seed a small dataset + run: | + node loadtest/seed/generate.mjs --logs=500 + node loadtest/seed/run.mjs --target=local + + - name: Start the Worker + env: + # Local-only development secrets; the smoke test needs to log in. + JWT_SECRET: ci-smoke-jwt-secret + ADMIN_PASSWORD: dev-admin-password + run: | + printf 'JWT_SECRET=%s\nADMIN_PASSWORD=%s\n' "$JWT_SECRET" "$ADMIN_PASSWORD" > .dev.vars + pnpm exec wrangler dev --local --port 8789 --var CSV_EXPORT_ENABLED:true \ + > wrangler.log 2>&1 & + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:8789/healthz > /dev/null; then + echo "worker ready after ${i}s" + exit 0 + fi + sleep 1 + done + echo "worker never became ready:" + cat wrangler.log + exit 1 + + - name: Run the smoke scenario + run: pnpm loadtest:smoke + + - name: Worker log (on failure) + if: failure() + run: cat wrangler.log diff --git a/.gitignore b/.gitignore index 301b003..f2bd37c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ worker-configuration.d.ts *.log *.tsbuildinfo +# D1 exports. These contain real access logs, including raw IP addresses, so they +# must never be committed — take them, keep them off the machine, delete them. +backup-*.sql + # Editor / IDE .idea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ca5ef8..dc288f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,10 +12,14 @@ covers installing dependencies and running `packages/web` locally against the de ```sh pnpm lint # biome check . pnpm typecheck # tsc --noEmit in both packages +pnpm test # vitest unit tests for packages/web pnpm build # vite build for packages/web ``` -There's no automated test suite yet (see "Not included" below for other gaps) — please describe +There's a small unit test suite (`pnpm test`) covering the pure logic that is +hardest to eyeball — the API error → message mapping, locale-aware date +formatting, filename slugging and the `localStorage` fallback. Page components +are deliberately not covered yet (see "Not included" below for other gaps) — please describe how you manually verified your change in the PR description. ## Scope @@ -27,7 +31,9 @@ focused PRs are much easier to review. Known gaps that are welcome as contributions: -- A real test suite (`vitest` is already used elsewhere in the ecosystem and would fit naturally). +- Component-level tests for the page components (`@testing-library/react` on top of the + existing `vitest` setup). The pages are large and fetch-heavy, so this needs a bit of + structure first. - The LINE Bot/LIFF QR scanner and receipt-printer (ePOS) integrations mentioned in the README's "Not included" section. - Additional `Verifier` implementations under `packages/api/src/auth/` (see the "認証" section in diff --git a/README.md b/README.md index 2e61c7c..d916193 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ QRコード/リンクのアクセスを記録し、リダイレクトするた - **プロジェクト**は遷移先(Instagramアカウント、特定のWebページなど)を表し、同じ遷移先URLを持つQRコードをグループ化する - 各**QRコード**は遷移"元"のソース情報を持つ: **名前**(貼る物の名前、例:「造形大ポスター」)、**媒体**(例:「Instagram」「ポスター」)、**場所**(貼ってある場所や渡された場所、例:「1F掲示板」。任意)。**物ごとに1つのQRコードを発行する**運用なので、名前はプロジェクト内で重複登録できない - スキャンのたびにアクセスログ(日時・User-Agent・IP)を記録し、302リダイレクトで指定URLへ遷移。SNSのリンクプレビュー用クローラーからのアクセスは `is_bot` フラグを立てて記録する(捨てないので、判定を後から改良して過去分を再集計できる) +- **D1に接続できないときは、QRコードに埋め込まれたキーワードから転送先を決めて遷移する**([設定](#5-d1に接続できないときのフォールバック)) - 管理画面でプロジェクトごとのスキャン数、QRコード管理(作成/編集/削除)、アクセスログのCSVダウンロード(現在は無効化中)を確認可能 ``` @@ -103,6 +104,33 @@ pnpm --filter @tracking-link/web build これを設定しないと、自作サーバー上のWebからAPIへのリクエストがブラウザ側でブロックされます(現時点ではドメイン未定のため、localhost分のみ許可されています)。 +### 5. D1に接続できないときのフォールバック + +スキャンのたびにD1から転送先を読むため、**D1に接続できないと全来場者に500が返り、貼り出したポスターが一斉に無反応になります**。最も起こりやすいのはD1の障害そのものより**クォータ枯渇**で、Freeプランの上限(読み取り500万行/日)を超えると Worker は生きたままD1呼び出しだけが失敗します。 + +これを避けるため、QRコードには転送先の**キーワード**が `&p=<キーワード>` として埋め込まれ、Workerは設定だけを見て転送先を決められるようになっています。 + +```jsonc +"vars": { + // D1に繋がらないときの最終手段。未設定だと503を返します。 + "FALLBACK_URL": "https://www.nutfes.net/", + // キーワード -> 転送先。D1が読めないときだけ使われます。 + "FALLBACK_DESTINATIONS": { + "instagram": "https://www.instagram.com/nutfes_official/", + "web": "https://www.nutfes.net/event/" + } +} +``` + +キーワードはプロジェクトごとに管理画面で設定します(転送先URLから自動で提案されます)。**プロジェクトを作ったら、ここにも追記してください。** 忘れても壊れはせず `FALLBACK_URL` に落ちるだけで、平常運転中に `scan_fallback_not_configured` がWorkers Logsに出るので、障害が起きる前に気づけます。 + +転送先URLそのものではなくキーワードを埋めているのは、QRから復元できるURLはQRに入っていなければならず、シンボルが 53×53 から 69×69 まで大きくなるためです(ハッシュは一方向で復元できず、暗号化は平文より長くなるので、暗号技術では回避できません)。キーワードなら +4モジュールで済み、副次的に`&p=`が設定済みの一覧からしか選べないため**オープンリダイレクトが構造的に不可能**になります。 + +**注意点が2つあります。** + +- **D1障害中はリダイレクトは動きますが、スキャンは計上されません。** `AccessLogs.project_id` はNOT NULLで `qr_id` はQRCodesへの外部キーがあり、DBが読めない状況では有効な行を作れないためです。件数は `scan_unlogged_due_to_db_failure` で分かります。 +- **キーワードを後から変更しても、既に印刷したQRコードには反映されません。** 古いキーワードの設定は残しておいてください。 + ## 認証 外部の認証プロバイダは使わず、単一の共有パスワード(`ADMIN_PASSWORD`)方式です。`POST /auth/login`でパスワードを渡すと、24時間有効なHS256 JWTが発行され、以降のリクエストで`Authorization: Bearer `として使用します。 diff --git a/biome.jsonc b/biome.jsonc index 66529a8..6815355 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -14,7 +14,10 @@ "**/dist", "**/node_modules", "**/.wrangler", - "**/worker-configuration.d.ts" + "**/worker-configuration.d.ts", + // Generated load-test fixtures. `useIgnoreFile` above only reads the root + // .gitignore, so the nested one under loadtest/ does not reach biome. + "**/loadtest/.out" ] }, "linter": { diff --git a/docs/load-test-report-2026-07-27.md b/docs/load-test-report-2026-07-27.md new file mode 100644 index 0000000..3b6b405 --- /dev/null +++ b/docs/load-test-report-2026-07-27.md @@ -0,0 +1,321 @@ +# 負荷テスト結果レポート(2026-07-27) + +対象: `https://trackinglink.nutfes-nutmeg9488.workers.dev`(Cloudflare Workers + D1、Free プラン) + +手順と設計判断の根拠は [`load-testing.md`](load-testing.md) を参照。本書は**実施結果と、そこから +分かったこと・分からなかったこと**をまとめる。 + +--- + +## 1. 結論 + +**想定される負荷に対しては問題なく、リリースを止める性能上の理由は見つからなかった。** + +- 本番で **5→50 rps のランプ(5,062 リクエスト)を失敗率 0.00%、p95 36.9 ms で処理**した。 +- 最もリスクの高かった変更(アクセスログ書き込みを `waitUntil` でレスポンス経路から外した件) + について、**5,062 リクエストに対し 5,062 行が着地し、欠落ゼロ**を本番で確認した。 +- Cloudflare 自身のレート制限(429 / 1015)には**一度も引っかからなかった**。 + +一方で、**性能はこのシステムのボトルネックではない**ことも分かった。実質の上限は +**D1 の書き込みクォータ 100,000 行/日**であり、スループットではない(§4)。 + +懸念は §5 に優先度順でまとめた。**性能起因のものは無く、運用と設定に関するものが中心**である。 + +--- + +## 2. 実施内容 + +| | | +|---|---| +| 実施日 | 2026-07-27 | +| 対象 | 本番 Worker(`trackinglink`)+ D1(`trackinglink-db`) | +| D1 リージョン | **APAC** | +| 接続コロ | **NRT**(成田)— `CF-RAY` で確認 | +| 負荷生成 | k6 v2.1.0、ノート PC 1 台、国内回線 | +| 事前バックアップ | 取得済み(5,291 bytes / INSERT 14 文) | +| 事後の状態 | **元に戻したことを確認済み**(プロジェクト 1 件・QR 2 件・ログ 9 件、`[loadtest]` 残骸ゼロ、スキャン正常動作) | + +テスト用データは `loadtest/seed/seed-remote.mjs` が管理 API 経由で作成した。全て `[loadtest]` +接頭辞を持ち、終了後に `--cleanup` で削除している(接頭辞の無いものには触らないガード付き)。 + +### 実行したシナリオ + +| | 内容 | 実行先 | +|---|---|---| +| P1 | スキャン経路 5→50 rps ランプ、60 秒維持 | 本番 | +| S3 | 管理ダッシュボード 5 VU × 1 分 | 本番 | +| S5 | ログイン総当たり 20 rps × 30 秒 | 本番 | +| S1 / S2 / S4 / S6 | 持続 / スパイク / CSV / スモーク | ローカル | + +--- + +## 3. 結果 + +### 3.1 P1 — スキャン経路(本番) + +| 指標 | 実測 | しきい値 | | +|---|---|---|---| +| リクエスト数 | 5,062 | — | | +| 失敗率 | **0.00%** | < 0.5% | ✓ | +| p95 | **36.9 ms** | < 500 ms | ✓ | +| p99 | **65.5 ms** | < 1,000 ms | ✓ | +| 平均 / 中央値 | 28.9 ms / 26.9 ms | — | | +| 最大 | 498.7 ms | — | | +| エッジのレート制限(429/1015) | **0 件** | 0 | ✓ | +| 必要 VU 数 | **11**(30 確保) | — | 余裕あり | + +必要 VU 数が確保数の 1/3 で済んでいることは、**サーバー側が待たせていない**ことを意味する。 +レイテンシが伸びていれば VU が滞留して数が増える。 + +### 3.2 書き込みの 1:1 照合(本レポートで最も重要) + +スキャンのアクセスログ書き込みは `executionCtx.waitUntil()` に移してある。これはレスポンスを +速くする代わりに、**書き込みが失敗してもクライアントからは見えない**という代償を負う変更で、 +一連の修正の中で最もリスクが高かった。 + +``` +P1 前のログ件数 1 +P1 後のログ件数 5,063 +差分 5,062 +k6 の発行数 5,062 +ずれ 0 ← 完全に 1:1 +``` + +**本番の実負荷で 1 件も落ちていない。** プロジェクトごとの内訳も設計した Zipf 分散どおりで、 +偏りのあるアクセスパターンでも取りこぼしがないことが確認できた。 + +| プロジェクト | 件数 | 割合 | +|---|---|---| +| 企画 1 | 3,042 | 60.1% | +| 企画 2〜5 | 520 / 489 / 502 / 510 | 各約 10% | + +### 3.3 S3 — 管理ダッシュボード(本番) + +| 指標 | 実測 | +|---|---| +| リクエスト数 | 5,464 | +| 失敗率 | 0.00% | +| p95 | 82.4 ms | +| ページ 1 平均 | 63.1 ms | +| ページ 3 平均 | 38.5 ms | + +正当性の確認も通っている — **ページ 1 と 3 で projectId の重複・欠落なし**(`ORDER BY` 欠落 +バグの回帰チェック)、`limit=500` は 50 に丸められる。 + +**ただしこの数字は性能の証明にはならない。** 本番のアクセスログは約 5,100 件しかなく、 +「集計をページ内に絞る」修正が効く規模ではない。その検証は 50 万行のローカルで行う想定。 + +### 3.4 S5 — ログインのレート制限(本番) + +| | | +|---|---| +| 試行 | 601 回(誤パスワード) | +| 429 で拒否 | **490 回(81.5%)** | +| 通過 | **111 回(18.5%)** | +| 実効の漏れ | **約 3.7 回/秒** | + +**設定値は「10 回/60 秒」だが、実際にはその 20 倍以上通っている。** Cloudflare のレート制限 +バインディングは best-effort かつロケーション単位で、設定値は上限ではなく目標値と考えるべき。 + +窓が明けた後に正しいパスワードでログインできることも確認済み(テスト直後は同一 IP なので 429、 +65 秒後に 200)。 + +### 3.5 ローカルで確認したこと + +| | 結果 | +|---|---| +| S1 持続(50 rps) | 1,717 リクエスト → **1,717 行、1:1** | +| S2 スパイク(500 rps 要求) | **5xx ゼロ / 書き込み競合ゼロ**。ただし §6 参照 | +| S4 CSV(12,009 行) | **TTFB 14〜23 ms に対し全体 400〜500 ms** = ストリーミングが効いている証拠。**キーセットページングの境界で重複 0・欠落 0**(5,000 行を 5 種類の日時だけでシードし、2,000 行のページ境界が同一日時グループ内に落ちる条件で検証) | +| S6 スモーク | 21/21 通過(302 + `no-store`、不明 ID は 404、クローラーは OG メタ、**`Line/` は人間扱い**、`javascript:` URL 拒否、ページング安定) | + +--- + +## 4. 容量の評価 — 本当の制約は何か + +**スループットは制約ではない。** 50 rps を p95 37 ms・VU 11 本で処理しており、余力がある。 + +**実質の上限は D1 の書き込みクォータ 100,000 行/日**である。スキャン 1 回 = 書き込み 1 行なので: + +| | | +|---|---| +| 1 日に処理できるスキャン | **最大 100,000 回** | +| 50 rps を維持した場合の枯渇まで | **約 33 分** | +| 想定イベント規模(2 万スキャン) | クォータの **20%** | +| 今回のテストで消費 | 約 10%(P1 の 5,062 + 掃除の削除分。**D1 は DELETE も書き込みに計上する**) | + +想定規模には十分な余裕があるが、**バースト耐性ではなく総量が効く**という理解が重要。 + +### クォータが尽きたときに何が起きるか + +書き込みクォータだけが尽きた場合、**リダイレクトは動き続け、記録だけが止まる**: + +1. 読み取りクォータ(500 万行/日 = 250 万スキャン相当)はまず尽きない +2. → QR の参照は成功し、**正しい遷移先へ 302 する** +3. → `waitUntil` の書き込みだけが失敗し、`access_log_insert_failed` がログに出る + +つまり**来場者体験は壊れず、分析データだけが欠ける**という縮退の仕方をする。これは望ましい +順序である。 + +なお読み取りが失敗する状況(D1 障害など)では、QR に埋め込んだキーワードから +`FALLBACK_DESTINATIONS` を引いて転送する経路が動く。この経路自体は検証済みだが、**本番の +実障害での動作確認はできていない**。 + +--- + +## 5. 懸念点(優先度順) + +### 5.1 `ADMIN_PASSWORD`(対応中) + +別途対応いただくため詳細は省略。S5 の結果が示すとおり、**レート制限は 3.7 回/秒しか止められず、 +推測可能なパスワードを埋め合わせない**という点だけ記録しておく。 + +### 5.2 D1 書き込みクォータが実質の上限 + +**イベント中にコードを出しても直せない唯一の制約。** §4 のとおり 1 日 10 万スキャンが天井。 + +- **当日の監視項目として最優先**: Cloudflare ダッシュボード → D1 → Metrics の当日 rows written +- **事前に合意しておくべきこと**: 「上限の 60% を超えたら即 Paid プラン($5/月)に切り替える」 +- Paid にすると書き込み・CPU・サブリクエスト・リクエストの各上限がまとめて緩む + +### 5.3 レート制限が設定値より大幅に緩い + +設定「10 回/60 秒」に対し実測 **約 3.7 回/秒(1 日約 32 万回)**。 + +強いパスワードに対しては意味のある防御だが、**辞書に載る単語であれば無力**。設定値を信じて +「10 回/分しか試せない」と考えるのは誤り。 + +### 5.4 `GIT_SHA` が未設定で、何がデプロイされているか識別できない + +本番の `/healthz` が `{"ok":true,"version":"dev"}` を返す。**バージョンが常に `dev`** なので、 +ロールバック後やデプロイ直後に「今動いているのはどのコミットか」を確認する手段がない。 + +障害対応中にこれが分からないのは地味に効く。Workers Builds の環境変数に `GIT_SHA` を設定するか、 +デプロイ時に埋め込む。 + +### 5.5 外部監視が未設定 + +`/healthz` と `/readyz` は実装済みで動作も確認したが、**それを叩く監視が無い**。 + +- 無料の UptimeRobot などを `/healthz` に 1〜5 分間隔で向ける +- `/readyz` は D1 到達性まで見る(1 分間隔でも約 1,440 読み取り/日で、500 万/日に対して無視できる) +- **`GET /` は監視先に使えない** — `?id=` 無しでは 404 を返す仕様なので永遠にアラートが鳴る + +### 5.6 D1 障害中はスキャンが計上されない + +設計上の割り切りとして受け入れた挙動だが、記録しておく。`AccessLogs.project_id` は NOT NULL で +`qr_id` は QRCodes への外部キーがあるため、**DB が読めない状況では有効な行を作れない**。 + +リダイレクトは動くが、その間のスキャン数は失われる。件数は +`scan_unlogged_due_to_db_failure` で把握できる。 + +### 5.7 CSV 出力が本番で未検証 + +`CSV_EXPORT_ENABLED` は `"false"` のままなので、本番では一度も動かしていない。 + +ローカルでは 12,009 行で TTFB 14 ms、境界の重複・欠落ゼロを確認済み。**有効化する前に、 +本番データで一度 S4 を回すこと。** Free の CPU 10 ms 制限に当たりやすい経路なので、 +エラー 1102 が出ないかを Workers Logs で確認する。 + +### 5.8 リクエスト単位の CPU 時間が未確認 + +k6 からは見えない。**Free プランの 10 ms 制限に対する余裕が分かっていない。** + +Cloudflare ダッシュボード → Workers → Metrics で CPU 時間 p99 を確認する必要がある。 +今回のテスト直後であれば、その時間帯のデータが残っているはず。 + +### 5.9 本番 D1 の高並列時の挙動が未測定 + +50 rps では書き込み競合ゼロだったが、それ以上は測っていない。**Cloudflare の DDoS テスト指針上、 +`*.workers.dev` は「Cloudflare にホストされている」「トラフィックが Cloudflare を経由する」の +両方に該当し、大きな負荷をかけるには事前連絡が要る**([`load-testing.md` §2.1](load-testing.md))。 + +通常のオリジンなら IP 許可やグレイクラウドで迂回して素の容量を測れるが、**Worker には迂回先 +となるオリジンが存在しない**ため、この手は使えない。 + +想定ピーク(50 rps)の範囲では問題が無いことが確認できているので、実務上の判断としては十分と +考えている。 + +### 5.10 `*.workers.dev` の構造的な制約 + +独自ドメインに移行するまで、以下は使えない: + +- **Cache API と `Cache-Control` が効かない** → キャッシュ戦略は Worker 内メモリのみ +- **WAF のレート制限ルールが効かない** → ログイン保護は Rate Limiting バインディング頼み +- **IP Access Rules が使えない** → 自分のゾーンではないため + +移行する場合、**QR に焼き込む URL が変わるのでポスター印刷前が唯一のタイミング**。 + +### 5.11 セッション TTL 8 時間 と複数日イベント + +意図した設定(全権限トークンの寿命を短くする)だが、**複数日開催なら毎日ログインし直す**ことに +なる。セッション切れは `/login` へ誘導して元のページに戻る導線を実装済みなので詰まりはしないが、 +現場のスタッフには事前に伝えておくとよい。 + +--- + +## 6. 測れていないこと・この結果が言えないこと + +数字の読み違いを防ぐため明記する。 + +**「50 rps 捌けた」以上のことは言えない。** 1 台のノート PC からのテストであり、Cloudflare の +エッジを飽和させることは原理的にできない。今回測ったのは「想定ピークで劣化しないこと」であって、 +上限ではない。 + +**ローカルの S2(500 rps スパイク)は本番の指標にならない。** 実測すると目標 500 rps に対し +実効 20.6 rps、サーバー側が 1 リクエストに平均 11.9 秒かかっていた。飽和していたのは +**ローカルの workerd** で、単一 SQLite ファイル + シングルスレッドという構成は本番 D1 とは +別物である。S2 から言えるのは「完全に飽和させても書き込み競合と 5xx は出ない」までで、 +本番のスループットについては何も言えない。 + +**S3 は本番では意味のある負荷になっていない。** 本番のログが約 5,100 件しかないため。 + +**`access_log_insert_failed` の発生有無は Workers Logs で未確認。** 書き込みが 1:1 で +着地しているので実質ゼロのはずだが、直接は見ていない。 + +--- + +## 7. 推奨アクション + +### イベント前に必ず + +1. **`ADMIN_PASSWORD` の変更**(対応中) +2. **`GIT_SHA` を設定**して `/healthz` でデプロイ内容が分かるようにする(§5.4) +3. **外部監視を `/healthz` に向ける**(§5.5) +4. **CPU 時間 p99 をダッシュボードで確認**(§5.8)— Free の 10 ms に対する余裕を把握する +5. **「D1 書き込みが上限の 60% を超えたら Paid に切り替える」を事前合意**(§5.2) +6. **ロールバック手順を 1 回リハーサル** — ダッシュボードの Rollback を実際に押してみる + +### 状況に応じて + +- CSV を使うなら、**有効化前に本番で S4 を 1 回**(§5.7) +- 独自ドメインに移行するなら、**ポスター印刷より前**(§5.10) + +### イベント当日に見るもの + +1. **D1 → Metrics の当日 rows written vs 100,000** — アラームすべき唯一の数字 +2. Workers → Metrics の 5xx と CPU 時間 p99 +3. Workers → Logs の `access_log_insert_failed` / `scan_served_from_fallback` / + `scan_unlogged_due_to_db_failure` / 1101 / 1102 / 1015 +4. **数時間ごとに自分で実物のポスターをスキャンする** — 「印刷された QR が間違った先を指している」 + はどのダッシュボードにも出ない + +--- + +## 付録: 再現方法 + +```sh +cd packages/api + +# 本番用のテストデータを作る(全て [loadtest] 接頭辞が付く) +node loadtest/seed/seed-remote.mjs --base= --password= --projects=5 --qrs=10 + +# P1 +BASE_URL= k6 run loadtest/k6/scan-ramping-prod.js + +# 後片付け(必須) +node loadtest/seed/seed-remote.mjs --base= --password= --cleanup +``` + +**実行前に D1 のバックアップを取り、当日の rows written が少ないことを確認すること。** +クォータのリセットは 00:00 UTC = 09:00 JST。 diff --git a/docs/load-testing.md b/docs/load-testing.md index 1a4524d..ad70c0f 100644 --- a/docs/load-testing.md +++ b/docs/load-testing.md @@ -43,12 +43,37 @@ NUTFES 本番運用に向けた、TrackingLink の負荷テスト手順とリリ 1. **1 台の PC からは自分の上り帯域を測っているだけ。** Cloudflare は 1 台では飽和させられないエッジで受け止める。「500 rps 捌けた」を成果として引用してはいけない。 2. **見るべきは集計値ではなくリクエスト単位の指標。** Worker の **CPU 時間**(Free は 10 ms 上限)と **D1 の rows read / rows written per request**。CPU と読み取り行数が半分になれば本物の改善、ノート PC の RPS が上がっただけなら NIC が温まっただけ。 3. **エラー率を信じる前に Cloudflare ダッシュボードの Security → Events を見る。** 単一 IP から `*.workers.dev` を叩くと Cloudflare 自身の濫用対策で 429 / 1015 が返り、**アプリの障害に見える**。 -4. **Cloudflare は大規模なストレステストについて事前連絡を求めている。** 1 台から数千リクエストはその水準には遠いが、分散クラウド負荷生成を本番ホスト名に向けないこと。 +4. **Cloudflare のDDoSテスト指針に、我々はまともに該当する。** 詳細は §2.1。 5. **`*.workers.dev` では Cache API と `Cache-Control` が効かない。** キャッシュのベンチマークを workers.dev に対して回しても何も測れない。独自ドメインに移行するまでキャッシュ戦略は Worker 内メモリのみ。 6. **D1 は 1 データベースあたり単一ライター(SQLite)。** `AccessLogs` は単一テーブルなので、**アクセスを複数プロジェクトに分散させても書き込み競合は緩和しない**。期待してはいけない。 --- +### 2.1 Cloudflare のDDoSテスト指針と、この構成の特殊事情 + +Cloudflare は自分の資産に対する攻撃シミュレーションを認めているが、**プロパティが Cloudflare に +ホストされている場合**、および**トラフィックがプロパティに到達する前に Cloudflare を経由する場合**は、 +事前連絡が必要としている。 + +**TrackingLink はこの両方に該当する。** そして通常の回避策が使えない: + +| よくある構成 | TrackingLink | +|---|---| +| 自前オリジン + Cloudflare がプロキシ | **Worker 自体が Cloudflare 上で動く** | +| オリジンのIPを直接叩いて迂回できる | **迂回先が存在しない** | +| DNSレコードをグレイクラウドにできる | `*.workers.dev` はDNSレコードではない | +| IP Access Rules で送信元を許可できる | workers.dev は自分のゾーンではない | + +つまり「Cloudflare を外して素の容量を測る」という選択肢が構造的に無い。WAF のレート制限ルールが +`*.workers.dev` に効かないのと同じ理由である。 + +**この制約への対応: 本番テストは「攻撃の模擬」ではなく「予想される実ピークの再現」に留める。** +想定実ピークは通常約 5 rps、最悪でもステージ告知時に 50 rps 程度。P1 を **50 rps** に設定して +あるのはこのためで、これは実際に起こりうるトラフィックそのものなので容量確認として説明がつく。 + +**これを大きく超える数字を測りたい場合は、先に Support チケットを開くこと。** チケットには +対象ホスト・実施日時(タイムゾーン込み)・送信元IP・ピーク rps・持続時間を書く。 + ## 3. ツールと配置 **k6 を使う。** Windows なら `winget install k6`(または `scoop install k6` / `choco install k6`)の 1 コマンドで、単一 Go バイナリが入る。 @@ -148,7 +173,14 @@ loadtest:prod:ramp k6 run loadtest/k6/scan-ramping-prod.js ### 4.4 本番(少量) -管理 API 経由で **5 プロジェクト × 各 10〜50 QR** を作る。11 件以上にして、管理画面のページングを実 D1 レイテンシで確認する。遷移先は `https://example.com/...` にしておく。 +`loadtest/seed/seed-remote.mjs` が管理 API 経由で作る。SQL を本番に流さないのは、ノート PC から実 DB への書き込み経路を開けないため。作られるものは全て `[loadtest]` 接頭辞を持ち、`--cleanup` で消える(接頭辞の無いものには触らないガード付き)。 + +``` +node loadtest/seed/seed-remote.mjs --base= --password= --projects=5 --qrs=10 +node loadtest/seed/seed-remote.mjs --base= --password= --cleanup +``` + +**これを実行せずに本番へ k6 を向けると 404 しか測れない。** 既定の `qrids.json` はローカルで採番した ID を持つため。既存のローカル用マニフェストは `qrids.local.json` に退避される。 --- @@ -161,25 +193,52 @@ loadtest:prod:ramp k6 run loadtest/k6/scan-ramping-prod.js | # | シナリオ | 形 | データ | 合否基準 | |---|---|---|---|---| | **S1** | スキャン持続 | `constant-arrival-rate` 50 rps × 5 分(15,000 件)、qrId は Zipf 重み、`redirects: 0` | 50 万ログ | 失敗率 < 0.1% / 全レスポンスが 302 / p95 < 50 ms / **CPU p95 < 8 ms**(10 ms 上限へのマージン)/ D1 書き込み数 = リクエスト数 ±0.1% | -| **S2** | **スキャンスパイク** | `ramping-arrival-rate` 5 → **500 rps** を 30 秒でランプ、60 秒維持、降下 | 50 万ログ | 5xx ゼロ / 失敗率 < 0.5% / p99 < 1 秒 / **`SQLITE_BUSY`・`database is locked`・D1 書き込み競合エラーがゼロ** | +| **S2** | スキャンスパイク | `ramping-arrival-rate` 5 → 500 rps を 30 秒でランプ、60 秒維持、降下 | 50 万ログ | **書き込み競合ゼロ / 5xx ゼロのみ。** レイテンシと失敗率は判定しない(§5.1.1) | | **S3** | 管理ダッシュボード | `GET /projects?page=1&limit=10` を 5 VU × 1 分 + コールド 1 発、`page=3` でも同様 | **50 万ログ / 25 プロジェクト** | p95 < 200 ms / CPU < 8 ms / **リクエストあたり `rows_read` < 5,000** / page 1→3 で projectId が重複せず欠落しない | | **S4** | CSV 出力 | 1 VU 逐次 3 回、1 万行 / 5 万行 / 上限超え | 5 万 / 10 万 | 200 / **TTFB < 2 秒で行数にほぼ依存しない** / エラー 1102 なし / メモリ超過なし / 上限超えは 500 ではなく **413 + 実件数** | -| **S5** | ログイン総当たり | 誤パスワードで 20 rps × 30 秒 | 任意 | 90% 以上が 429 / 別キーでの正パスワードログインは成功する | +| **S5** | ログイン総当たり | 誤パスワードで 20 rps × 30 秒 | 任意 | **75% 以上が 429**(本番実測 81.5%、§5.5)/ 窓が明ければ正パスワードで入れる | | **S6** | スモーク・回帰(**CI ゲート**) | 1 VU で全エンドポイント 1 周 | 200 ログ | §5.4 参照 | -**S2 がこの計画で最も価値が高い。** D1 は単一ライターなので、**500 並列 INSERT が優雅に直列化するのか、エラーを吐き始めるのかを答える唯一のシナリオ**。他のどのシナリオもこの問いには答えない。書き込み 3 万件は Free の日次クォータの 30% を消費するため、**必ずローカルのみで回す**。 +#### 5.1.1 S2 で分かること・分からないこと(実測による訂正) + +当初「S2 は D1 が優雅に直列化するかエラーを吐くかを答える唯一のシナリオ」と書いたが、**これは +言い過ぎだった**。実測結果: + +``` +no 5xx 5xx ゼロ +d1_write_conflicts: 0 書き込み競合ゼロ +http_reqs: 6761 (20.6/s) 目標 500/s に対し実測 20.6/s +dropped_iterations: 29791 発行すらできなかった分 +http_req_waiting: avg=11.9s max=4m34s +``` + +`http_req_blocked` / `http_req_connecting` はマイクロ秒なので接続の問題ではなく、**サーバー側が +1 リクエストに平均 11.9 秒かかっていた**。飽和していたのはローカルの workerd である。 + +**ローカル D1 は単一 SQLite ファイルをシングルスレッドの workerd が触るもので、本番 D1 とは +別物。** したがって S2 から言えるのは「ローカルランタイムを完全に飽和させても書き込み競合と +5xx は出ない」までで、**本番のスループットについては何も言えない**。 + +このためレイテンシと失敗率のしきい値は外した。この構成では必ず失敗し、常に失敗するテストは +無視されるようになるため。判定は `d1_write_conflicts == 0` と `checks > 99%` のみ。 + +**PR #1 のレビューで出た「SQLite の同時書き込み」への回答としては、ここまでが限界。** 本番 D1 の +挙動を本当に知るには本番で高並列を出すしかないが、それは §2.1 のとおり事前連絡が要る領域なので、 +今回は「予想される実ピーク(50 rps)で問題が出ないこと」の確認に留める。 + +書き込み 3 万件は Free の日次クォータの 30% を消費するため、**必ずローカルのみで回す**。 **S1・S3 は修正前のベースラインを先に記録する。** 悪い数字がそのまま before/after の証拠になる。特に S3 の `rows_read` は本当のアサーションで、レイテンシだけ見ていると温まったページキャッシュが全件走査を隠す。 -### 5.2 本番で回すもの(合計約 14,000 リクエスト) +### 5.2 本番で回すもの(合計約 11,000 リクエスト) | # | 形 | 件数 | 目的 | |---|---|---|---| -| **P1** | `ramping-arrival-rate` **5 → 200 rps を 60 秒でランプ、60 秒維持**、qrId は 5 プロジェクトに Zipf 分散、`redirects: 0` | 約 9,000 | 実 D1 レイテンシでの**劣化の境界**と信頼できる p95/p99 分布。段階的に上げるので「どこで壊れるか」が分かる(ステップ関数は「どこかで壊れた」しか言わない) | +| **P1** | `ramping-arrival-rate` **5 → 50 rps を 60 秒でランプ、60 秒維持**、qrId は 5 プロジェクトに Zipf 分散、`redirects: 0` | **約 5,060**(実測) | 実 D1 レイテンシでの p95/p99 分布と、リクエスト単位の CPU 時間。**想定実ピークの再現であって攻撃の模擬ではない**(§2.1) | | **P2** | soak 約 0.5 rps × 2〜3 時間 | 約 5,000 | ログ書き込みが**リクエスト数と 1:1 で着地するか**、isolate の安定性、ログが全部届いているか | | **P3** | S3 / S4 / S5 を各 1 回 | 約 100 | 実環境の CPU 時間の実測 | -書き込み消費 ≈ 14,100 / 100,000 = **14%**。修正後の再実行が 2 回分残るのが、この数字を選んだ理由。 +書き込み消費 ≈ 5,100 / 100,000 = **約 5%**(掃除の削除分を含めて約 10%)。再実行の余地は十分ある。 **避けるべき形**: @@ -220,6 +279,19 @@ CI には **S6 のみ**を `smoke` ジョブとして追加する。**これが --- +### 5.5 本番実行の結果 + +実施済み。**結果・懸念点・推奨アクションは [`load-test-report-2026-07-27.md`](load-test-report-2026-07-27.md) にまとめてある。** + +見出しだけ: + +- P1(5→50 rps、5,062 リクエスト)は失敗率 0.00%、p95 36.9 ms、エッジのレート制限ゼロ。 +- **`waitUntil` の書き込みは 5,062 → 5,062 で完全に 1:1、欠落ゼロ。** +- **スループットは制約ではない。実質の上限は D1 の書き込みクォータ 100,000 行/日。** +- レート制限は設定値「10 回/60 秒」に対し実測 約 3.7 回/秒 と大幅に緩い。 + +数値の詳細をここに二重に持たない(片方だけ古くなるため)。 + ## 6. リリース前チェックリスト ### 6.1 リリースブロッカー diff --git a/packages/api/loadtest/.gitignore b/packages/api/loadtest/.gitignore new file mode 100644 index 0000000..90f8a92 --- /dev/null +++ b/packages/api/loadtest/.gitignore @@ -0,0 +1,2 @@ +# Generated seed SQL and the qrids manifest — large, reproducible from generate.mjs. +.out/ diff --git a/packages/api/loadtest/README.md b/packages/api/loadtest/README.md new file mode 100644 index 0000000..2395c5a --- /dev/null +++ b/packages/api/loadtest/README.md @@ -0,0 +1,109 @@ +# 負荷テスト + +計画・シナリオの根拠・リリース前チェックリストは [`docs/load-testing.md`](../../../docs/load-testing.md) にある。 +ここは実行手順だけ。 + +--- + +## ⚠️ 結果を読み違えないための4点 + +**先にこれを読むこと。** ここを飛ばすと、測っていないものを測ったと報告することになる。 + +1. **1台のPCからは自分の上り帯域を測っているだけ。** Cloudflare は1台では飽和させられない + エッジで受け止める。**「500 rps 捌けた」を成果として引用してはいけない。** +2. **見るべきは集計値ではなくリクエスト単位の指標** — Worker の **CPU時間**(Free は 10ms 上限)と + **D1 の rows read / rows written per request**。CPUと読み取り行数が半分になれば本物の改善、 + ノートPCの RPS が上がっただけなら NIC が温まっただけ。 +3. **エラー率を信じる前に Cloudflare の Security → Events を見る。** 単一IPから + `*.workers.dev` を叩くと Cloudflare 自身の濫用対策で 429/1015 が返り、**アプリの障害に見える。** +4. **`*.workers.dev` では Cache API と `Cache-Control` が効かない。** キャッシュのベンチマークを + workers.dev に向けても何も測れない。 + +もう1つ、書き込み側の性質: **`AccessLogs` は単一テーブルで D1 は DB あたり単一ライター**なので、 +**アクセスを複数プロジェクトに分散させても書き込み競合は緩和しない。** 期待してはいけない。 + +--- + +## 準備 + +k6 を入れる(単一 Go バイナリ): + +``` +winget install k6 # または: scoop install k6 / choco install k6 +``` + +シードを作って流す。**`wrangler dev` は止めてから**(起動中に書き込むと workerd が +`kj/table.c++:57: HashIndex detected hash table inconsistency` で落ちる — 実測): + +``` +cd packages/api +node loadtest/seed/generate.mjs --logs=100000 +node loadtest/seed/run.mjs --target=local +``` + +25プロジェクト × 10 QRコード + 指定件数のアクセスログが入る。プロジェクト数が +`PAGE_SIZE`(10)を超えているのは意図的で、**そうでないとページングのバグも集計を +絞る修正の効果も観測できない。** + +データには意図的な偏りを付けている(1プロジェクトに約60%、その中の1 QRに約30%、 +3日間の日内変動 + 10分の急峻なスパイク1回)。一様乱数はインデックスの選択性を +過大評価させ、p95 が実際より良く見えるため。 + +サーバーを起動: + +``` +npx wrangler dev --local --port 8789 +# CSVを試すとき: --var CSV_EXPORT_ENABLED:true +``` + +--- + +## 実行 + +| コマンド | シナリオ | 実行先 | +|---|---|---| +| `pnpm loadtest:smoke` | S6 挙動スモーク(**CIゲート**) | ローカル | +| `pnpm loadtest:scan` | S1 50rps × 5分 | **ローカルのみ** | +| `pnpm loadtest:spike` | S2 5→500rps ランプ | **ローカルのみ** | +| `pnpm loadtest:admin` | S3 管理ダッシュボード | ローカル → 本番 | +| `pnpm loadtest:csv` | S4 CSV出力 | ローカル → 本番 | +| `pnpm loadtest:login` | S5 ログイン総当たり | ローカル → 本番 | +| `pnpm loadtest:prod:ramp` | P1 本番ランプ(約9,000件) | **本番** | + +**S1 と S3 は修正前のベースラインも取る。** 悪い数字がそのまま before/after の証拠になる。 + +**S2 がこの一式で最も価値が高い。** D1 は SQLite で単一ライターなので、500並列 INSERT が +優雅に直列化するのか `SQLITE_BUSY` を吐き始めるのかを答える唯一のシナリオ。 +書き込み3万件は Free の日次クォータの30%なので**必ずローカルで**。 + +--- + +## 本番に向けるとき + +``` +BASE_URL=https://trackinglink..workers.dev \ +ADMIN_PASSWORD=<本番のパスワード> \ +k6 run loadtest/k6/scan-ramping-prod.js +``` + +実行前: + +1. D1 の当日 rows written が0に近いことを確認(リセットは 00:00 UTC = **09:00 JST**)。 +2. `wrangler d1 export trackinglink-db --remote --output=backup-YYYYMMDD.sql` +3. `.out/qrids.json` の ID を**本番に実在する ID** に差し替える(ローカルのシードIDは本番にない)。 + +実行中に見るもの: Workers Metrics の 5xx と CPU時間 p99 / Workers Logs の +`access_log_insert_failed`・1101・1102・1015 / Security → Events。 + +**後片付け**: D1 は **DELETE した行も rows written に計上する**ので、9,000行消すと更に +9,000書き込み。リリース前にどうせ本番を空にするなら、**DBを作り直すのが書き込みゼロで済む** +(`wrangler d1 delete` → `d1 create` → `schema.sql` と移行を流し直す。`database_id` の +更新を忘れないこと)。行単位で消すならテストと別日にする。 + +--- + +## 生成物 + +`loadtest/.out/` は gitignore 済み。`qrids.json` には k6 が同じ偏りを再現するための +重み付き ID 配列が入っている(単一 IDを叩き続けると Worker 内メモリキャッシュの +ヒット率が非現実的に良くなるため)。 diff --git a/packages/api/loadtest/k6/admin-projects.js b/packages/api/loadtest/k6/admin-projects.js new file mode 100644 index 0000000..5b1d1de --- /dev/null +++ b/packages/api/loadtest/k6/admin-projects.js @@ -0,0 +1,74 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { Trend } from 'k6/metrics'; +import { BASE_URL, authHeader } from './lib/config.js'; + +/** + * S3 — admin dashboard against a large access log. + * + * Run this BEFORE the fix as well: `GET /projects` used to GROUP BY over all of + * AccessLogs and all of QRCodes on every request regardless of page, so the + * before/after numbers are the evidence that scoping the aggregation to the + * page's ids was worth doing. + * + * Latency alone is not the assertion. A warm page cache hides a full scan, so the + * number that matters is rows read per request — visible in the Cloudflare + * dashboard, or via `meta.rows_read` when querying D1 directly. + */ +const page1 = new Trend('admin_page1_duration', true); +const deepPage = new Trend('admin_page3_duration', true); + +export const options = { + scenarios: { + dashboard: { + executor: 'constant-vus', + vus: 5, + duration: '1m', + }, + }, + thresholds: { + http_req_failed: ['rate==0'], + http_req_duration: ['p(95)<200'], + checks: ['rate==1.0'], + }, +}; + +export function setup() { + return { headers: authHeader() }; +} + +export default function (data) { + const { headers } = data; + + const first = http.get(`${BASE_URL}/projects?page=1&limit=10`, { headers }); + page1.add(first.timings.duration); + check(first, { 'page 1 is 200': (r) => r.status === 200 }); + + const third = http.get(`${BASE_URL}/projects?page=3&limit=10`, { headers }); + deepPage.add(third.timings.duration); + check(third, { 'page 3 is 200': (r) => r.status === 200 }); + + // Correctness, not just speed: paging must not repeat or drop rows. This is + // what catches a missing ORDER BY, which latency never would. + if (first.status === 200 && third.status === 200) { + const ids1 = first.json('data').map((p) => p.projectId); + const ids3 = third.json('data').map((p) => p.projectId); + check( + {}, + { + 'page 1 and page 3 do not overlap': () => + ids1.filter((id) => ids3.includes(id)).length === 0, + }, + ); + } + + // The limit is capped at 50 for this endpoint, because the aggregation binds + // one parameter per project id and D1 allows about 100 per query. + const overLimit = http.get(`${BASE_URL}/projects?page=1&limit=500`, { + headers, + }); + check(overLimit, { + 'limit is clamped rather than rejected': (r) => + r.status === 200 && r.json('data').length <= 50, + }); +} diff --git a/packages/api/loadtest/k6/csv-export.js b/packages/api/loadtest/k6/csv-export.js new file mode 100644 index 0000000..bca1efd --- /dev/null +++ b/packages/api/loadtest/k6/csv-export.js @@ -0,0 +1,118 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { Trend } from 'k6/metrics'; +import { BASE_URL, authHeader, projectIds } from './lib/config.js'; + +/** + * S4 — CSV export. + * + * The assertion that proves streaming works is that **time to first byte stays + * roughly flat as the row count grows**. Total duration obviously scales with + * size; TTFB should not, because the response starts before the rows are read. + * + * Requires CSV_EXPORT_ENABLED=true, e.g. + * wrangler dev --local --var CSV_EXPORT_ENABLED:true + */ +const ttfb = new Trend('csv_time_to_first_byte', true); +const total = new Trend('csv_total_duration', true); + +export const options = { + vus: 1, + iterations: 3, + thresholds: { + // Flat, and fast, regardless of how many rows follow. + csv_time_to_first_byte: ['p(95)<2000'], + csv_total_duration: ['p(95)<30000'], + checks: ['rate==1.0'], + }, +}; + +export function setup() { + return { headers: authHeader() }; +} + +export default function (data) { + const { headers } = data; + // The seed skews most traffic to the first project, so this is the big one. + const projectId = projectIds[0]; + + const res = http.get(`${BASE_URL}/projects/${projectId}/access-logs/csv`, { + headers, + timeout: '120s', + }); + + if (res.status === 403) { + check(res, { + '403 means the flag is off, not a crash': (r) => + r.json('code') === 'CSV_EXPORT_DISABLED', + }); + console.log( + 'CSV export disabled — re-run with --var CSV_EXPORT_ENABLED:true to exercise it', + ); + return; + } + + ttfb.add(res.timings.waiting); + total.add(res.timings.duration); + + if (res.status === 413) { + // Over the row cap. The point is that it says so with real numbers instead + // of running the Worker out of CPU and returning a bare 500. + check(res, { + 'over-cap returns 413, not 500': (r) => r.status === 413, + '413 reports the actual and maximum counts': (r) => + typeof r.json('meta.total') === 'number' && + typeof r.json('meta.max') === 'number', + }); + console.log( + `over cap: ${res.json('meta.total')} rows vs max ${res.json('meta.max')} — narrow with ?from=&to=`, + ); + return; + } + + const body = res.body || ''; + const lines = body.split('\r\n').filter((line) => line.length > 0); + + check(res, { + 200: (r) => r.status === 200, + // Streaming is asserted through the timings, not the Transfer-Encoding + // header: k6's Go HTTP client strips `chunked` from the header map once it + // has decoded it, so the header is not observable from here (curl -D does + // show it). What *is* observable — and is the property that matters — is that + // the first byte arrives long before the last one, i.e. the response starts + // before all the rows have been read out of D1. A buffered implementation + // would have TTFB ≈ total. + 'streamed: first byte arrives well before the last': (r) => + lines.length < 2000 || r.timings.waiting < r.timings.duration * 0.5, + 'UTF-8 BOM for Excel': () => body.charCodeAt(0) === 0xfeff, + 'CRLF line endings (RFC 4180)': () => body.includes('\r\n'), + 'has the bot column': () => lines[0].includes('ボット'), + 'filename is derived from the project name': (r) => + !/access-logs-[0-9a-f-]{36}\.csv/.test( + r.headers['Content-Disposition'] || '', + ), + 'newest row first': () => { + if (lines.length < 3) return true; + return lines[1].slice(0, 24) >= lines[lines.length - 1].slice(0, 24); + }, + }); + + console.log( + `rows: ${lines.length - 1}, ttfb: ${Math.round(res.timings.waiting)}ms, total: ${Math.round(res.timings.duration)}ms`, + ); + + // A narrowed range must return strictly fewer rows. + const narrowed = http.get( + `${BASE_URL}/projects/${projectId}/access-logs/csv?from=2026-07-05&to=2026-07-05`, + { headers, timeout: '120s' }, + ); + if (narrowed.status === 200) { + const narrowedLines = (narrowed.body || '') + .split('\r\n') + .filter((l) => l.length > 0); + check(narrowed, { + 'date range narrows the result': () => + narrowedLines.length < lines.length, + }); + } +} diff --git a/packages/api/loadtest/k6/lib/config.js b/packages/api/loadtest/k6/lib/config.js new file mode 100644 index 0000000..9fe9fec --- /dev/null +++ b/packages/api/loadtest/k6/lib/config.js @@ -0,0 +1,57 @@ +import { SharedArray } from 'k6/data'; +import http from 'k6/http'; + +export const BASE_URL = __ENV.BASE_URL || 'http://127.0.0.1:8789'; +export const PASSWORD = __ENV.ADMIN_PASSWORD || 'dev-admin-password'; + +/** + * QR ids weighted to match the skew in the seeded data. + * + * Hammering a single id would give the Worker's in-isolate cache a hit rate it + * will never see in the field, so every scan scenario samples from here instead. + */ +export const qrIds = new SharedArray('qrIds', () => { + const manifest = JSON.parse(open('../../.out/qrids.json')); + return manifest.weightedQrIds; +}); + +export const projectIds = new SharedArray('projectIds', () => { + const manifest = JSON.parse(open('../../.out/qrids.json')); + return manifest.projectIds; +}); + +export function randomQrId() { + return qrIds[Math.floor(Math.random() * qrIds.length)]; +} + +/** Logs in and returns an Authorization header. */ +export function authHeader() { + const res = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ password: PASSWORD }), + { headers: { 'Content-Type': 'application/json' } }, + ); + if (res.status !== 200) { + throw new Error(`login failed: ${res.status} ${res.body}`); + } + return { Authorization: `Bearer ${res.json('token')}` }; +} + +/** + * `redirects: 0` is not optional for the scan path. + * + * The endpoint answers with a redirect to the campaign's destination. Any client + * that follows it measures that third-party site, not this Worker. + */ +export const NO_REDIRECT = { redirects: 0 }; + +/** A representative mix of real user agents, including LINE's in-app browser. */ +export const HUMAN_AGENTS = [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Line/13.15.0', +]; + +export function randomHumanAgent() { + return HUMAN_AGENTS[Math.floor(Math.random() * HUMAN_AGENTS.length)]; +} diff --git a/packages/api/loadtest/k6/login-bruteforce.js b/packages/api/loadtest/k6/login-bruteforce.js new file mode 100644 index 0000000..9c85105 --- /dev/null +++ b/packages/api/loadtest/k6/login-bruteforce.js @@ -0,0 +1,94 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { Counter } from 'k6/metrics'; +import { BASE_URL, PASSWORD } from './lib/config.js'; + +/** + * S5 — login brute force. + * + * Run it before the fix too: the attempts/second it achieves against an + * unprotected single shared password *is* the justification for adding the + * limiter. + * + * The limiter is keyed on CF-Connecting-IP, so an attacker throttles their own + * address and cannot lock the real admin out. It is also per-colo rather than + * global, which is ample for an admin panel but is not a defence against a + * distributed attacker. + * + * ## Measured against production, and what it actually buys + * + * 601 attempts over 30s: **490 rejected with 429, 111 let through** — about 3.7 + * attempts per second sustained, not the ~10-per-60s the configuration reads + * like. Cloudflare's rate limiting binding is explicitly best-effort and enforced + * per location, so the configured number is a target rather than a hard ceiling. + * + * 3.7/s is roughly 320,000 attempts per day from one address. That is real + * protection against a dictionary run at a strong password, and no protection at + * all against a weak one — the limiter buys time, it does not substitute for the + * password being unguessable. + * + * The threshold below is therefore set from the measurement (>75% turned away) + * rather than from the configured limit. An earlier `count>500` was calibrated + * against the config and failed on a run that was behaving correctly. + */ +const rejected = new Counter('rate_limited_responses'); +const allowed = new Counter('accepted_attempts'); + +const ATTEMPT_RATE = 20; +const DURATION_SECONDS = 30; +/** 75% of the attempts issued. See the measurement note above. */ +const MIN_REJECTED = Math.floor(ATTEMPT_RATE * DURATION_SECONDS * 0.75); + +export const options = { + scenarios: { + bruteforce: { + executor: 'constant-arrival-rate', + rate: ATTEMPT_RATE, + timeUnit: '1s', + duration: `${DURATION_SECONDS}s`, + preAllocatedVUs: 20, + maxVUs: 100, + }, + }, + thresholds: { + rate_limited_responses: [`count>${MIN_REJECTED}`], + }, +}; + +const json = { headers: { 'Content-Type': 'application/json' } }; + +export default function () { + const res = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ password: `wrong-${Math.random()}` }), + json, + ); + + if (res.status === 429) { + rejected.add(1); + check(res, { + '429 carries RATE_LIMITED': (r) => r.json('code') === 'RATE_LIMITED', + }); + } else { + allowed.add(1); + check(res, { + // Never 200 for a wrong password, and never a 500. + 'wrong password is 401': (r) => r.status === 401, + '401 is INVALID_PASSWORD, not UNAUTHORIZED': (r) => + r.json('code') === 'INVALID_PASSWORD', + }); + } +} + +export function teardown() { + // The limiter must not have permanently locked out the legitimate password — + // only throttled this source for the window. + const res = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ password: PASSWORD }), + json, + ); + console.log( + `\nCorrect password immediately after the burst: ${res.status} (429 is expected from the same IP inside the window; it must succeed once it rolls)`, + ); +} diff --git a/packages/api/loadtest/k6/scan-ramping-prod.js b/packages/api/loadtest/k6/scan-ramping-prod.js new file mode 100644 index 0000000..b6effa8 --- /dev/null +++ b/packages/api/loadtest/k6/scan-ramping-prod.js @@ -0,0 +1,112 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { + BASE_URL, + NO_REDIRECT, + randomHumanAgent, + randomQrId, +} from './lib/config.js'; + +/** + * P1 — the production run. Roughly 5,000 requests (measured: 5,062). + * + * ## Why 50 rps and not more + * + * This is deliberately sized to the *expected* peak rather than to an attack. + * Realistic worst case for the event is a stage announcement producing on the + * order of 50 rps for a short burst; normal load is around 5 rps. + * + * That distinction matters legally, not just technically. Cloudflare's DDoS + * testing guidance requires contacting them beforehand when the property is + * hosted on Cloudflare or when traffic passes through Cloudflare before reaching + * it — and a Worker on *.workers.dev is *both*. The usual escape hatch of + * allow-listing the test source or grey-clouding a subdomain to hit the origin + * directly does not exist here, because there is no origin: the Worker is the + * thing, running on Cloudflare's edge. + * + * At 50 rps this is capacity testing against traffic the system is expected to + * see. Going meaningfully above that turns it into an attack simulation, so open + * a support ticket first if you want a bigger number. + * + * Ramping rather than firing one burst, because a ramp shows *where* degradation + * starts while a step function only says that it did. A single instantaneous + * burst from one machine is also not measurable: ephemeral ports, TLS handshake + * CPU and the uplink saturate before Cloudflare does, so the number describes + * your laptop. + * + * ## Before running + * + * 1. Check today's D1 rows-written is near zero (quota resets 00:00 UTC / 09:00 JST). + * 2. Back up: wrangler d1 export trackinglink-db --remote --output=backup.sql + * 3. Seed ids that actually exist in the target: + * node loadtest/seed/seed-remote.mjs --base= --password= + * The default manifest holds locally-seeded ids, and pointing this at + * production without reseeding measures nothing but 404s. + * + * Budget: ~5,060 requests = ~5,060 D1 writes = ~5% of the Free daily quota. + * Deleting them afterwards costs the same again — D1 bills rows written, not net + * change. + * + * While it runs, watch Workers Metrics (5xx, CPU p99), Workers Logs + * (access_log_insert_failed / 1101 / 1102 / 1015) and Security → Events, so that + * Cloudflare's own abuse protection is not mistaken for an application failure. + */ +export const options = { + scenarios: { + ramp: { + executor: 'ramping-arrival-rate', + startRate: 5, + timeUnit: '1s', + preAllocatedVUs: 30, + maxVUs: 200, + stages: [ + { target: 50, duration: '60s' }, + { target: 50, duration: '60s' }, + { target: 5, duration: '15s' }, + ], + gracefulStop: '15s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.005'], + // Wall-clock budgets, generous on purpose: they include the round trip to + // the nearest colo and a D1 read whose latency depends on where the + // database's primary lives. The number that actually reflects the code is + // CPU time per request in the Cloudflare dashboard — check that too. + http_req_duration: ['p(95)<500', 'p(99)<1000'], + checks: ['rate>0.99'], + }, +}; + +export default function () { + const res = http.get(`${BASE_URL}/?id=${randomQrId()}`, { + ...NO_REDIRECT, + headers: { 'User-Agent': randomHumanAgent() }, + }); + + check(res, { + 302: (r) => r.status === 302, + 'no-store': (r) => (r.headers['Cache-Control'] || '').includes('no-store'), + // 429/1015 here is Cloudflare's abuse protection, not the app. + 'not rate limited by the edge': (r) => + r.status !== 429 && r.status !== 1015, + }); +} + +// See the note in scan-sustained.js: handleSummary would suppress k6's summary. +export function teardown() { + console.log( + [ + '', + 'Reconcile 1:1 against the access log. This is what verifies that moving the', + 'INSERT into waitUntil did not start silently dropping writes under load:', + '', + ' wrangler d1 execute trackinglink-db --remote \\', + ' --command "SELECT COUNT(*) FROM AccessLogs WHERE accessed_at > \'\'"', + '', + 'Compare against http_reqs below, and subtract it from the 100,000/day D1', + 'write budget before running anything else today.', + '', + ].join('\n'), + ); +} diff --git a/packages/api/loadtest/k6/scan-spike.js b/packages/api/loadtest/k6/scan-spike.js new file mode 100644 index 0000000..62008ac --- /dev/null +++ b/packages/api/loadtest/k6/scan-spike.js @@ -0,0 +1,103 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { Counter } from 'k6/metrics'; +import { + BASE_URL, + NO_REDIRECT, + randomHumanAgent, + randomQrId, +} from './lib/config.js'; + +/** + * S2 — scan spike. LOCAL ONLY. + * + * D1 is SQLite with a single writer per database, and every scan inserts into the + * same AccessLogs table, so the question is whether concurrent writes *serialise* + * or start erroring. That is the question raised in review on PR #1. + * + * Note that spreading load across projects does NOT relieve write contention — + * one table, one writer. Do not expect it to. + * + * ## What this scenario can and cannot tell you + * + * Measured: the local runtime saturates at roughly 20 requests/second. Under a + * 500 rps demand it queued, served each request in ~12s on average, and dropped + * ~30k iterations it never managed to start — while returning **zero 5xx and zero + * write conflicts**. + * + * So this answers "does the write path produce SQLITE_BUSY when completely + * saturated" (no) but it says **nothing about production throughput**. Local D1 is + * one SQLite file behind a single-threaded workerd; production D1 is a different + * system. Latency and throughput numbers from here are properties of miniflare on + * a laptop, not of Cloudflare. + * + * Thresholds are therefore only on correctness. Asserting p99 latency or a failure + * rate here would fail on every run for reasons that have nothing to do with the + * code, and a test that always fails gets ignored. + * + * Local only: at the demanded rate this is ~30,000 D1 writes, 30% of the Workers + * Free daily quota, and deletes count as writes too. + */ +const writeConflicts = new Counter('d1_write_conflicts'); + +export const options = { + scenarios: { + spike: { + executor: 'ramping-arrival-rate', + startRate: 5, + timeUnit: '1s', + preAllocatedVUs: 100, + maxVUs: 1000, + stages: [ + { target: 500, duration: '30s' }, // ramp, so the breaking point is visible + { target: 500, duration: '60s' }, // hold + { target: 5, duration: '15s' }, // recover + ], + // Without this, k6 waits out the slowest in-flight request — which was + // over four minutes once the local runtime was saturated. + gracefulStop: '15s', + }, + }, + thresholds: { + // Correctness only. See the note above on why latency is not asserted. + d1_write_conflicts: ['count==0'], + checks: ['rate>0.99'], + }, +}; + +export default function () { + const res = http.get(`${BASE_URL}/?id=${randomQrId()}`, { + ...NO_REDIRECT, + headers: { 'User-Agent': randomHumanAgent() }, + }); + + // A 5xx whose body mentions locking is the signature we are hunting. + if (res.status >= 500) { + const body = res.body || ''; + if (/SQLITE_BUSY|database is locked|write conflict/i.test(body)) { + writeConflicts.add(1); + } + } + + check(res, { + 'no 5xx': (r) => r.status < 500, + 302: (r) => r.status === 302, + }); +} + +// See the note in scan-sustained.js: handleSummary would suppress k6's summary. +export function teardown() { + console.log( + [ + '', + 'Client-side metrics cannot see these — check Workers Logs too:', + ' access_log_insert_failed (a waitUntil write that lost)', + ' 1101 / 1102 (uncaught throw / CPU exceeded)', + '', + 'If http_req_failed is high while d1_write_conflicts is 0, suspect the load', + 'generator rather than D1: one machine cannot always sustain 500 rps, and a', + 'saturated client shows up as timeouts, not as database errors.', + '', + ].join('\n'), + ); +} diff --git a/packages/api/loadtest/k6/scan-sustained.js b/packages/api/loadtest/k6/scan-sustained.js new file mode 100644 index 0000000..b62d920 --- /dev/null +++ b/packages/api/loadtest/k6/scan-sustained.js @@ -0,0 +1,68 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { + BASE_URL, + NO_REDIRECT, + randomHumanAgent, + randomQrId, +} from './lib/config.js'; + +/** + * S1 — sustained scan load. LOCAL ONLY. + * + * `constant-arrival-rate`, not a fixed VU count: QR scanning is an arrival-rate + * phenomenon (people scan at the same rate whether or not the server is slow). + * A closed-model tool self-throttles when latency rises and so hides the exact + * degradation this is looking for. + * + * 50 rps for 5 minutes is roughly 10x the expected real peak, so passing means + * genuine headroom. + */ +export const options = { + scenarios: { + sustained: { + executor: 'constant-arrival-rate', + rate: 50, + timeUnit: '1s', + duration: '5m', + preAllocatedVUs: 50, + maxVUs: 300, + }, + }, + thresholds: { + http_req_failed: ['rate<0.001'], + // Local D1 is sub-millisecond, so this is a CPU/logic budget, not a network + // one. Compare the CPU time in the Cloudflare dashboard for the real number. + http_req_duration: ['p(95)<50', 'p(99)<200'], + checks: ['rate>0.999'], + }, +}; + +export default function () { + const res = http.get(`${BASE_URL}/?id=${randomQrId()}`, { + ...NO_REDIRECT, + headers: { 'User-Agent': randomHumanAgent() }, + }); + check(res, { + 302: (r) => r.status === 302, + 'no-store': (r) => (r.headers['Cache-Control'] || '').includes('no-store'), + }); +} + +// Printed from teardown rather than handleSummary on purpose: returning a value +// from handleSummary *replaces* k6's own stdout summary, which silently hid every +// metric this scenario exists to produce. +export function teardown() { + console.log( + [ + '', + 'Reconcile the access-log writes against http_reqs in the summary below.', + 'This is the real integration test for moving the INSERT into waitUntil —', + 'the delta should equal the request count exactly:', + '', + ' wrangler d1 execute trackinglink-db --local \\', + ' --command "SELECT COUNT(*) FROM AccessLogs"', + '', + ].join('\n'), + ); +} diff --git a/packages/api/loadtest/k6/smoke.js b/packages/api/loadtest/k6/smoke.js new file mode 100644 index 0000000..add0d60 --- /dev/null +++ b/packages/api/loadtest/k6/smoke.js @@ -0,0 +1,169 @@ +import { check, fail } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, NO_REDIRECT, authHeader, randomQrId } from './lib/config.js'; + +/** + * S6 — behavioural smoke test. This is the CI gate. + * + * Thresholds are deliberately loose on timing and strict on behaviour: a shared + * GitHub runner cannot produce stable latency numbers, so gating on those would + * get the job disabled within a month. What it does gate is the set of + * regressions that would be expensive to discover in the field. + */ +export const options = { + vus: 1, + iterations: 1, + thresholds: { + // `checks` is the gate, not http_req_failed: this scenario deliberately + // provokes 404s, 401s and a 400, and every one of them is asserted below. + checks: ['rate==1.0'], + http_req_duration: ['p(95)<500'], + }, +}; + +// Without this, the intentional 4xx probes above are reported as +// `http_req_failed: 30%`, which reads as a broken service to anyone skimming the +// summary. 5xx is still counted as a failure, which is what we care about. +http.setResponseCallback(http.expectedStatuses({ min: 200, max: 499 })); + +export default function () { + // --- health --------------------------------------------------------------- + const health = http.get(`${BASE_URL}/healthz`); + check(health, { + '/healthz is 200': (r) => r.status === 200, + '/healthz reports ok': (r) => r.json('ok') === true, + }); + + const ready = http.get(`${BASE_URL}/readyz`); + check(ready, { '/readyz reaches D1': (r) => r.status === 200 }); + + // GET / without ?id= is a 404 by design — asserted so nobody points a monitor + // at it and then wonders why it alerts forever. + check(http.get(`${BASE_URL}/`), { + 'GET / without id is 404 (not a health target)': (r) => r.status === 404, + }); + + // --- scan path ------------------------------------------------------------ + const qrId = randomQrId(); + const scan = http.get(`${BASE_URL}/?id=${qrId}`, { + ...NO_REDIRECT, + headers: { 'User-Agent': 'k6-smoke-human/1.0 Safari/604.1' }, + }); + check(scan, { + // 302, not 301: a permanent redirect is cached forever, which hides repeat + // scans and pins visitors to a stale destination. + 'scan returns 302': (r) => r.status === 302, + 'scan sets Cache-Control: no-store': (r) => + (r.headers['Cache-Control'] || '').includes('no-store'), + 'scan sets a Location': (r) => Boolean(r.headers.Location), + }); + + check(http.get(`${BASE_URL}/?id=definitely-not-a-real-id`, NO_REDIRECT), { + 'unknown id is 404': (r) => r.status === 404, + }); + + // A preview crawler gets OG metadata rather than a redirect. + const bot = http.get(`${BASE_URL}/?id=${qrId}`, { + ...NO_REDIRECT, + headers: { 'User-Agent': 'Twitterbot/1.0' }, + }); + check(bot, { + 'crawler gets 200 HTML': (r) => r.status === 200, + 'crawler gets og:title': (r) => (r.body || '').includes('og:title'), + }); + + // LINE's in-app browser is a person, and must never be treated as a crawler. + const line = http.get(`${BASE_URL}/?id=${qrId}`, { + ...NO_REDIRECT, + headers: { + 'User-Agent': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Line/13.15.0', + }, + }); + check(line, { + 'LINE in-app browser is treated as a human (302)': (r) => r.status === 302, + }); + + // --- auth ----------------------------------------------------------------- + check(http.get(`${BASE_URL}/projects`), { + 'unauthenticated /projects is 401': (r) => r.status === 401, + '401 carries a machine-readable code': (r) => + r.json('code') === 'UNAUTHORIZED', + }); + + const headers = authHeader(); + + // --- admin API ------------------------------------------------------------ + const page1 = http.get(`${BASE_URL}/projects?page=1&limit=5`, { headers }); + const page2 = http.get(`${BASE_URL}/projects?page=2&limit=5`, { headers }); + check(page1, { '/projects is 200': (r) => r.status === 200 }); + + if (page1.status === 200 && page2.status === 200) { + const ids1 = page1.json('data').map((p) => p.projectId); + const ids2 = page2.json('data').map((p) => p.projectId); + const overlap = ids1.filter((id) => ids2.includes(id)); + check( + { overlap }, + { + // Catches a missing ORDER BY: without one, D1 can repeat or drop rows + // between pages, and a freshly created project may not be on page 1. + 'pagination does not repeat rows across pages': () => + overlap.length === 0, + }, + ); + + const createdAt = ids1.length + ? page1.json('data').map((p) => p.createdAt) + : []; + const sortedDesc = [...createdAt].sort().reverse(); + check( + { createdAt }, + { + 'projects are newest-first': () => + JSON.stringify(createdAt) === JSON.stringify(sortedDesc), + }, + ); + } else { + fail(`could not read both pages: ${page1.status}/${page2.status}`); + } + + // --- URL validation ------------------------------------------------------- + const badUrl = http.post( + `${BASE_URL}/projects`, + JSON.stringify({ + projectName: 'smoke-xss', + destinationUrl: 'javascript:alert(1)', + }), + { headers: { ...headers, 'Content-Type': 'application/json' } }, + ); + check(badUrl, { + // The admin UI renders destinationUrl as an , so accepting this is a + // stored-XSS vector into the session that holds the API token. + 'javascript: destination URL is rejected': (r) => r.status === 400, + }); + + // --- CSV export ----------------------------------------------------------- + const someProject = page1.json('data')[0]; + if (someProject) { + const csv = http.get( + `${BASE_URL}/projects/${someProject.projectId}/access-logs/csv`, + { headers }, + ); + check(csv, { + // 200 when the flag is on, 403 when off. Either is fine; a 500 is not. + 'CSV export answers 200 or 403': (r) => + r.status === 200 || r.status === 403, + }); + if (csv.status === 200) { + check(csv, { + 'CSV starts with a UTF-8 BOM (Excel)': (r) => + r.body.charCodeAt(0) === 0xfeff, + 'CSV has the bot column': (r) => (r.body || '').includes('ボット'), + 'CSV filename is not a raw UUID': (r) => + !/filename="access-logs-[0-9a-f-]{36}\.csv"/.test( + r.headers['Content-Disposition'] || '', + ), + }); + } + } +} diff --git a/packages/api/loadtest/seed/generate.mjs b/packages/api/loadtest/seed/generate.mjs new file mode 100644 index 0000000..b827576 --- /dev/null +++ b/packages/api/loadtest/seed/generate.mjs @@ -0,0 +1,255 @@ +// Generates SQL + a qrids manifest for the load tests. +// +// D1 constraints this works around (see docs/load-testing.md): +// - ~100 bound parameters per query -> emit literal values, no placeholders +// - per-statement SQL size -> 500 rows per INSERT +// - BEGIN/COMMIT unsupported via the API -> no explicit transactions +// - workerd falls over on a ~4MB file -> split into small files (measured) +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROWS_PER_STATEMENT = 500; +// Kept low on purpose: 500 rows x 92 statements in one file reproducibly failed +// with `kj/table.c++:57: HashIndex detected hash table inconsistency`. +const STATEMENTS_PER_FILE = 10; + +const PROJECT_COUNT = 25; // > PAGE_SIZE, so pagination bugs are observable +const QR_PER_PROJECT = 10; + +/** SQL string literal. */ +const q = (value) => `'${String(value).replace(/'/g, "''")}'`; + +/** + * Realistic user agents. The same pool the bot classifier is asserted against — + * note `Line/` (a real person in LINE's in-app browser) sitting right next to + * `line-poker` (LINE's preview crawler), which is exactly the pair that must not + * be conflated. + */ +const USER_AGENTS = [ + // people + [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 0, + ], + [ + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', + 0, + ], + [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Line/13.15.0', + 0, + ], + [ + 'Mozilla/5.0 (Linux; Android 13; SO-52C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36 Line/14.2.1', + 0, + ], + // crawlers + ['facebookexternalhit/1.1;line-poker/0.1', 1], + ['Twitterbot/1.0', 1], + ['Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)', 1], + ['Discordbot/2.0; +https://discordapp.com', 1], +]; + +/** Deterministic PRNG, so a run is reproducible without Math.random. */ +function makeRandom(seed) { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +/** + * Zipf-ish weights: one project takes ~60% of all scans and, within it, one QR + * code takes ~30%. Uniform data flatters index selectivity and makes p95 look + * better than it will be in the field. + */ +function weightedPick(random, items, weights) { + const total = weights.reduce((sum, w) => sum + w, 0); + let roll = random() * total; + for (const [index, weight] of weights.entries()) { + roll -= weight; + if (roll <= 0) return items[index]; + } + return items[items.length - 1]; +} + +function parseArgs(argv) { + const args = { logs: 100_000, out: 'loadtest/.out', seed: 42 }; + for (const arg of argv) { + const [key, value] = arg.replace(/^--/, '').split('='); + if (key === 'logs') args.logs = Number(value); + else if (key === 'out') args.out = value; + else if (key === 'seed') args.seed = Number(value); + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const random = makeRandom(args.seed); + +rmSync(args.out, { recursive: true, force: true }); +mkdirSync(args.out, { recursive: true }); + +const files = []; +const write = (name, sql) => { + writeFileSync(join(args.out, name), `${sql}\n`); + files.push(name); +}; + +// --- projects + QR codes ----------------------------------------------------- +const projects = Array.from({ length: PROJECT_COUNT }, (_, i) => ({ + id: `lt-project-${String(i + 1).padStart(3, '0')}`, + name: `負荷テスト企画${i + 1}`, +})); + +const qrCodes = []; +for (const [projectIndex, project] of projects.entries()) { + for (let i = 1; i <= QR_PER_PROJECT; i++) { + qrCodes.push({ + id: `lt-qr-${String(projectIndex + 1).padStart(3, '0')}-${String(i).padStart(3, '0')}`, + projectId: project.id, + // Unique per project, matching idx_qrcodes_project_name. + name: `ポスター${String(i).padStart(2, '0')}`, + medium: i % 3 === 0 ? 'チラシ' : 'ポスター', + // A third have no location, exercising the optional case. + location: i % 3 === 0 ? '' : `${i}F掲示板`, + }); + } +} + +write( + '000-cleanup.sql', + [ + "DELETE FROM AccessLogs WHERE project_id LIKE 'lt-project-%';", + "DELETE FROM QRCodes WHERE project_id LIKE 'lt-project-%';", + "DELETE FROM Projects WHERE project_id LIKE 'lt-project-%';", + ].join('\n'), +); + +write( + '010-projects.sql', + `INSERT INTO Projects (project_id, name, destination_url, created_at) VALUES\n${projects + .map( + (p, i) => + `(${q(p.id)}, ${q(p.name)}, ${q(`https://example.com/lt/${i + 1}`)}, ${q( + `2026-07-${String((i % 20) + 1).padStart(2, '0')}T09:00:00.000Z`, + )})`, + ) + .join(',\n')};`, +); + +write( + '020-qrcodes.sql', + `INSERT INTO QRCodes (id, project_id, name, medium, location, created_at) VALUES\n${qrCodes + .map( + (qr) => + `(${q(qr.id)}, ${q(qr.projectId)}, ${q(qr.name)}, ${q(qr.medium)}, ${q( + qr.location, + )}, ${q('2026-07-01T09:00:00.000Z')})`, + ) + .join(',\n')};`, +); + +// --- access logs ------------------------------------------------------------- +// Project weights: first project dominates. +const projectWeights = projects.map((_, i) => + i === 0 ? 60 : 40 / (PROJECT_COUNT - 1), +); +// Within a project, the first QR code dominates. +const qrWeights = Array.from({ length: QR_PER_PROJECT }, (_, i) => + i === 0 ? 30 : 70 / (QR_PER_PROJECT - 1), +); + +/** + * Three days, with a diurnal curve plus one sharp 10-minute spike on day 2 — + * standing in for a stage announcement. This is what the + * (project_id, accessed_at) index range-scans. + */ +function randomTimestamp() { + const day = 5 + Math.floor(random() * 3); // 2026-07-05..07 + if (day === 6 && random() < 0.15) { + const second = Math.floor(random() * 600); + const mm = String(Math.floor(second / 60)).padStart(2, '0'); + const ss = String(second % 60).padStart(2, '0'); + return `2026-07-06T12:${mm}:${ss}.000Z`; + } + // Weight the afternoon, when a festival is busy. + const hour = 9 + Math.floor(random() ** 0.6 * 11); + const minute = Math.floor(random() * 60); + const second = Math.floor(random() * 60); + const ms = Math.floor(random() * 1000); + return `2026-07-0${day}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}.${String(ms).padStart(3, '0')}Z`; +} + +const rows = []; +for (let i = 0; i < args.logs; i++) { + const project = weightedPick(random, projects, projectWeights); + const offset = weightedPick( + random, + Array.from({ length: QR_PER_PROJECT }, (_, k) => k), + qrWeights, + ); + const qr = qrCodes.find( + (candidate) => + candidate.projectId === project.id && + candidate.name === `ポスター${String(offset + 1).padStart(2, '0')}`, + ); + const [userAgent, isBot] = + USER_AGENTS[Math.floor(random() * USER_AGENTS.length)]; + // Campus /16 plus carrier ranges, with repeats — real scans repeat per device. + const ip = + random() < 0.6 + ? `10.42.${Math.floor(random() * 256)}.${Math.floor(random() * 254) + 1}` + : `203.0.${Math.floor(random() * 100)}.${Math.floor(random() * 254) + 1}`; + + rows.push( + `(${q(qr.id)}, ${q(project.id)}, ${q(randomTimestamp())}, ${q(userAgent)}, ${q(ip)}, ${isBot})`, + ); +} + +const statements = []; +for (let i = 0; i < rows.length; i += ROWS_PER_STATEMENT) { + statements.push( + `INSERT INTO AccessLogs (qr_id, project_id, accessed_at, user_agent, ip_address, is_bot) VALUES\n${rows + .slice(i, i + ROWS_PER_STATEMENT) + .join(',\n')};`, + ); +} +for (let i = 0; i < statements.length; i += STATEMENTS_PER_FILE) { + const index = String(Math.floor(i / STATEMENTS_PER_FILE)).padStart(4, '0'); + write( + `030-logs-${index}.sql`, + statements.slice(i, i + STATEMENTS_PER_FILE).join('\n'), + ); +} + +// --- manifest for k6 --------------------------------------------------------- +// Weight column so k6 can reproduce the same skew the data has, rather than +// hammering one id (which would make the isolate cache look far better than it is). +writeFileSync( + join(args.out, 'qrids.json'), + JSON.stringify( + { + projectIds: projects.map((p) => p.id), + qrIds: qrCodes.map((qr) => qr.id), + weightedQrIds: qrCodes.flatMap((qr) => { + const projectIndex = projects.findIndex((p) => p.id === qr.projectId); + const qrIndex = Number(qr.name.replace('ポスター', '')) - 1; + const copies = Math.max( + 1, + Math.round(projectWeights[projectIndex] * qrWeights[qrIndex] * 0.5), + ); + return Array.from({ length: copies }, () => qr.id); + }), + }, + null, + 2, + ), +); + +console.log( + `seeded plan: ${projects.length} projects, ${qrCodes.length} QR codes, ${args.logs} access logs`, +); +console.log(` ${files.length} SQL files + qrids.json in ${args.out}`); +console.log(' apply with: node loadtest/seed/run.mjs --target=local'); diff --git a/packages/api/loadtest/seed/run.mjs b/packages/api/loadtest/seed/run.mjs new file mode 100644 index 0000000..4ec0658 --- /dev/null +++ b/packages/api/loadtest/seed/run.mjs @@ -0,0 +1,99 @@ +// Applies the generated SQL to a D1 database, one file at a time. +// +// All orchestration lives here rather than in npm-script shell chains: `&&`, +// `$(...)` and single-quoted JSON all break on Windows, which is where this is +// actually run. +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +function parseArgs(argv) { + const args = { + target: 'local', + out: 'loadtest/.out', + logs: null, + db: 'trackinglink-db', + }; + for (const arg of argv) { + const [key, value] = arg.replace(/^--/, '').split('='); + if (key in args) args[key] = value ?? true; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); + +if (args.target !== 'local' && args.target !== 'remote') { + console.error(`unknown --target=${args.target} (expected local or remote)`); + process.exit(1); +} + +if (args.target === 'remote') { + console.error( + [ + 'Refusing to seed --target=remote.', + '', + 'Bulk-seeding production burns the D1 daily write quota (and deleted rows', + 'count as writes too, so cleaning up costs the same again). Seed locally and', + 'create a handful of rows through the API for the production run — see', + 'docs/load-testing.md section 4.4.', + ].join('\n'), + ); + process.exit(1); +} + +if (!existsSync(args.out)) { + console.error(`${args.out} not found — run generate.mjs first`); + process.exit(1); +} + +// Seeding while `wrangler dev` holds the local D1 reproducibly fails with a +// workerd HashIndex error, and the failure is confusing. Warn up front. +console.log( + 'NOTE: stop `wrangler dev` before seeding — workerd fails if it holds the DB.\n', +); + +const files = readdirSync(args.out) + .filter((name) => name.endsWith('.sql')) + .sort(); + +const wrangler = process.platform === 'win32' ? 'wrangler.CMD' : 'wrangler'; +const wranglerPath = join('node_modules', '.bin', wrangler); + +let applied = 0; +for (const file of files) { + process.stdout.write(` ${file} … `); + const result = spawnSync( + wranglerPath, + [ + 'd1', + 'execute', + args.db, + `--${args.target}`, + `--file=${join(args.out, file)}`, + ], + { encoding: 'utf8', shell: process.platform === 'win32' }, + ); + + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + // Deliberately narrow. A bare /error/i also matches wrangler's own + // "update to prevent critical errors" version notice, which made every run + // look like a failure. `[ERROR]` is wrangler's actual failure marker, and the + // HashIndex check catches the workerd crash that exits 0 with a broken DB. + const failed = + result.status !== 0 || + /\[ERROR\]/.test(output) || + /HashIndex detected/.test(output); + if (failed) { + console.log('FAILED'); + console.error(output.split('\n').slice(-15).join('\n')); + console.error( + `\nApplied ${applied}/${files.length} files. Fix the cause and re-run; 000-cleanup.sql makes a full re-run safe.`, + ); + process.exit(1); + } + console.log('ok'); + applied++; +} + +console.log(`\napplied ${applied} files to ${args.db} (${args.target})`); diff --git a/packages/api/loadtest/seed/seed-remote.mjs b/packages/api/loadtest/seed/seed-remote.mjs new file mode 100644 index 0000000..f1a1818 --- /dev/null +++ b/packages/api/loadtest/seed/seed-remote.mjs @@ -0,0 +1,249 @@ +// Creates a small, clearly-labelled test dataset in a *deployed* environment via +// the admin API, and writes the qrids manifest the k6 scenarios read. +// +// Why this exists: loadtest/.out/qrids.json is produced by generate.mjs from +// locally-seeded ids like `lt-qr-001-001`, which do not exist in production. A +// production run pointed at that manifest would measure nothing but 404s. +// +// Why the API rather than SQL: seeding production by executing SQL means opening +// a write path to the real database from a laptop. Going through the API uses the +// same code path real users do, needs only the admin password, and is trivially +// reversible — every object created here is deleted by `--cleanup`. +// +// Usage: +// node loadtest/seed/seed-remote.mjs \ +// --base=https://trackinglink..workers.dev \ +// --password= \ +// --projects=5 --qrs=10 +// +// node loadtest/seed/seed-remote.mjs --base=... --password=... --cleanup +// +// The password can also come from ADMIN_PASSWORD in the environment, which keeps +// it out of your shell history. +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * Every object created here carries this prefix so cleanup can find them and a + * human reading the admin UI can tell at a glance that they are not real. + */ +const MARKER = '[loadtest]'; + +function parseArgs(argv) { + const args = { + base: '', + password: process.env.ADMIN_PASSWORD ?? '', + projects: 5, + qrs: 10, + out: 'loadtest/.out', + cleanup: false, + }; + for (const arg of argv) { + const [rawKey, value] = arg.replace(/^--/, '').split('='); + if (rawKey === 'cleanup') args.cleanup = true; + else if (rawKey === 'projects' || rawKey === 'qrs') + args[rawKey] = Number(value); + else if (rawKey in args) args[rawKey] = value ?? ''; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); + +if (!args.base || !args.password) { + console.error( + [ + 'Missing --base or --password.', + '', + ' node loadtest/seed/seed-remote.mjs \\', + ' --base=https://trackinglink..workers.dev \\', + ' --password=', + '', + 'ADMIN_PASSWORD in the environment works too.', + ].join('\n'), + ); + process.exit(1); +} + +const base = args.base.replace(/\/$/, ''); + +/** Refuses to touch anything that is not clearly a loadtest object. */ +function assertIsTestObject(name) { + if (!name.startsWith(MARKER)) { + throw new Error( + `Refusing to act on "${name}" — it does not carry the ${MARKER} prefix. This guard is what stops a cleanup run from deleting real projects.`, + ); + } +} + +async function login() { + const res = await fetch(`${base}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: args.password }), + }); + if (!res.ok) { + throw new Error( + `Login failed (${res.status}). Check --base and --password.${res.status === 429 ? ' Rate limited — wait a minute.' : ''}`, + ); + } + return (await res.json()).token; +} + +async function api(token, path, init = {}) { + const res = await fetch(`${base}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`${init.method ?? 'GET'} ${path} -> ${res.status} ${body}`); + } + return res.status === 204 ? null : res.json(); +} + +/** Every loadtest project currently on the server. */ +async function listTestProjects(token) { + const found = []; + for (let page = 1; ; page++) { + const body = await api(token, `/projects?page=${page}&limit=50`); + const rows = body.data ?? []; + found.push(...rows.filter((p) => p.name.startsWith(MARKER))); + if (rows.length < 50) break; + } + return found; +} + +async function cleanup(token) { + const projects = await listTestProjects(token); + if (projects.length === 0) { + console.log(`No ${MARKER} projects found — nothing to clean up.`); + return; + } + console.log(`Deleting ${projects.length} ${MARKER} project(s)…`); + for (const project of projects) { + assertIsTestObject(project.name); + // QR codes and access logs go with it via ON DELETE CASCADE. + await api(token, `/projects/${project.projectId}`, { method: 'DELETE' }); + console.log(` deleted ${project.name}`); + } + console.log( + '\nNote: deleted rows still count against the D1 daily write quota — ' + + 'D1 bills rows written, not net change.', + ); +} + +async function seed(token) { + const existing = await listTestProjects(token); + if (existing.length > 0) { + console.error( + `${existing.length} ${MARKER} project(s) already exist. Run with --cleanup first, or they will skew the results.`, + ); + process.exit(1); + } + + // Mirrors the local seed: one destination keyword per project, alternating, so + // the fallback path is exercised by whichever ids the scenarios pick. + const keywords = ['instagram', 'web']; + const projects = []; + const qrIds = []; + + for (let p = 1; p <= args.projects; p++) { + const project = await api(token, '/projects', { + method: 'POST', + body: JSON.stringify({ + projectName: `${MARKER} 負荷テスト企画${p}`, + // example.com is reserved by RFC 2606 — a redirect target that cannot + // accidentally send load at anyone's real site. + destinationUrl: `https://example.com/loadtest/${p}`, + fallbackKey: keywords[(p - 1) % keywords.length], + }), + }); + projects.push(project.projectId); + process.stdout.write(` ${project.projectName ?? project.name ?? ''}`); + + for (let q = 1; q <= args.qrs; q++) { + const qr = await api(token, `/projects/${project.projectId}/qrcodes`, { + method: 'POST', + body: JSON.stringify({ + name: `ポスター${String(q).padStart(2, '0')}`, + medium: q % 3 === 0 ? 'チラシ' : 'ポスター', + location: q % 3 === 0 ? '' : `${q}F掲示板`, + }), + }); + qrIds.push({ id: qr.id, projectIndex: p - 1 }); + } + console.log(` — ${args.qrs} QR codes`); + } + + // Same Zipf-ish skew as the local manifest: hammering one id would give the + // Worker's in-isolate cache a hit rate it will never see in the field. + const weighted = []; + for (const { id, projectIndex } of qrIds) { + const copies = projectIndex === 0 ? 12 : 2; + for (let i = 0; i < copies; i++) weighted.push(id); + } + + const manifestPath = join(args.out, 'qrids.json'); + mkdirSync(dirname(manifestPath), { recursive: true }); + + // Keep the local manifest recoverable — overwriting it silently would break + // the local scenarios with ids that only exist in production. + let previous = null; + try { + previous = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch { + /* nothing to preserve */ + } + if (previous && !previous.remote) { + writeFileSync( + join(args.out, 'qrids.local.json'), + JSON.stringify(previous, null, 2), + ); + console.log('\n saved the previous local manifest to qrids.local.json'); + } + + writeFileSync( + manifestPath, + JSON.stringify( + { + remote: true, + base, + generatedFor: MARKER, + projectIds: projects, + qrIds: qrIds.map((q) => q.id), + weightedQrIds: weighted, + }, + null, + 2, + ), + ); + + const created = args.projects * args.qrs + args.projects; + console.log( + [ + '', + `Created ${args.projects} projects and ${args.projects * args.qrs} QR codes`, + `(~${created} D1 writes of the 100,000/day budget).`, + '', + `Manifest written to ${manifestPath}.`, + '', + 'Next:', + ` BASE_URL=${base} k6 run loadtest/k6/scan-ramping-prod.js`, + '', + 'Afterwards:', + ' node loadtest/seed/seed-remote.mjs --base=... --password=... --cleanup', + ].join('\n'), + ); +} + +const token = await login(); +if (args.cleanup) { + await cleanup(token); +} else { + await seed(token); +} diff --git a/packages/api/package.json b/packages/api/package.json index 1c7858a..304aee1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,7 +16,16 @@ "db:apply:local": "wrangler d1 execute trackinglink-db --local --file=./schema.sql", "db:apply:remote": "wrangler d1 execute trackinglink-db --remote --file=./schema.sql", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "loadtest:seed": "node loadtest/seed/generate.mjs --logs=100000", + "loadtest:apply": "node loadtest/seed/run.mjs --target=local", + "loadtest:smoke": "k6 run loadtest/k6/smoke.js", + "loadtest:scan": "k6 run loadtest/k6/scan-sustained.js", + "loadtest:spike": "k6 run loadtest/k6/scan-spike.js", + "loadtest:admin": "k6 run loadtest/k6/admin-projects.js", + "loadtest:csv": "k6 run loadtest/k6/csv-export.js", + "loadtest:login": "k6 run loadtest/k6/login-bruteforce.js", + "loadtest:prod:ramp": "k6 run loadtest/k6/scan-ramping-prod.js" }, "dependencies": { "drizzle-orm": "^0.36.4", diff --git a/packages/web/package.json b/packages/web/package.json index 74a0e38..dafcc45 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,7 +10,9 @@ "build": "tsc -b && vite build", "preview": "vite preview", "deploy": "tsc -b && vite build && wrangler deploy", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "clsx": "^2.1.1", @@ -28,9 +30,11 @@ "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "25.0.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vite": "^6.0.3", + "vitest": "2.1.8", "wrangler": "^3.90.0" } } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index cbda81e..088e043 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -2,53 +2,64 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppLayout } from './components/AppLayout'; import { AuthProvider } from './components/AuthProvider'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { ToastProvider } from './components/ToastProvider'; import { LocaleProvider } from './lib/i18n'; import CreateProjectPage from './pages/CreateProjectPage'; import { LoginPage } from './pages/LoginPage'; import ManageProjectsPage from './pages/ManageProjectsPage'; import QRCodesPage from './pages/QRCodesPage'; +/** + * Provider order is load-bearing: + * - ToastProvider below LocaleProvider, so toasts can translate their labels. + * - ToastProvider above AuthProvider, whose 401 handling raises a toast. + * - ToastProvider above Routes, so a success toast fired immediately before + * `navigate('/links')` survives the navigation. That is exactly what "project + * created" needs, and the reason a page-local banner could never deliver it. + */ export default function App() { return ( - - - - } /> - } /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - + + + + + } /> + } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + ); } diff --git a/packages/web/src/components/AppLayout.tsx b/packages/web/src/components/AppLayout.tsx index 509b702..9991723 100644 --- a/packages/web/src/components/AppLayout.tsx +++ b/packages/web/src/components/AppLayout.tsx @@ -3,18 +3,66 @@ import type { ReactNode } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Permissions, hasPermission } from '../hooks/useStaffAuth'; import { useTranslation } from '../lib/i18n'; +import { btnIcon } from '../lib/styles'; +import { cn } from '../lib/utils'; import { useAuthContext } from './AuthProvider'; import { LanguageSwitcher } from './LanguageSwitcher'; +/** + * Sidebar on desktop, top app bar on mobile. + * + * The sidebar used to render unconditionally at `w-60`, which on a 375px phone is + * 240px — 64% of the screen — leaving ~135px for content. Staff use this on their + * phones while putting up posters, so every carefully built "mobile card" layout + * was being squeezed into a width the app never actually gave it. + * + * A top bar rather than a hamburger drawer, because there are only two nav + * destinations and one of them (`/links/create`) is permission-gated *and* already + * the primary call to action in the projects page header. So the real payload is + * brand/home, language, logout — three things that fit a 56px bar at full touch + * size. A drawer would add an overlay, focus trap, scroll lock and open/close + * state to reveal one non-redundant link. A bottom tab bar would permanently spend + * vertical space on screens that are lists and forms with a keyboard open, and + * would collide with the toast anchor. + */ export function AppLayout({ children }: { children: ReactNode }) { const { user, logout } = useAuthContext(); const { t } = useTranslation(); const navigate = useNavigate(); const permissions = user?.permissions ?? 0; + const canCreate = hasPermission(permissions, Permissions.TRACKING_LINK_EDIT); + + const onLogout = () => { + logout(); + navigate('/login'); + }; return ( -
-
+ } + > +

+ {body} +

+ + ); +} diff --git a/packages/web/src/components/FallbackKeySelect.tsx b/packages/web/src/components/FallbackKeySelect.tsx new file mode 100644 index 0000000..fa96e14 --- /dev/null +++ b/packages/web/src/components/FallbackKeySelect.tsx @@ -0,0 +1,136 @@ +import type { FallbackDestination } from '../hooks/useFallbackDestinations'; +import { useTranslation } from '../lib/i18n'; +import { fieldErrorText, inputBase, labelBase } from '../lib/styles'; + +/** + * Picks the fallback keyword for a project from the Worker's configured list. + * + * A picker rather than free text because the value only means anything if it + * matches a key in FALLBACK_DESTINATIONS — a typed keyword that does not is + * silently inert until the day D1 goes down, which is the worst possible time to + * discover it. + */ +interface FallbackKeySelectProps { + id: string; + value: string; + onChange: (value: string) => void; + destinations: FallbackDestination[]; + staticFallbackUrl: string | null; + isLoading: boolean; + /** True when the list could not be fetched; falls back to a text input. */ + failed: boolean; + error?: string; + disabled?: boolean; +} + +export function FallbackKeySelect({ + id, + value, + onChange, + destinations, + staticFallbackUrl, + isLoading, + failed, + error, + disabled, +}: FallbackKeySelectProps) { + const { t } = useTranslation(); + const describedBy = error ? `${id}-error` : `${id}-hint`; + + // A stored keyword that is no longer configured must stay visible and selected. + // Dropping it would silently rewrite a value that is already printed on posters + // the moment someone edits an unrelated field. + const isOrphaned = value !== '' && !destinations.some((d) => d.key === value); + const selected = destinations.find((d) => d.key === value); + + const label = ( + + ); + + const message = error ? ( +

+ {error} +

+ ) : ( +

+ {isOrphaned + ? t('projects.fallbackKeyOrphaned', { key: value }) + : selected + ? t('projects.fallbackKeySelected', { url: selected.url }) + : staticFallbackUrl + ? t('projects.fallbackKeyNoneSelected', { url: staticFallbackUrl }) + : t('projects.fallbackKeyHint')} +

+ ); + + // The list is a convenience, not a dependency: if it could not be fetched, a + // plain input still lets the form be filled in and saved. + if (failed) { + return ( +
+ {label} + onChange(e.target.value)} + onBlur={() => onChange(value.trim())} + placeholder={t('projects.fallbackKeyPlaceholder')} + maxLength={40} + disabled={disabled} + aria-invalid={error ? true : undefined} + aria-describedby={describedBy} + className={inputBase} + /> + {error ? ( +

+ {error} +

+ ) : ( +

+ {t('projects.fallbackKeyListUnavailable')} +

+ )} +
+ ); + } + + return ( +
+ {label} + + {destinations.length === 0 && !isLoading ? ( +

+ {t('projects.fallbackKeyEmpty')} +

+ ) : ( + message + )} +
+ ); +} diff --git a/packages/web/src/components/FullScreenMessage.tsx b/packages/web/src/components/FullScreenMessage.tsx new file mode 100644 index 0000000..d2585d6 --- /dev/null +++ b/packages/web/src/components/FullScreenMessage.tsx @@ -0,0 +1,48 @@ +import { Loader2 } from 'lucide-react'; +import { useTranslation } from '../lib/i18n'; +import { btnPrimary } from '../lib/styles'; + +/** + * Full-viewport loading / error state, used before any page chrome exists. + * + * `min-h-dvh` rather than `h-screen`: on iOS Safari the dynamic toolbar makes + * `100vh` taller than the visible area, so the bottom of a centred block gets + * clipped and the page cannot be scrolled to reach it. + */ +export function FullScreenLoading() { + const { t } = useTranslation(); + return ( +
this replaces — a screen reader user + // previously got silence during every page load. + role="status" + aria-live="polite" + className="flex min-h-dvh items-center justify-center gap-2 p-4 text-muted-foreground" + > +
+ ); +} + +export function FullScreenError({ + message, + onRetry, +}: { + message: string; + onRetry?: () => void; +}) { + const { t } = useTranslation(); + return ( +
+

+ {message} +

+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/packages/web/src/components/LanguageSwitcher.tsx b/packages/web/src/components/LanguageSwitcher.tsx index 6d65556..aac2ec0 100644 --- a/packages/web/src/components/LanguageSwitcher.tsx +++ b/packages/web/src/components/LanguageSwitcher.tsx @@ -1,30 +1,51 @@ import { useTranslation } from '../lib/i18n'; +import { cn } from '../lib/utils'; +/** + * Language toggle. + * + * Both buttons were 16px tall — a real control, on a phone, at a third of the + * minimum comfortable tap size. The active choice was also signalled by font + * weight alone, which is a contrast-only cue, and neither button told assistive + * tech what it would do or which one was selected. + */ export function LanguageSwitcher({ className = '' }: { className?: string }) { - const { locale, setLocale } = useTranslation(); + const { locale, setLocale, t } = useTranslation(); + + const option = (active: boolean) => + cn( + 'flex min-h-11 items-center rounded px-2 text-xs transition-colors', + active + ? 'font-semibold text-foreground' + : 'text-muted-foreground hover:text-foreground', + ); return ( -
+
- / + diff --git a/packages/web/src/components/Modal.tsx b/packages/web/src/components/Modal.tsx new file mode 100644 index 0000000..f757087 --- /dev/null +++ b/packages/web/src/components/Modal.tsx @@ -0,0 +1,154 @@ +import { X } from 'lucide-react'; +import { type ReactNode, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from '../lib/i18n'; +import { btnIcon } from '../lib/styles'; +import { cn } from '../lib/utils'; + +/** + * A dialog that is actually usable from a keyboard. + * + * The QR dialog it replaces carried `role="dialog" aria-modal="true"` but had no + * focus trap, no autofocus, no Escape handler, no focus restore and no scroll + * lock — Tab walked straight into the page behind the overlay. It also had + * `onKeyDown={(e) => e.stopPropagation()}` on the panel, which *actively* + * prevented key events from bubbling, so any Escape handler added higher up + * could never have fired. + */ +interface ModalProps { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + /** Rendered under the body, typically actions. */ + footer?: ReactNode; + /** Suppresses backdrop-click and Escape. Use while a submit is in flight. */ + dismissible?: boolean; + className?: string; +} + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function Modal({ + open, + onClose, + title, + children, + footer, + dismissible = true, + className, +}: ModalProps) { + const { t } = useTranslation(); + const panelRef = useRef(null); + const restoreFocusTo = useRef(null); + + const requestClose = useCallback(() => { + if (dismissible) onClose(); + }, [dismissible, onClose]); + + // Remember what had focus so it can be handed back on close — otherwise focus + // lands on and keyboard users lose their place in the list. + useEffect(() => { + if (!open) return; + restoreFocusTo.current = document.activeElement as HTMLElement | null; + return () => restoreFocusTo.current?.focus?.(); + }, [open]); + + // Lock the page behind the dialog, so scrolling the overlay does not scroll the + // list underneath it. + useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + // Move focus into the dialog on open: the first field for a form, the panel + // itself otherwise. + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + const first = panel.querySelector(FOCUSABLE); + (first ?? panel).focus(); + }, [open]); + + // Escape to close, and Tab cycling confined to the panel. + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + return; + } + if (event.key !== 'Tab') return; + const panel = panelRef.current; + if (!panel) return; + const items = Array.from(panel.querySelectorAll(FOCUSABLE)); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + // Capture phase, so a child that stops propagation cannot disable Escape. + document.addEventListener('keydown', onKeyDown, true); + return () => document.removeEventListener('keydown', onKeyDown, true); + }, [open, requestClose]); + + if (!open) return null; + + return createPortal( +
+ {/* Presentational backdrop. Keyboard users close with Escape, which is why + this needs no role or key handler of its own. */} + , + document.body, + ); +} diff --git a/packages/web/src/components/Pagination.tsx b/packages/web/src/components/Pagination.tsx new file mode 100644 index 0000000..3b3ac94 --- /dev/null +++ b/packages/web/src/components/Pagination.tsx @@ -0,0 +1,133 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useTranslation } from '../lib/i18n'; +import { btnIcon } from '../lib/styles'; +import { cn } from '../lib/utils'; + +/** + * Shared pagination. Previously duplicated byte-for-byte in both list pages. + * + * Fixes carried over from that copy: + * - Renders even at a single page, so the row-count summary does not blink in and + * out as `total` crosses the page size, and there is always a control to reach + * page 1 from a page that just emptied. + * - `aria-label` on the icon-only chevrons, which a screen reader previously + * announced as just "button, button" — while being the primary navigation. + * - `aria-current="page"` on the active number; it used to be conveyed by colour + * alone. + * - Disabled while loading, so fast clicks cannot queue overlapping fetches. + * - Touch targets sized from lib/styles (they were 28px on the device this app is + * actually used on). + * - A ±2 window instead of ±1, so long lists need fewer taps. + */ +interface PaginationProps { + page: number; + totalPages: number; + total: number; + pageSize: number; + shownCount: number; + isLoading?: boolean; + onPageChange: (page: number) => void; +} + +const WINDOW = 2; + +export function Pagination({ + page, + totalPages, + total, + pageSize, + shownCount, + isLoading = false, + onPageChange, +}: PaginationProps) { + const { t } = useTranslation(); + + // Derived from what is actually rendered, not from page × pageSize — the old + // version asserted "1–10 of 11" even when the server returned fewer rows. + const start = total === 0 ? 0 : (page - 1) * pageSize + 1; + const end = total === 0 ? 0 : start + shownCount - 1; + + const numbers: (number | 'gap')[] = []; + for (let candidate = 1; candidate <= totalPages; candidate++) { + const isEdge = candidate === 1 || candidate === totalPages; + const isNear = Math.abs(candidate - page) <= WINDOW; + if (isEdge || isNear) { + numbers.push(candidate); + } else if (numbers[numbers.length - 1] !== 'gap') { + numbers.push('gap'); + } + } + + const go = (target: number) => { + const clamped = Math.min(Math.max(1, target), totalPages); + if (clamped === page) return; + onPageChange(clamped); + // On a phone, tapping "next" at the bottom of a list otherwise leaves you at + // the bottom of the new one, which reads as nothing having happened. + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( + + ); +} diff --git a/packages/web/src/components/ProtectedRoute.tsx b/packages/web/src/components/ProtectedRoute.tsx index 65d32e1..e77f9dc 100644 --- a/packages/web/src/components/ProtectedRoute.tsx +++ b/packages/web/src/components/ProtectedRoute.tsx @@ -1,19 +1,24 @@ import type { ReactNode } from 'react'; import { Navigate } from 'react-router-dom'; +import { useTranslation } from '../lib/i18n'; import { useAuthContext } from './AuthProvider'; +import { FullScreenError, FullScreenLoading } from './FullScreenMessage'; export function ProtectedRoute({ children }: { children: ReactNode }) { - const { user, isLoading } = useAuthContext(); + const { user, isLoading, authErrorKey, checkAuth } = useAuthContext(); + const { t } = useTranslation(); - if (isLoading) { - return ( -
- Loading… -
- ); - } - if (!user) { - return ; + // Was a hardcoded English "Loading…" despite common.loading existing — and it + // is the first thing a Japanese user sees on every single page load. + if (isLoading) return ; + + // The session could not be *checked* (offline, cold Worker), which is not the + // same as being signed out. Bouncing to /login here would silently log out a + // user whose token is perfectly valid. + if (authErrorKey) { + return ; } + + if (!user) return ; return <>{children}; } diff --git a/packages/web/src/components/ToastProvider.tsx b/packages/web/src/components/ToastProvider.tsx new file mode 100644 index 0000000..d2da3bf --- /dev/null +++ b/packages/web/src/components/ToastProvider.tsx @@ -0,0 +1,128 @@ +import { X } from 'lucide-react'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from '../lib/i18n'; +import { cn } from '../lib/utils'; + +/** + * App-wide notifications. + * + * This is not merely a DRY pass over the four inline error banners. It is the + * only structure that survives the component that raised the message + * unmounting — which was the root cause of the worst bug in the app: cancelling + * a QR code edit mid-save closed the form, and the failure was then written to + * `formError` on a form that no longer rendered. The save failed and the user was + * never told. + * + * It also supplies the success feedback the app had none of: create, edit and + * delete all completed in total silence, which is why "did that work?" was a + * reasonable question to ask of every action. + * + * Placement matters. Bottom-centre on mobile puts messages within thumb reach + * *and* above the fold; the old banners rendered at the very top of the page, so + * a failure while deleting row 9 on a phone was scrolled out of sight and looked + * exactly like nothing happening. + */ + +type ToastKind = 'success' | 'error' | 'info'; + +interface Toast { + id: number; + kind: ToastKind; + message: string; +} + +interface ToastApi { + success: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; +} + +const ToastContext = createContext(null); + +const KIND_STYLES: Record = { + success: 'border-primary/40 bg-card text-foreground', + error: 'border-destructive/50 bg-destructive/10 text-destructive', + info: 'border-border bg-card text-foreground', +}; + +// Errors linger long enough to read a sentence; confirmations get out of the way. +const DISMISS_MS: Record = { + success: 4000, + error: 8000, + info: 5000, +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + + const dismiss = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const push = useCallback( + (kind: ToastKind, message: string) => { + const id = ++nextId.current; + // Capped at three: a burst of failures (a dead API, say) must not paper + // over the whole screen. + setToasts((current) => [...current.slice(-2), { id, kind, message }]); + window.setTimeout(() => dismiss(id), DISMISS_MS[kind]); + }, + [dismiss], + ); + + const api = useMemo( + () => ({ + success: (message) => push('success', message), + error: (message) => push('error', message), + info: (message) => push('info', message), + }), + [push], + ); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
+
+ ); +} + +export function useToast(): ToastApi { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within a ToastProvider'); + return ctx; +} diff --git a/packages/web/src/hooks/useApiError.ts b/packages/web/src/hooks/useApiError.ts new file mode 100644 index 0000000..8cd9f92 --- /dev/null +++ b/packages/web/src/hooks/useApiError.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; +import { describeError } from '../lib/api'; +import { useTranslation } from '../lib/i18n'; + +/** + * Turns any thrown value into a message the user can read in their language. + * + * Kept as a hook (rather than living in lib/api.ts) so that api.ts stays free of + * React imports. Every catch block in the app funnels through this, which is what + * guarantees no raw server string or `HTTP 404` reaches a user. + */ +export function useApiErrorMessage(): (error: unknown) => string { + const { t } = useTranslation(); + return useCallback( + (error: unknown) => { + const { key, vars } = describeError(error); + return t(key, vars); + }, + [t], + ); +} diff --git a/packages/web/src/hooks/useFallbackDestinations.ts b/packages/web/src/hooks/useFallbackDestinations.ts new file mode 100644 index 0000000..f592464 --- /dev/null +++ b/packages/web/src/hooks/useFallbackDestinations.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { TRACKING_LINK_API_URL } from '../config'; +import { assertOk, authFetch } from '../lib/api'; + +/** + * The fallback keywords an operator has configured on the Worker. + * + * Fetched rather than hardcoded because the list lives in the Worker's + * FALLBACK_DESTINATIONS var, which the browser cannot read. Driving the form + * from it means adding or removing a destination in wrangler.jsonc changes the + * options with no code change, and a keyword that is not configured cannot be + * chosen in the first place. + */ +export interface FallbackDestination { + key: string; + url: string; +} + +interface Result { + destinations: FallbackDestination[]; + /** Where scans go when a project has no keyword. Null if unconfigured. */ + staticFallbackUrl: string | null; + isLoading: boolean; + /** True when the list could not be fetched — the form degrades to free text. */ + failed: boolean; +} + +export function useFallbackDestinations(): Result { + const [destinations, setDestinations] = useState([]); + const [staticFallbackUrl, setStaticFallbackUrl] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(true); + const [failed, setFailed] = useState(false); + const mounted = useRef(true); + + const load = useCallback(async () => { + try { + const res = await authFetch( + `${TRACKING_LINK_API_URL}/projects/fallback-destinations`, + ); + await assertOk(res); + const body = (await res.json()) as { + data?: FallbackDestination[]; + staticFallbackUrl?: string | null; + }; + if (!mounted.current) return; + setDestinations(Array.isArray(body.data) ? body.data : []); + setStaticFallbackUrl(body.staticFallbackUrl ?? null); + setFailed(false); + } catch { + if (!mounted.current) return; + // Deliberately quiet: this list is a convenience for one field, and a + // toast here would fire on every form open during an API blip. The form + // falls back to a plain text input, which still validates and still + // saves. + setFailed(true); + } finally { + if (mounted.current) setIsLoading(false); + } + }, []); + + useEffect(() => { + mounted.current = true; + void load(); + return () => { + mounted.current = false; + }; + }, [load]); + + return { destinations, staticFallbackUrl, isLoading, failed }; +} diff --git a/packages/web/src/hooks/useFieldErrors.ts b/packages/web/src/hooks/useFieldErrors.ts new file mode 100644 index 0000000..16fe238 --- /dev/null +++ b/packages/web/src/hooks/useFieldErrors.ts @@ -0,0 +1,95 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from '../lib/i18n'; + +/** + * Per-field validation messages. + * + * Replaces the pattern all three forms shared: inputs marked `required` (which a + * single space satisfies), then a `handleSubmit` that trimmed the values and bailed + * out with a bare `return`. Typing one space and pressing Save did absolutely + * nothing — no message, no spinner, no closed form. Indistinguishable from a + * broken app. + * + * The submit button stays *enabled* on purpose. Disabling it is the same problem + * wearing a different hat: a greyed-out button with no explanation of what is + * missing, which is exactly what CreateProjectPage already did. + */ +export interface FieldRule { + /** DOM id of the input, used to focus the first invalid field. */ + id: string; + value: string; + required?: boolean; + maxLength?: number; + /** Extra check; return an i18n key to fail. */ + validate?: (value: string) => string | null; +} + +export function useFieldErrors() { + const { t } = useTranslation(); + const [errors, setErrors] = useState>({}); + + const clear = useCallback(() => setErrors({}), []); + + /** Marks the fields named by an API 409/400 response. */ + const setFromFields = useCallback((fields: string[], message: string) => { + if (fields.length === 0) return; + setErrors(Object.fromEntries(fields.map((field) => [field, message]))); + }, []); + + /** + * Validates, stores messages, focuses the first offender, and returns whether + * the form may be submitted. + */ + const validate = useCallback( + (rules: Record): boolean => { + const next: Record = {}; + for (const [name, rule] of Object.entries(rules)) { + const value = rule.value.trim(); + if (rule.required && !value) { + next[name] = t('validation.required'); + continue; + } + if (rule.maxLength && value.length > rule.maxLength) { + next[name] = t('validation.tooLong', { max: rule.maxLength }); + continue; + } + const custom = value ? rule.validate?.(value) : null; + if (custom) next[name] = t(custom); + } + setErrors(next); + + const firstInvalid = Object.keys(next)[0]; + if (firstInvalid) { + document.getElementById(rules[firstInvalid].id)?.focus(); + return false; + } + return true; + }, + [t], + ); + + return { errors, validate, clear, setFromFields }; +} + +/** + * Mirrors the API's fallback-key rule (`^[a-z0-9][a-z0-9-]*$`). + * + * ASCII-only is not arbitrary: the keyword is percent-encoded into the printed QR + * payload, and a Japanese character costs 9 bytes there — enough to grow the + * symbol from 57x57 to 61x61 modules. + */ +export function validateFallbackKey(value: string): string | null { + return /^[a-z0-9][a-z0-9-]*$/.test(value) ? null : 'validation.fallbackKey'; +} + +/** Shared http(s) check, mirroring the API's protocol allow-list. */ +export function validateHttpUrl(value: string): string | null { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:' + ? null + : 'validation.url'; + } catch { + return 'validation.url'; + } +} diff --git a/packages/web/src/hooks/useListQuery.ts b/packages/web/src/hooks/useListQuery.ts new file mode 100644 index 0000000..f394a70 --- /dev/null +++ b/packages/web/src/hooks/useListQuery.ts @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { assertOk, authFetch } from '../lib/api'; +import { useApiErrorMessage } from './useApiError'; + +/** + * Paginated list fetching for this API's `{ data, total }` envelope. + * + * Not a generic data layer — deliberately narrow. It exists because the identical + * pagination logic was duplicated byte-for-byte across the two list pages and was + * broken in four separate ways: + * + * 1. **Stranded on an empty last page.** With 11 rows you are on page 2, you + * delete the only row there, `totalPages` becomes 1, and the pagination + * component returns null — leaving "No projects yet." and *no control to get + * back to page 1*. Only a manual reload recovered. (Worse on the QR page, + * where the pagination was rendered inside the non-empty branch, so it + * vanished even with several pages left.) + * 2. **Total drifting from reality.** The QR page ran `setTotal(total - 1)` after + * `await fetchData()`, clobbering the count the server had just returned with + * one computed from the pre-fetch value. + * 3. **Races.** No AbortController and no staleness guard, so clicking 1 → 3 → 5 + * on a slow connection let whichever response landed last win: the highlighted + * page and the rendered rows could disagree. + * 4. **No unmount safety.** Navigating away mid-fetch called setState on an + * unmounted component. + */ +interface ListResponse { + data: T[]; + total: number; +} + +export interface UseListQueryResult { + items: T[]; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + /** Already-translated message, or null. */ + error: string | null; + setPage: (page: number) => void; + /** Re-runs the current page. Use after a mutation. */ + refresh: () => Promise; +} + +export function useListQuery( + /** Given a 1-based page, returns the absolute URL to fetch. */ + buildUrl: (page: number) => string, + pageSize: number, +): UseListQueryResult { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const describeError = useApiErrorMessage(); + + // Monotonic request id: only the newest response is allowed to write state. + const requestId = useRef(0); + const abortRef = useRef(null); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + abortRef.current?.abort(); + }; + }, []); + + const load = useCallback( + async (targetPage: number) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const id = ++requestId.current; + + setIsLoading(true); + setError(null); + try { + const response = await authFetch(buildUrl(targetPage), { + signal: controller.signal, + }); + await assertOk(response); + const body = (await response.json()) as ListResponse; + if (id !== requestId.current || !mounted.current) return; + + const nextTotal = typeof body.total === 'number' ? body.total : 0; + const rows = Array.isArray(body.data) ? body.data : []; + + // Clamp: if the page we asked for no longer exists (the last row on it + // was deleted), fall back to the last page that does and refetch, + // instead of showing an empty list with no way back. + const lastPage = Math.max(1, Math.ceil(nextTotal / pageSize)); + if (targetPage > lastPage && nextTotal > 0) { + setPage(lastPage); + return; + } + + setItems(rows); + setTotal(nextTotal); + } catch (caught) { + // An abort is our own doing, not a failure to report. + if (controller.signal.aborted) return; + if (id !== requestId.current || !mounted.current) return; + setError(describeError(caught)); + setItems([]); + } finally { + if (id === requestId.current && mounted.current) setIsLoading(false); + } + }, + [buildUrl, describeError, pageSize], + ); + + useEffect(() => { + void load(page); + }, [load, page]); + + const refresh = useCallback(() => load(page), [load, page]); + + return { + items, + total, + page, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + isLoading, + error, + setPage, + refresh, + }; +} diff --git a/packages/web/src/hooks/useStaffAuth.ts b/packages/web/src/hooks/useStaffAuth.ts index 1ade9a7..047f93c 100644 --- a/packages/web/src/hooks/useStaffAuth.ts +++ b/packages/web/src/hooks/useStaffAuth.ts @@ -1,5 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; -import { apiFetch, clearToken, getToken, setToken } from '../lib/api'; +import { + NetworkError, + apiFetch, + clearToken, + getToken, + setToken, +} from '../lib/api'; export const Permissions = { TRACKING_LINK_VIEW: 1 << 0, @@ -34,23 +40,39 @@ export interface StaffUser { export function useStaffAuth() { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + /** i18n key set only when the session could not be *checked*, vs. rejected. */ + const [authErrorKey, setAuthErrorKey] = useState(null); const checkAuth = useCallback(async () => { if (!getToken()) { setUser(null); + setAuthErrorKey(null); setIsLoading(false); return; } try { const me = await apiFetch('/auth/me'); setUser(me); - } catch { + setAuthErrorKey(null); + } catch (error) { setUser(null); + // The empty `catch { setUser(null) }` this replaces could not tell a 401 + // from `TypeError: Failed to fetch`, so a Wi-Fi blip or a cold Worker + // silently bounced a perfectly valid session to /login with no + // explanation. A network failure now surfaces a retry screen instead. + setAuthErrorKey(error instanceof NetworkError ? 'error.network' : null); } finally { setIsLoading(false); } }, []); + /** Drops the local session without a network call (used by the 401 handler). */ + const reset = useCallback(() => { + clearToken(); + setUser(null); + setAuthErrorKey(null); + }, []); + useEffect(() => { checkAuth(); }, [checkAuth]); @@ -70,7 +92,8 @@ export function useStaffAuth() { const logout = useCallback(async () => { clearToken(); setUser(null); + setAuthErrorKey(null); }, []); - return { user, isLoading, login, logout, checkAuth }; + return { user, isLoading, authErrorKey, login, logout, checkAuth, reset }; } diff --git a/packages/web/src/lib/api.test.ts b/packages/web/src/lib/api.test.ts new file mode 100644 index 0000000..1eb894c --- /dev/null +++ b/packages/web/src/lib/api.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError, NetworkError, describeError } from './api'; + +/** + * The status/code → i18n key table is exactly the kind of thing that regresses + * without anyone noticing: rename a code on the API and the mapping silently + * falls through to a generic message, which still *looks* fine. + */ +describe('describeError', () => { + it('maps a coded API error to its own key', () => { + expect( + describeError(new ApiError(409, 'x', { code: 'DUPLICATE_NAME' })).key, + ).toBe('error.duplicateName'); + }); + + it('keeps a wrong password distinct from an expired session', () => { + // These must never collapse: telling someone their session expired when + // they simply mistyped the password sends them chasing the wrong problem. + expect( + describeError(new ApiError(401, 'x', { code: 'INVALID_PASSWORD' })).key, + ).toBe('login.invalidPassword'); + expect( + describeError(new ApiError(401, 'x', { code: 'UNAUTHORIZED' })).key, + ).toBe('error.unauthorized'); + }); + + it('falls back to the status when no code is present', () => { + expect(describeError(new ApiError(403, 'x')).key).toBe('error.permission'); + expect(describeError(new ApiError(429, 'x')).key).toBe('error.rateLimited'); + }); + + it('treats an unrecognised code as an unmapped response rather than crashing', () => { + // Falls through to the status, so a newly added API code degrades to a + // sensible message instead of showing the raw string. + expect( + describeError(new ApiError(403, 'x', { code: 'SOMETHING_NEW' })).key, + ).toBe('error.permission'); + }); + + it('uses a server-error message for 5xx and a generic one for unmapped 4xx', () => { + expect(describeError(new ApiError(500, 'x')).key).toBe('error.serverError'); + // 404 is deliberately unmapped: "not found" could be a project or a QR code. + expect(describeError(new ApiError(404, 'x')).key).toBe( + 'common.genericError', + ); + }); + + it('distinguishes a network failure from any API response', () => { + expect(describeError(new NetworkError()).key).toBe('error.network'); + }); + + it('passes numeric meta through for interpolation', () => { + const { key, vars } = describeError( + new ApiError(413, 'x', { + code: 'TOO_MANY_ROWS', + meta: { total: 120000, max: 50000 }, + }), + ); + expect(key).toBe('error.tooManyRows'); + expect(vars).toEqual({ total: 120000, max: 50000 }); + }); + + it('drops non-scalar meta rather than interpolating objects', () => { + const { vars } = describeError( + new ApiError(400, 'x', { + code: 'INVALID_BODY', + meta: { nested: { a: 1 } }, + }), + ); + expect(vars).toBeUndefined(); + }); + + it('handles values that are not Errors at all', () => { + expect(describeError('a string').key).toBe('common.genericError'); + expect(describeError(undefined).key).toBe('common.genericError'); + }); + + it('exposes fields so a form can mark the offending input', () => { + const error = new ApiError(409, 'x', { + code: 'DUPLICATE_NAME', + fields: ['name'], + }); + expect(error.fields).toEqual(['name']); + // Absent `fields` must be an empty array, not undefined — call sites read + // `.length` directly. + expect(new ApiError(500, 'x').fields).toEqual([]); + }); +}); diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index e91304a..a4c7efc 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,47 +1,132 @@ import { TRACKING_LINK_API_URL } from '../config'; +import { safeStorage } from './storage'; const TOKEN_KEY = 'tracking-link.token'; export function getToken(): string | null { - return localStorage.getItem(TOKEN_KEY); + return safeStorage.get(TOKEN_KEY); } export function setToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token); + safeStorage.set(TOKEN_KEY, token); } export function clearToken(): void { - localStorage.removeItem(TOKEN_KEY); + safeStorage.remove(TOKEN_KEY); } /** - * Raw `fetch` with the Bearer session token attached — for call sites that - * want to inspect `res.ok`/`res.status` themselves (matches the fetch style - * used throughout the ported page components). + * Fired when the API rejects a request that *should* have been authenticated, + * i.e. the session died mid-use. + * + * Before this existed, `authFetch` cleared the token and then handed the 401 + * back to the caller, which rendered the literal string "HTTP 401" in a red + * banner. The sidebar stayed, the user stayed on the page, and every subsequent + * click failed identically with no way out but a manual reload. With an 8h token + * and a multi-day festival that is a guaranteed occurrence, not an edge case. */ -export function authFetch( - input: string, - init: RequestInit = {}, -): Promise { - const token = getToken(); - const headers = new Headers(init.headers); - if (token) headers.set('Authorization', `Bearer ${token}`); - return fetch(input, { ...init, headers }).then((res) => { - if (res.status === 401) clearToken(); - return res; - }); +export const UNAUTHORIZED_EVENT = 'tracking-link:unauthorized'; + +/** The coded error body the API returns (see packages/api/src/errors.ts). */ +export interface ApiFailure { + code?: string; + error?: string; + fields?: string[]; + meta?: Record; } export class ApiError extends Error { constructor( public status: number, message: string, + public failure: ApiFailure = {}, ) { super(message); + this.name = 'ApiError'; + } + + /** Stable machine-readable code, when the API supplied one. */ + get code(): string | undefined { + return this.failure.code; + } + + /** Field names the form should mark invalid. */ + get fields(): string[] { + return this.failure.fields ?? []; + } + + get meta(): Record { + return this.failure.meta ?? {}; } } -/** JSON convenience wrapper used by the auth flow itself (login/me). */ +/** + * The request never reached the API — offline, DNS, CORS, or the Worker being + * cold. Distinct from ApiError because the two need opposite handling: a 401 + * means sign in again, a network failure means retry. Conflating them is what + * made a Wi-Fi blip look like being signed out. + */ +export class NetworkError extends Error { + constructor(cause?: unknown) { + super('Network request failed'); + this.name = 'NetworkError'; + this.cause = cause; + } +} + +/** + * True for the endpoints where a 401 is an ordinary answer rather than an + * expired session: a wrong password on the login form, and the session probe + * that runs for every not-yet-signed-in visitor. Firing "your session expired" + * for either would be actively misleading. + */ +function isAuthProbe(url: string): boolean { + return url.includes('/auth/login') || url.includes('/auth/me'); +} + +/** + * `fetch` with the Bearer token attached, for call sites that inspect + * `res.ok`/`res.status` themselves. + * + * Rejects with `NetworkError` rather than a bare `TypeError: Failed to fetch`, + * so callers can tell "server said no" from "never got there". + */ +export async function authFetch( + input: string, + init: RequestInit = {}, +): Promise { + const token = getToken(); + const headers = new Headers(init.headers); + if (token) headers.set('Authorization', `Bearer ${token}`); + + let response: Response; + try { + response = await fetch(input, { ...init, headers }); + } catch (cause) { + throw new NetworkError(cause); + } + + if (response.status === 401) { + clearToken(); + if (!isAuthProbe(input)) { + window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)); + } + } + return response; +} + +/** Throws a coded ApiError unless the response succeeded. */ +export async function assertOk(response: Response): Promise { + if (response.ok) return; + const failure = (await response.json().catch(() => ({}))) as ApiFailure; + throw new ApiError( + response.status, + failure.error ?? `Request failed (${response.status})`, + failure, + ); +} + +/** JSON convenience wrapper. */ export async function apiFetch( path: string, init: RequestInit = {}, @@ -53,14 +138,77 @@ export async function apiFetch( headers, }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new ApiError( - response.status, - body.error || body.message || `Request failed (${response.status})`, - ); - } - + await assertOk(response); if (response.status === 204) return undefined as T; return response.json(); } + +/** + * API error code → i18n key. + * + * This mapping is the whole point of the codes. The API used to decide the + * wording, which made translation impossible: English developer strings + * ("Invalid request body") reached Japanese users, while the 409 was a hardcoded + * Japanese sentence that reached English users. Now the server states *what* + * happened and the client owns *how it reads*. + */ +const CODE_TO_KEY: Record = { + UNAUTHORIZED: 'error.unauthorized', + PASSWORD_REQUIRED: 'validation.required', + INVALID_PASSWORD: 'login.invalidPassword', + PERMISSION_REQUIRED: 'error.permission', + NOT_OWNER: 'error.notOwner', + INVALID_BODY: 'error.invalidBody', + NO_FIELDS_TO_UPDATE: 'error.noFieldsToUpdate', + PROJECT_NOT_FOUND: 'error.projectNotFound', + QR_CODE_NOT_FOUND: 'error.qrNotFound', + DUPLICATE_NAME: 'error.duplicateName', + CSV_EXPORT_DISABLED: 'csvExport.disabled', + TOO_MANY_ROWS: 'error.tooManyRows', + RATE_LIMITED: 'error.rateLimited', +}; + +/** + * Status → i18n key, for responses without a code. + * + * Only statuses whose meaning is unambiguous without one. 404 is deliberately + * absent: "not found" could be a project or a QR code, and guessing wrong is + * worse than a generic message. + */ +const STATUS_TO_KEY: Record = { + 400: 'error.invalidBody', + 401: 'error.unauthorized', + 403: 'error.permission', + 409: 'error.duplicateName', + 413: 'error.tooManyRows', + 429: 'error.rateLimited', +}; + +export interface ErrorDescription { + key: string; + vars?: Record; +} + +/** Maps any thrown value to an i18n key plus interpolation vars. */ +export function describeError(error: unknown): ErrorDescription { + if (error instanceof NetworkError) return { key: 'error.network' }; + + if (error instanceof ApiError) { + const key = + (error.code && CODE_TO_KEY[error.code]) ?? + STATUS_TO_KEY[error.status] ?? + (error.status >= 500 ? 'error.serverError' : 'common.genericError'); + + // Only TOO_MANY_ROWS interpolates today, but reading the numbers off `meta` + // generically means adding another such message needs no change here. + const vars: Record = {}; + for (const [name, value] of Object.entries(error.meta)) { + if (typeof value === 'string' || typeof value === 'number') { + vars[name] = value; + } + } + return { key, vars: Object.keys(vars).length ? vars : undefined }; + } + + return { key: 'common.genericError' }; +} diff --git a/packages/web/src/lib/download.ts b/packages/web/src/lib/download.ts new file mode 100644 index 0000000..9bf35fc --- /dev/null +++ b/packages/web/src/lib/download.ts @@ -0,0 +1,29 @@ +/** + * Saves a Blob to the user's downloads. + * + * The CSV download this replaces was broken in a way that produced *no* error: + * the anchor was never appended to the document, and `URL.revokeObjectURL` ran + * synchronously right after `.click()`. Firefox ignores a click on a detached + * anchor, and revoking before the browser has read the blob can abort the save + * elsewhere. Since `res.ok` had been true, nothing was reported — the user + * clicked Download and nothing ever happened, forever. + * + * Also the right shape for the QR PNG: `
` is unreliable on iOS + * Safari, where it tends to navigate in-tab instead of saving and so destroys the + * dialog state behind it. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.rel = 'noopener'; + // Appended, so the click counts in every browser. + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + // Deferred: give the browser a turn to start reading the blob before the URL + // is invalidated. + window.setTimeout(() => URL.revokeObjectURL(url), 10_000); +} diff --git a/packages/web/src/lib/format.test.ts b/packages/web/src/lib/format.test.ts new file mode 100644 index 0000000..6e1e945 --- /dev/null +++ b/packages/web/src/lib/format.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { formatDate, formatDateTime, slugForFilename } from './format'; + +describe('formatDateTime', () => { + const iso = '2026-07-25T09:05:00.000Z'; + + it('produces different output per app locale', () => { + // The bug being locked down: every call site used toLocaleString() with no + // locale, so switching the UI to Japanese still rendered en-US dates. + const ja = formatDateTime(iso, 'ja'); + const en = formatDateTime(iso, 'en'); + expect(ja).not.toBe(en); + expect(ja).toMatch(/2026/); + }); + + it('includes a time, because the ja label says 作成日時', () => { + // toLocaleDateString() silently dropped the time while the column header + // promised it. + expect(formatDateTime(iso, 'ja')).toMatch(/\d{1,2}:\d{2}/); + }); + + it('renders a placeholder for missing or unparseable input', () => { + expect(formatDateTime(null, 'ja')).toBe('-'); + expect(formatDateTime(undefined, 'ja')).toBe('-'); + expect(formatDateTime('', 'ja')).toBe('-'); + // Would previously have rendered the string "Invalid Date" to the user. + expect(formatDateTime('not-a-date', 'ja')).toBe('-'); + }); + + it('formatDate omits the time', () => { + expect(formatDate(iso, 'ja')).not.toMatch(/\d{1,2}:\d{2}/); + }); +}); + +describe('slugForFilename', () => { + it('keeps Japanese, because that is the whole point', () => { + expect(slugForFilename('造形大ポスター', 'ポスター', '1F掲示板')).toBe( + '造形大ポスター_ポスター_1F掲示板', + ); + }); + + it('strips characters no filesystem accepts', () => { + expect(slugForFilename('a/b\\c:d*e?f"gi|j')).toBe('abcdefghij'); + }); + + it('collapses whitespace, including full-width spaces', () => { + expect(slugForFilename('造形大 ポスター')).toBe('造形大_ポスター'); + expect(slugForFilename('造形大 ポスター')).toBe('造形大_ポスター'); + }); + + it('skips empty parts instead of leaving stray separators', () => { + // An omitted location must not produce "name_medium_". + expect(slugForFilename('name', 'medium', '')).toBe('name_medium'); + expect(slugForFilename('name', '', undefined, null)).toBe('name'); + }); + + it('never returns an empty filename', () => { + expect(slugForFilename('')).toBe('qr'); + expect(slugForFilename('///')).toBe('qr'); + }); + + it('caps the length so the OS does not reject the path', () => { + expect(slugForFilename('あ'.repeat(200)).length).toBeLessThanOrEqual(80); + }); +}); diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts new file mode 100644 index 0000000..11d2884 --- /dev/null +++ b/packages/web/src/lib/format.ts @@ -0,0 +1,75 @@ +import type { Locale } from './i18n'; + +/** + * Date formatting that follows the *app's* language, not the browser's. + * + * Every call site used `toLocaleDateString()` / `toLocaleString()` with no locale + * argument, so switching the UI to 日本語 still produced `7/25/2026`. Worse, the + * Japanese column header reads 作成日時 ("date and time") while + * `toLocaleDateString()` drops the time entirely, and the mobile card and the + * desktop table disagreed on which of the two to call. + */ +const LOCALE_TAGS: Record = { en: 'en-US', ja: 'ja-JP' }; + +/** Rendered when a timestamp is missing *or* unparseable — `Invalid Date` is not an answer. */ +const PLACEHOLDER = '-'; + +function toDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** Date only. */ +export function formatDate( + value: string | null | undefined, + locale: Locale, +): string { + const date = toDate(value); + if (!date) return PLACEHOLDER; + return date.toLocaleDateString(LOCALE_TAGS[locale], { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** Date and time — use wherever the label says 作成日時 / "Created". */ +export function formatDateTime( + value: string | null | undefined, + locale: Locale, +): string { + const date = toDate(value); + if (!date) return PLACEHOLDER; + return date.toLocaleString(LOCALE_TAGS[locale], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Builds a filename fragment from user text. + * + * Japanese is deliberately preserved: the whole reason downloads were unusable + * was that `qr-3f2b8a1c-….png` says nothing about which poster it belongs to, and + * transliterating 造形大ポスター to `zou-kei-dai` would not help either. + */ +export function slugForFilename( + ...parts: (string | null | undefined)[] +): string { + const slug = parts + .map((part) => (part ?? '').trim()) + .filter(Boolean) + .join('_') + // Characters no filesystem accepts. + .replace(/[\\/:*?"<>|]/g, '') + // Any whitespace (including full-width) collapses to one underscore. + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .slice(0, 80); + return slug || 'qr'; +} diff --git a/packages/web/src/lib/i18n.tsx b/packages/web/src/lib/i18n.tsx index 6124e39..bde9dc7 100644 --- a/packages/web/src/lib/i18n.tsx +++ b/packages/web/src/lib/i18n.tsx @@ -7,6 +7,7 @@ import { useMemo, useState, } from 'react'; +import { safeStorage } from './storage'; export type Locale = 'en' | 'ja'; @@ -36,6 +37,43 @@ const en: Dictionary = { 'common.ip': 'IP', 'common.backToProjects': 'Back to projects', 'common.genericError': 'Something went wrong', + 'common.retry': 'Try again', + 'common.optional': 'optional', + 'common.medium': 'Medium', + 'common.bot': 'Bot', + 'common.deleting': 'Deleting…', + 'common.saving': 'Saving…', + 'common.menu': 'Menu', + 'common.discardChanges': 'Discard your unsaved changes?', + + // Errors, keyed off the API's error codes so wording lives on the client. + 'error.network': + 'Could not reach the server. Check your connection and try again.', + 'error.unauthorized': 'Your session has expired. Please sign in again.', + 'error.permission': "You don't have permission to do that.", + 'error.notOwner': 'You can only change QR codes you created.', + 'error.invalidBody': 'Please check the highlighted fields.', + 'error.noFieldsToUpdate': 'Nothing was changed.', + 'error.projectNotFound': + 'That project no longer exists. It may have been deleted.', + 'error.qrNotFound': + 'That QR code no longer exists. It may have been deleted.', + 'error.duplicateName': + 'A QR code with this name already exists in this project.', + 'error.tooManyRows': + 'Too many rows ({total}) to export at once. The limit is {max} — narrow the date range.', + 'error.rateLimited': 'Too many attempts. Please wait a moment and try again.', + 'error.serverError': 'The server had a problem. Please try again.', + + 'validation.required': 'This field is required.', + 'validation.tooLong': 'Please use {max} characters or fewer.', + 'validation.url': 'Enter a URL starting with http:// or https://', + 'validation.fallbackKey': + 'Use lowercase letters, digits and hyphens only, starting with a letter or digit.', + + 'login.sessionExpired': 'Your session expired. Please sign in again.', + 'login.retryAfterNetwork': + 'Could not reach the server. Check your connection.', 'login.subtitle': 'Sign in with the admin password.', 'login.passwordLabel': 'Password', @@ -47,12 +85,15 @@ const en: Dictionary = { 'nav.projects': 'Projects', 'nav.newProject': 'New project', 'nav.logOut': 'Log out', + 'nav.language': 'Language', 'permission.noAccessTitle': "You don't have access to this page.", 'permission.noAccessDesc': 'Contact an administrator if you believe this is a mistake.', 'pagination.range': '{start}–{end} of {total}', + 'pagination.previous': 'Previous page', + 'pagination.next': 'Next page', 'projects.heading': 'TrackingLink', 'projects.cardTitle': 'Projects', @@ -61,9 +102,32 @@ const en: Dictionary = { 'projects.csvDownloadLink': 'Download CSV', 'projects.editFormTitle': 'Edit project', 'projects.editFailed': 'Failed to update the project', - 'projects.deleteConfirm': - 'Delete this project? All of its QR codes will be deleted too.', + 'projects.created': 'Project created', + 'projects.updated': 'Project updated', + 'projects.deleted': 'Project deleted', + 'projects.deleteTitle': 'Delete project', + 'projects.deleteBody': + 'Delete “{name}”?\n\nIts QR codes and all of their scan history will be deleted too. This cannot be undone.', 'projects.deleteFailed': 'Failed to delete the project', + 'projects.emptyCta': 'Create your first project', + 'projects.destinationUrlPropagation': + 'A changed destination URL can take up to a minute to take effect for everyone.', + 'projects.fallbackKeyLabel': 'Fallback keyword', + 'projects.fallbackKeyPlaceholder': 'e.g. instagram', + 'projects.fallbackKeyHint': + 'Baked into this project’s QR codes and used to pick a destination if the database is unreachable. Changing it later does not update QR codes that are already printed.', + 'projects.fallbackKeyNone': '(none — use the site-wide fallback)', + 'projects.fallbackKeySelected': + 'If the database is unreachable, scans go to {url}', + 'projects.fallbackKeyNoneSelected': + 'With no keyword, scans go to {url} if the database is unreachable.', + 'projects.fallbackKeyEmpty': + 'No destinations are configured yet. Add them to FALLBACK_DESTINATIONS in packages/api/wrangler.jsonc.', + 'projects.fallbackKeyOrphaned': + '“{key}” is no longer in the Worker configuration. QR codes already printed with it will use the site-wide fallback until it is added back.', + 'projects.fallbackKeyOrphanedOption': '{key} (not in the configuration)', + 'projects.fallbackKeyListUnavailable': + 'Could not load the configured destinations. Enter the keyword manually.', 'createProject.heading': 'New project', 'createProject.subtitle': 'Create a new TrackingLink project', @@ -88,13 +152,22 @@ const en: Dictionary = { 'qrCodes.cardTitle': 'QR codes', 'qrCodes.empty': 'No QR codes yet.', 'qrCodes.showButton': 'Show QR', - 'qrCodes.deleteConfirm': - 'Delete this QR code? Its scan history will be deleted too.', + 'qrCodes.editFormTitleNamed': 'Edit “{name}”', + 'qrCodes.nameHint': + 'One QR code per item, so this has to be unique within the project.', + 'qrCodes.created': 'QR code created', + 'qrCodes.updated': 'QR code updated', + 'qrCodes.deleted': 'QR code deleted', + 'qrCodes.deleteTitle': 'Delete QR code', + 'qrCodes.deleteBody': + 'Delete “{name}”?\n\nIts scan history will be deleted too, and anything already printed with this code will stop working. This cannot be undone.', 'qrCodes.createFailed': 'Failed to create the QR code', 'qrCodes.editFailed': 'Failed to update the QR code', 'qrCodes.deleteFailed': 'Failed to delete', 'qrCodes.qrIdHeader': 'QR ID', 'qrCodes.dialogTitle': 'QR code', + 'qrCodes.imageAlt': 'QR code for {name}', + 'qrCodes.scanUrlLabel': 'This code links to', 'qrCodes.generateFailed': 'Failed to generate the QR code', 'qrCodes.downloadButton': 'Download PNG', @@ -124,6 +197,45 @@ const ja: Dictionary = { 'common.ip': 'IPアドレス', 'common.backToProjects': 'プロジェクト一覧に戻る', 'common.genericError': 'エラーが発生しました', + 'common.retry': '再試行', + 'common.optional': '任意', + 'common.medium': '媒体', + 'common.bot': 'ボット', + 'common.deleting': '削除中…', + 'common.saving': '保存中…', + 'common.menu': 'メニュー', + 'common.discardChanges': '保存していない変更を破棄しますか?', + + // Errors, keyed off the API's error codes so wording lives on the client. + 'error.network': + 'サーバーに接続できませんでした。通信環境を確認して再試行してください。', + 'error.unauthorized': + 'セッションの有効期限が切れました。再度ログインしてください。', + 'error.permission': 'この操作を行う権限がありません。', + 'error.notOwner': '自分が作成したQRコードのみ変更できます。', + 'error.invalidBody': '入力内容を確認してください。', + 'error.noFieldsToUpdate': '変更点がありません。', + 'error.projectNotFound': + 'このプロジェクトは存在しません。削除された可能性があります。', + 'error.qrNotFound': + 'このQRコードは存在しません。削除された可能性があります。', + 'error.duplicateName': 'この名前のQRコードはこのプロジェクトに既にあります。', + 'error.tooManyRows': + '件数が多すぎます({total}件)。一度に出力できるのは{max}件までです。期間を絞ってください。', + 'error.rateLimited': + '試行回数が多すぎます。しばらく待ってから再試行してください。', + 'error.serverError': 'サーバー側で問題が発生しました。再試行してください。', + + 'validation.required': 'この項目は必須です。', + 'validation.tooLong': '{max}文字以内で入力してください。', + 'validation.url': 'http:// または https:// で始まるURLを入力してください。', + 'validation.fallbackKey': + '半角の英小文字・数字・ハイフンのみ、先頭は英数字で入力してください。', + + 'login.sessionExpired': + 'セッションの有効期限が切れました。再度ログインしてください。', + 'login.retryAfterNetwork': + 'サーバーに接続できませんでした。通信環境を確認してください。', 'login.subtitle': '管理者パスワードでログインしてください。', 'login.passwordLabel': 'パスワード', @@ -135,12 +247,15 @@ const ja: Dictionary = { 'nav.projects': 'プロジェクト', 'nav.newProject': 'プロジェクト作成', 'nav.logOut': 'ログアウト', + 'nav.language': '言語', 'permission.noAccessTitle': 'このページへのアクセス権限がありません。', 'permission.noAccessDesc': '心当たりがない場合は管理者にお問い合わせください。', 'pagination.range': '{start}〜{end} 件(全 {total} 件)', + 'pagination.previous': '前のページ', + 'pagination.next': '次のページ', 'projects.heading': 'TrackingLink', 'projects.cardTitle': 'プロジェクト一覧', @@ -149,9 +264,32 @@ const ja: Dictionary = { 'projects.csvDownloadLink': 'CSVダウンロード', 'projects.editFormTitle': 'プロジェクトを編集', 'projects.editFailed': 'プロジェクトの更新に失敗しました', - 'projects.deleteConfirm': - 'このプロジェクトを削除しますか?QRコードもすべて削除されます。', + 'projects.created': 'プロジェクトを作成しました', + 'projects.updated': 'プロジェクトを更新しました', + 'projects.deleted': 'プロジェクトを削除しました', + 'projects.deleteTitle': 'プロジェクトを削除', + 'projects.deleteBody': + '「{name}」を削除しますか?\n\nこのプロジェクトのQRコードと、そのアクセスログもすべて削除されます。この操作は取り消せません。', 'projects.deleteFailed': 'プロジェクトの削除に失敗しました', + 'projects.emptyCta': '最初のプロジェクトを作成', + 'projects.destinationUrlPropagation': + '転送先URLの変更は、全員に反映されるまで最大1分かかることがあります。', + 'projects.fallbackKeyLabel': 'フォールバック用キーワード', + 'projects.fallbackKeyPlaceholder': '例:instagram', + 'projects.fallbackKeyHint': + 'このプロジェクトのQRコードに埋め込まれ、データベースに接続できないときの転送先を選ぶのに使われます。後から変更しても、既に印刷したQRコードには反映されません。', + 'projects.fallbackKeyNone': '(なし — 全体のフォールバック先を使う)', + 'projects.fallbackKeySelected': + 'データベースに接続できないとき、{url} へ転送されます。', + 'projects.fallbackKeyNoneSelected': + 'キーワードなしの場合、データベースに接続できないときは {url} へ転送されます。', + 'projects.fallbackKeyEmpty': + '転送先がまだ設定されていません。packages/api/wrangler.jsonc の FALLBACK_DESTINATIONS に追加してください。', + 'projects.fallbackKeyOrphaned': + '「{key}」はWorkerの設定にありません。このキーワードで既に印刷したQRコードは、設定に戻すまで全体のフォールバック先へ転送されます。', + 'projects.fallbackKeyOrphanedOption': '{key}(設定にありません)', + 'projects.fallbackKeyListUnavailable': + '設定されている転送先を読み込めませんでした。キーワードを手で入力してください。', 'createProject.heading': 'プロジェクト作成', 'createProject.subtitle': '新しいTrackingLinkプロジェクトを作成します。', @@ -176,13 +314,22 @@ const ja: Dictionary = { 'qrCodes.cardTitle': 'QRコード一覧', 'qrCodes.empty': 'QRコードがありません。', 'qrCodes.showButton': 'QR表示', - 'qrCodes.deleteConfirm': - 'このQRコードを削除しますか?アクセスログも削除されます。', + 'qrCodes.editFormTitleNamed': '「{name}」を編集', + 'qrCodes.nameHint': + '物ごとに1つのQRコードを発行するため、プロジェクト内で重複しない名前にしてください。', + 'qrCodes.created': 'QRコードを作成しました', + 'qrCodes.updated': 'QRコードを更新しました', + 'qrCodes.deleted': 'QRコードを削除しました', + 'qrCodes.deleteTitle': 'QRコードを削除', + 'qrCodes.deleteBody': + '「{name}」を削除しますか?\n\nアクセスログも削除され、このコードで既に印刷したものは読み取れなくなります。この操作は取り消せません。', 'qrCodes.createFailed': 'QRコードの作成に失敗しました', 'qrCodes.editFailed': 'QRコードの更新に失敗しました', 'qrCodes.deleteFailed': '削除に失敗しました', 'qrCodes.qrIdHeader': 'QR ID', 'qrCodes.dialogTitle': 'QRコード', + 'qrCodes.imageAlt': '「{name}」のQRコード', + 'qrCodes.scanUrlLabel': 'このコードの転送先', 'qrCodes.generateFailed': 'QRコードの生成に失敗しました', 'qrCodes.downloadButton': 'PNGをダウンロード', @@ -203,9 +350,15 @@ function interpolate( } function detectDefaultLocale(): Locale { - const stored = localStorage.getItem(LOCALE_KEY); + // safeStorage, not localStorage: this runs inside a useState initialiser, so a + // throw here (Safari Private Browsing, storage blocked) white-screens the app. + const stored = safeStorage.get(LOCALE_KEY); if (stored === 'en' || stored === 'ja') return stored; - return navigator.language.toLowerCase().startsWith('ja') ? 'ja' : 'en'; + try { + return navigator.language.toLowerCase().startsWith('ja') ? 'ja' : 'en'; + } catch { + return 'en'; + } } interface LocaleContextValue { @@ -220,7 +373,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) { const [locale, setLocale] = useState(detectDefaultLocale); useEffect(() => { - localStorage.setItem(LOCALE_KEY, locale); + safeStorage.set(LOCALE_KEY, locale); document.documentElement.lang = locale; }, [locale]); diff --git a/packages/web/src/lib/qr.test.ts b/packages/web/src/lib/qr.test.ts new file mode 100644 index 0000000..ba9c7ba --- /dev/null +++ b/packages/web/src/lib/qr.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { deriveFallbackKey, qrTargetUrl } from './qr'; + +/** + * These two functions decide what gets physically printed. A mistake here is not + * a bug you patch — it is a reprint. + */ + +describe('deriveFallbackKey', () => { + it('takes the first host label, dropping www.', () => { + expect( + deriveFallbackKey('https://www.instagram.com/nutfes_official/'), + ).toBe('instagram'); + expect(deriveFallbackKey('https://nutfes.net/')).toBe('nutfes'); + }); + + it('handles multi-part suffixes without a public-suffix list', () => { + expect(deriveFallbackKey('https://www.nutfes.ac.jp/')).toBe('nutfes'); + expect(deriveFallbackKey('https://example.co.jp/path')).toBe('example'); + }); + + it('lowercases and strips characters the API would reject', () => { + // The API accepts ^[a-z0-9][a-z0-9-]*$, so a suggestion that fails validation + // would be a self-inflicted form error. + expect(deriveFallbackKey('https://WWW.Insta_Gram.com/')).toBe('instagram'); + expect(deriveFallbackKey('https://my-shop.example.com/')).toBe('my-shop'); + }); + + it('returns empty for anything unparseable rather than throwing', () => { + // Called while the user is still typing a URL, so it sees partial input + // constantly. + for (const input of ['', 'not a url', 'https://', 'javascript:alert(1)']) { + expect(deriveFallbackKey(input)).toBe(''); + } + }); + + it('caps the length to what the column accepts', () => { + const long = `https://${'a'.repeat(80)}.com/`; + expect(deriveFallbackKey(long).length).toBeLessThanOrEqual(40); + }); +}); + +describe('qrTargetUrl', () => { + it('includes the keyword when there is one', () => { + expect(qrTargetUrl('abc-123', 'instagram')).toMatch( + /\/\?id=abc-123&p=instagram$/, + ); + }); + + it('omits &p= entirely when there is no keyword', () => { + // A blank `&p=` is payload noise, and the Worker treats missing and blank + // identically anyway. + expect(qrTargetUrl('abc-123', '')).toMatch(/\/\?id=abc-123$/); + expect(qrTargetUrl('abc-123')).toMatch(/\/\?id=abc-123$/); + }); + + it('percent-encodes the keyword', () => { + expect(qrTargetUrl('abc', 'a b')).toContain('&p=a%20b'); + }); +}); diff --git a/packages/web/src/lib/qr.ts b/packages/web/src/lib/qr.ts new file mode 100644 index 0000000..3b25825 --- /dev/null +++ b/packages/web/src/lib/qr.ts @@ -0,0 +1,159 @@ +import QRCodeLib from 'qrcode'; +import { TRACKING_LINK_API_URL } from '../config'; + +/** + * Base URL that scanned QR codes resolve against. Separate from the API URL + * because the redirect endpoint can live on its own hostname (see + * VITE_FWD_BASE_URL in .env.example). + */ +const FWD_BASE_URL = import.meta.env.VITE_FWD_BASE_URL ?? TRACKING_LINK_API_URL; + +/** + * Longest fallback keyword accepted. Mirrors FALLBACK_KEY_MAX in the API's + * projects route and the Projects.fallback_key column. + */ +const FALLBACK_KEY_MAX = 40; + +/** + * Suggests a fallback keyword from a destination URL's host. + * + * `https://www.instagram.com/nutfes/` → `instagram`. Strips a leading `www.` and + * takes the first label, which gives a sensible answer for `nutfes.net`, + * `www.nutfes.ac.jp` and `example.co.jp` alike without needing a public-suffix + * list. + * + * Used in two places: to prefill the field when someone types a destination URL, + * and — more importantly — as the effective keyword for projects whose + * `fallbackKey` is still blank. That second use is why no data migration was + * needed: QR codes for projects created before the column existed still carry a + * usable keyword. + * + * Returns '' rather than throwing for anything unparseable; the caller then just + * omits `&p=` and the scan falls through to the site-wide fallback. + */ +export function deriveFallbackKey(destinationUrl: string): string { + let host: string; + try { + host = new URL(destinationUrl).hostname; + } catch { + return ''; + } + + const label = host.replace(/^www\./i, '').split('.')[0] ?? ''; + return label + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/^-+/, '') + .slice(0, FALLBACK_KEY_MAX); +} + +/** + * The URL baked into a printed QR code. + * + * `&p=` is what lets a scan still reach somewhere sensible when the + * Worker cannot read D1 — see packages/api/src/fallback.ts for why this is a + * keyword rather than the destination URL itself. + * + * Extracted from the dialog because it is the one string that, if wrong, produces + * posters that have to be reprinted — and because a future bulk-print view needs + * exactly this and nothing else from the dialog. + */ +export function qrTargetUrl(qrId: string, fallbackKey = ''): string { + const base = `${FWD_BASE_URL}/?id=${qrId}`; + // Omitted rather than sent empty: `&p=` with no value is noise in the payload + // and the Worker treats a missing key and a blank one identically. + return fallbackKey ? `${base}&p=${encodeURIComponent(fallbackKey)}` : base; +} + +/** On-screen preview. */ +export function qrPreviewDataUrl(text: string): Promise { + return QRCodeLib.toDataURL(text, { + width: 300, + margin: 2, + errorCorrectionLevel: 'H', + }); +} + +const PNG_QR_SIZE = 640; +const PNG_PADDING = 32; +const CAPTION_LINE_HEIGHT = 34; +const CAPTION_FONT_SIZE = 24; + +/** Shortens a caption line to fit the image width, with an ellipsis. */ +function fitText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): string { + if (ctx.measureText(text).width <= maxWidth) return text; + let low = 0; + let high = text.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (ctx.measureText(`${text.slice(0, mid)}…`).width <= maxWidth) { + low = mid; + } else { + high = mid - 1; + } + } + return `${text.slice(0, low)}…`; +} + +/** + * Renders a QR code to a PNG with its name, medium and location printed beneath. + * + * The caption is the point. One QR code is generated per physical item, and the + * downloaded PNG used to carry no identifying text at all — so once printed, + * nobody could tell which poster a given sheet belonged to. The filename helps in + * a folder; only the caption helps on paper. + */ +export async function qrPngBlob( + text: string, + captionLines: string[], +): Promise { + const qrCanvas = document.createElement('canvas'); + await QRCodeLib.toCanvas(qrCanvas, text, { + width: PNG_QR_SIZE, + margin: 2, + errorCorrectionLevel: 'H', + }); + + const lines = captionLines.filter(Boolean); + const canvas = document.createElement('canvas'); + canvas.width = qrCanvas.width + PNG_PADDING * 2; + canvas.height = + qrCanvas.height + + PNG_PADDING * 2 + + (lines.length ? lines.length * CAPTION_LINE_HEIGHT + PNG_PADDING / 2 : 0); + + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas 2D context unavailable'); + + // Explicit white fill: a transparent PNG prints as nothing useful, and QR + // scanners need the quiet zone to actually be light. + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(qrCanvas, PNG_PADDING, PNG_PADDING); + + if (lines.length) { + ctx.fillStyle = '#000000'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + const maxWidth = canvas.width - PNG_PADDING * 2; + let y = qrCanvas.height + PNG_PADDING + PNG_PADDING / 2; + for (const [index, line] of lines.entries()) { + // First line is the name, and it is what someone reads from across a + // corridor — so it gets the bold weight. + ctx.font = `${index === 0 ? '600 ' : ''}${CAPTION_FONT_SIZE}px system-ui, sans-serif`; + ctx.fillText(fitText(ctx, line, maxWidth), canvas.width / 2, y); + y += CAPTION_LINE_HEIGHT; + } + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Failed to encode the QR code as PNG')); + }, 'image/png'); + }); +} diff --git a/packages/web/src/lib/storage.test.ts b/packages/web/src/lib/storage.test.ts new file mode 100644 index 0000000..5e0d385 --- /dev/null +++ b/packages/web/src/lib/storage.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * safeStorage is the one unit that genuinely *cannot* be checked by hand: there + * is no way to make DevTools throw from `localStorage.getItem`, and the bug it + * guards against (Safari Private Browsing white-screening the whole app at + * mount) only reproduces on a real device. A stubbed global is the only + * practical proof. + */ +async function freshModule() { + // The availability probe memoises, so each scenario needs a clean module. + vi.resetModules(); + return (await import('./storage')).safeStorage; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe('safeStorage', () => { + it('reads and writes through to localStorage when it works', async () => { + const store = new Map(); + vi.stubGlobal('window', { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }, + }); + + const safeStorage = await freshModule(); + safeStorage.set('k', 'v'); + expect(safeStorage.get('k')).toBe('v'); + safeStorage.remove('k'); + expect(safeStorage.get('k')).toBeNull(); + }); + + it('does not throw when every localStorage call throws, and still round-trips in memory', async () => { + vi.stubGlobal('window', { + localStorage: { + getItem: () => { + throw new DOMException('blocked'); + }, + setItem: () => { + throw new DOMException('blocked'); + }, + removeItem: () => { + throw new DOMException('blocked'); + }, + }, + }); + + const safeStorage = await freshModule(); + expect(() => safeStorage.set('token', 'abc')).not.toThrow(); + // The in-memory fallback keeps the session usable for the life of the tab. + expect(safeStorage.get('token')).toBe('abc'); + expect(() => safeStorage.remove('token')).not.toThrow(); + expect(safeStorage.get('token')).toBeNull(); + }); + + it('survives a write that exceeds quota while reads still work', async () => { + const store = new Map(); + vi.stubGlobal('window', { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: () => { + throw new DOMException('QuotaExceededError'); + }, + removeItem: (k: string) => void store.delete(k), + }, + }); + + const safeStorage = await freshModule(); + expect(() => safeStorage.set('locale', 'ja')).not.toThrow(); + // Falls back to memory rather than losing the value. + expect(safeStorage.get('locale')).toBe('ja'); + }); +}); diff --git a/packages/web/src/lib/storage.ts b/packages/web/src/lib/storage.ts new file mode 100644 index 0000000..50a9f8f --- /dev/null +++ b/packages/web/src/lib/storage.ts @@ -0,0 +1,61 @@ +/** + * localStorage that cannot take the app down. + * + * Safari Private Browsing, storage-blocked contexts and a full quota all throw + * on plain `localStorage` access — including the getter. Two call sites made + * that fatal: the token accessors, and `detectDefaultLocale` inside a + * `useState` initialiser, where a throw white-screens the entire app at mount. + * Staff share phones on site and open links in private tabs, so this is a + * realistic way to lose the admin UI completely. + * + * Falls back to an in-memory map: the session and the language switch keep + * working for the life of the tab, they just do not persist. + */ +const memory = new Map(); +let available: boolean | null = null; + +function probe(): boolean { + if (available !== null) return available; + try { + const probeKey = '__tracking_link_probe__'; + window.localStorage.setItem(probeKey, '1'); + window.localStorage.removeItem(probeKey); + available = true; + } catch { + available = false; + } + return available; +} + +export const safeStorage = { + get(key: string): string | null { + if (!probe()) return memory.get(key) ?? null; + try { + return window.localStorage.getItem(key); + } catch { + return memory.get(key) ?? null; + } + }, + + set(key: string, value: string): void { + // The memory copy is written first and unconditionally, so a quota error + // below still leaves the value readable for this tab. + memory.set(key, value); + if (!probe()) return; + try { + window.localStorage.setItem(key, value); + } catch { + /* quota exceeded or blocked — memory copy already holds it */ + } + }, + + remove(key: string): void { + memory.delete(key); + if (!probe()) return; + try { + window.localStorage.removeItem(key); + } catch { + /* nothing useful to do */ + } + }, +}; diff --git a/packages/web/src/lib/styles.ts b/packages/web/src/lib/styles.ts new file mode 100644 index 0000000..169d5df --- /dev/null +++ b/packages/web/src/lib/styles.ts @@ -0,0 +1,74 @@ +/** + * Shared class-name constants. + * + * Deliberately constants rather than ` - + {t('common.cancel')}
diff --git a/packages/web/src/pages/LoginPage.tsx b/packages/web/src/pages/LoginPage.tsx index 22176e0..145b81f 100644 --- a/packages/web/src/pages/LoginPage.tsx +++ b/packages/web/src/pages/LoginPage.tsx @@ -1,19 +1,36 @@ import { type FormEvent, useState } from 'react'; -import { Navigate, useNavigate } from 'react-router-dom'; +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuthContext } from '../components/AuthProvider'; import { LanguageSwitcher } from '../components/LanguageSwitcher'; -import { ApiError } from '../lib/api'; +import { useApiErrorMessage } from '../hooks/useApiError'; import { useTranslation } from '../lib/i18n'; +import { btnPrimary, inputBase, labelBase } from '../lib/styles'; +import { cn } from '../lib/utils'; + +/** + * Only same-origin paths are honoured, so a crafted `?next=https://evil.example` + * cannot turn the login form into an open redirect. + */ +function safeNextPath(next: string | null): string { + if (!next) return '/links'; + if (!next.startsWith('/') || next.startsWith('//')) return '/links'; + return next; +} export function LoginPage() { const { user, login } = useAuthContext(); const { t } = useTranslation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const describeError = useApiErrorMessage(); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - if (user) return ; + const next = safeNextPath(searchParams.get('next')); + const sessionExpired = searchParams.get('reason') === 'session_expired'; + + if (user) return ; async function handleSubmit(e: FormEvent) { e.preventDefault(); @@ -21,29 +38,28 @@ export function LoginPage() { setSubmitting(true); try { await login(password); - navigate('/links'); + // Back to whatever they were doing when the session died. + navigate(next, { replace: true }); } catch (err) { - if (err instanceof ApiError) { - setError( - err.message === 'Invalid password' - ? t('login.invalidPassword') - : err.message, - ); - } else { - setError(t('login.failed')); - } + // Was `err.message === 'Invalid password'` — string-matching an English + // server literal, so any rewording on the API leaked raw English to the + // user, and the 400 path already did. + setError(describeError(err)); } finally { setSubmitting(false); } } return ( -
+ // min-h-dvh, not h-screen: with a fixed height the vertically centred card + // gets pushed out of the viewport when the soft keyboard opens, and there is + // no scroll container to reach it — you literally cannot log in. +
-
+

TrackingLink

@@ -51,25 +67,48 @@ export function LoginPage() { {t('login.subtitle')}

-
-
-

{project.name}

- - - - {project.qrCodeCount.toLocaleString()} - - - - {project.accessCount.toLocaleString()} - - -
-
- {project.destinationUrl} - - -
- - {project.createdAt - ? new Date(project.createdAt).toLocaleDateString() - : '-'} - -
- - - {t('projects.qrCodesLink')} - - {canEdit && ( - - )} - {canAnalytics && ( - + // A 2-column grid on a phone: four labelled 44px buttons cannot fit one row + // at 375px, and the previous `flex gap-2` had no wrap at all. +
+ + + {t('projects.qrCodesLink')} + + {canEdit && ( + + )} + {canAnalytics && ( + + {t('projects.csvDownloadLink')} + + )} + {canDelete && ( +
-
+ {t('common.delete')} + + )}
); } function ManageProjectsContent() { const { user } = useAuthContext(); - const { t } = useTranslation(); - const canEdit = hasPermission( - user?.permissions ?? 0, - Permissions.TRACKING_LINK_EDIT, - ); + const { t, locale } = useTranslation(); + const toast = useToast(); + const describeError = useApiErrorMessage(); + const permissions = user?.permissions ?? 0; + const canEdit = hasPermission(permissions, Permissions.TRACKING_LINK_EDIT); const canAnalytics = hasPermission( - user?.permissions ?? 0, + permissions, Permissions.TRACKING_LINK_ANALYTICS, ); const canDelete = hasPermission( - user?.permissions ?? 0, + permissions, Permissions.TRACKING_LINK_DELETE, ); - const [projects, setProjects] = useState([]); - const [total, setTotal] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [downloadingId, setDownloadingId] = useState(null); - const [editingProject, setEditingProject] = useState(null); + const buildUrl = useCallback( + (page: number) => + `${TRACKING_LINK_API_URL}/projects?page=${page}&limit=${PAGE_SIZE}`, + [], + ); + const list = useListQuery(buildUrl, PAGE_SIZE); + + const [pending, setPending] = useState(null); + const [editing, setEditing] = useState(null); const [editName, setEditName] = useState(''); const [editUrl, setEditUrl] = useState(''); + const [editFallbackKey, setEditFallbackKey] = useState(''); + // Stops the destination URL from overwriting a keyword the user chose by hand — + // the value ends up printed on posters, so a silent overwrite is worse than no + // suggestion at all. + const fallbackKeyTouched = useRef(false); const [isSaving, setIsSaving] = useState(false); + const [confirmTarget, setConfirmTarget] = useState(null); + const { + errors, + validate, + clear: clearErrors, + setFromFields, + } = useFieldErrors(); + const fallback = useFallbackDestinations(); - const fetchProjects = useCallback( - async (page: number) => { - setIsLoading(true); - setError(null); - try { - const res = await authFetch( - `${TRACKING_LINK_API_URL}/projects?page=${page}&limit=${PAGE_SIZE}`, - ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - setProjects(Array.isArray(data.data) ? data.data : []); - setTotal(typeof data.total === 'number' ? data.total : 0); - } catch (e) { - setError(e instanceof Error ? e.message : t('common.genericError')); - } finally { - setIsLoading(false); - } - }, - [t], - ); - - useEffect(() => { - fetchProjects(currentPage); - }, [fetchProjects, currentPage]); + const isDirty = + editing !== null && + (editName !== editing.name || + editUrl !== editing.destinationUrl || + editFallbackKey !== editing.fallbackKey); - const handleDownloadCsv = async (project: Project) => { - setDownloadingId(project.projectId); - setError(null); - try { - const res = await authFetch( - `${TRACKING_LINK_API_URL}/projects/${project.projectId}/access-logs/csv`, - ); - if (!res.ok) { - throw new Error( - res.status === 403 - ? t('csvExport.disabled') - : t('csvExport.downloadFailed'), - ); - } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `access-logs-${project.projectId}.csv`; - a.click(); - URL.revokeObjectURL(url); - } catch (e) { - setError(e instanceof Error ? e.message : t('csvExport.downloadFailed')); - } finally { - setDownloadingId(null); - } - }; - - const openEditForm = (project: Project) => { - setEditingProject(project); + const openEdit = (project: Project) => { + setEditing(project); setEditName(project.name); setEditUrl(project.destinationUrl); + setEditFallbackKey(project.fallbackKey); + // An existing project already has a considered value (even a blank one), so + // the URL must not start rewriting it just because the form opened. + fallbackKeyTouched.current = true; + clearErrors(); }; - const closeEditForm = () => { - setEditingProject(null); + const onEditUrlChange = (value: string) => { + setEditUrl(value); + if (fallbackKeyTouched.current) return; + // Only ever suggest a keyword the Worker actually knows about. + const derived = deriveFallbackKey(value); + const match = fallback.destinations.find((d) => d.key === derived); + setEditFallbackKey(match ? match.key : ''); + }; + + const closeEdit = () => { + setEditing(null); setEditName(''); setEditUrl(''); + setEditFallbackKey(''); + fallbackKeyTouched.current = false; + clearErrors(); + }; + + const requestCloseEdit = () => { + if (isDirty && !window.confirm(t('common.discardChanges'))) return; + closeEdit(); }; const handleEditSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!editingProject) return; - const projectName = editName.trim(); - const destinationUrl = editUrl.trim(); - if (!projectName || !destinationUrl) return; + if (!editing || isSaving) return; + const ok = validate({ + projectName: { + id: 'editProjectName', + value: editName, + required: true, + maxLength: NAME_MAX, + }, + destinationUrl: { + id: 'editProjectUrl', + value: editUrl, + required: true, + maxLength: URL_MAX, + validate: validateHttpUrl, + }, + fallbackKey: { + id: 'editProjectFallbackKey', + value: editFallbackKey, + maxLength: FALLBACK_KEY_MAX, + validate: validateFallbackKey, + }, + }); + if (!ok) return; setIsSaving(true); - setError(null); try { const res = await authFetch( - `${TRACKING_LINK_API_URL}/projects/${editingProject.projectId}`, + `${TRACKING_LINK_API_URL}/projects/${editing.projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectName, destinationUrl }), + body: JSON.stringify({ + projectName: editName.trim(), + destinationUrl: editUrl.trim(), + fallbackKey: editFallbackKey.trim(), + }), }, ); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error( - (data as { error?: string }).error ?? `HTTP ${res.status}`, - ); + await assertOk(res); + closeEdit(); + await list.refresh(); + toast.success(t('projects.updated')); + } catch (err) { + if (err instanceof ApiError && err.fields.length) { + setFromFields(err.fields, describeError(err)); } - closeEditForm(); - await fetchProjects(currentPage); - } catch (e) { - setError(e instanceof Error ? e.message : t('projects.editFailed')); + // Routed to a toast rather than form-local state, so the message survives + // the form closing. + toast.error(describeError(err)); } finally { setIsSaving(false); } }; - const handleDeleteProject = async (projectId: string) => { - if (!confirm(t('projects.deleteConfirm'))) return; + const handleDownloadCsv = async (project: Project) => { + setPending({ projectId: project.projectId, kind: 'csv' }); try { const res = await authFetch( - `${TRACKING_LINK_API_URL}/projects/${projectId}`, + `${TRACKING_LINK_API_URL}/projects/${project.projectId}/access-logs/csv`, + ); + await assertOk(res); + const blob = await res.blob(); + // Named from the project rather than its UUID, and saved through a helper + // that actually works — the old inline anchor was never appended to the + // document and revoked its URL synchronously, so in some browsers the + // download simply never happened and nothing was reported. + downloadBlob(blob, `${slugForFilename('access-logs', project.name)}.csv`); + } catch (err) { + toast.error(describeError(err)); + } finally { + setPending(null); + } + }; + + const handleConfirmDelete = async () => { + if (!confirmTarget) return; + const target = confirmTarget; + setPending({ projectId: target.projectId, kind: 'delete' }); + try { + const res = await authFetch( + `${TRACKING_LINK_API_URL}/projects/${target.projectId}`, { method: 'DELETE' }, ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - await fetchProjects(currentPage); - } catch (e) { - setError(e instanceof Error ? e.message : t('projects.deleteFailed')); + await assertOk(res); + setConfirmTarget(null); + // The hook re-reads total from the server and clamps the page, so deleting + // the only row on the last page lands on a page that exists instead of an + // empty list with no way back. + await list.refresh(); + toast.success(t('projects.deleted')); + } catch (err) { + toast.error(describeError(err)); + } finally { + setPending(null); } }; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const showEmpty = !list.isLoading && !list.error && list.items.length === 0; return ( -
-
-

- {t('projects.heading')} -

+
+
+
+

{t('projects.cardTitle')}

+

+ {t('common.totalCount', { total: list.total })} +

+
{canEdit && ( - + {t('nav.newProject')} )}
- {error && ( -
-

{error}

-
- )} - - {canEdit && editingProject && ( -
-

{t('projects.editFormTitle')}

- -
-
- - setEditName(e.target.value)} - placeholder={t('createProject.namePlaceholder')} - required - className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
- - setEditUrl(e.target.value)} - placeholder={t('createProject.urlPlaceholder')} - required - className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
-
- - +
+ ) : showEmpty ? ( +
+
+ ) : ( +
    + {list.items.map((project) => ( +
  • - {t('common.cancel')} - -
- -
- )} +
+

+ {project.name} +

+ + {/* Labelled text, not a `title` attribute: title is + unavailable on touch and inconsistently exposed to + assistive tech, so these numbers were unlabelled on the + device this app is used on. */} + + + + + +
-
-
-

{t('projects.cardTitle')}

-

- {isLoading - ? t('common.loading') - : t('common.totalCount', { total })} -

-
+ + {project.destinationUrl} + + - {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
+
+ + {t('common.created')}:{' '} + {formatDateTime(project.createdAt, locale)} + + openEdit(project)} + onDownloadCsv={() => void handleDownloadCsv(project)} + onDelete={() => setConfirmTarget(project)} + /> +
+ ))} -
- ) : projects.length === 0 ? ( -

- {t('projects.empty')} -

- ) : ( - <> - {/* Mobile: card list */} -
- {projects.map((project) => ( - openEditForm(project)} - onDownloadCsv={() => handleDownloadCsv(project)} - onDelete={() => handleDeleteProject(project.projectId)} - /> - ))} -
- - {/* Desktop: table */} -
- - - - - - - - - - - - - {projects.map((project) => ( - - - - - - - - - ))} - -
- {t('common.name')} - - {t('common.destinationUrl')} - - {t('common.qrCodeCount')} - - {t('common.scans')} - - {t('common.created')} - - {t('common.actions')} -
{project.name} - - - {project.destinationUrl} - - - - - - - {project.qrCodeCount.toLocaleString()} - - - - - {project.accessCount.toLocaleString()} - - - {project.createdAt - ? new Date(project.createdAt).toLocaleDateString() - : '-'} - -
- - - {t('projects.qrCodesLink')} - - {canEdit && ( - - )} - {canAnalytics && ( - - )} - {canDelete && ( - - )} -
-
-
- + )} - + {!list.error && !showEmpty ? ( + + ) : null}
+ + {/* A modal rather than a panel rendered above the table: clicking Edit on + row 8 used to open a form off-screen with no scroll or focus move, so + visually nothing happened and the button looked broken. */} + + + +
+ } + > +
+
+ + setEditName(e.target.value)} + onBlur={() => setEditName((v) => v.trim())} + maxLength={NAME_MAX} + disabled={isSaving} + aria-invalid={errors.projectName ? true : undefined} + aria-describedby={ + errors.projectName ? 'editProjectName-error' : undefined + } + className={inputBase} + /> + {errors.projectName ? ( +

+ {errors.projectName} +

+ ) : null} +
+
+ + onEditUrlChange(e.target.value)} + onBlur={() => setEditUrl((v) => v.trim())} + maxLength={URL_MAX} + disabled={isSaving} + aria-invalid={errors.destinationUrl ? true : undefined} + aria-describedby={ + errors.destinationUrl ? 'editProjectUrl-error' : undefined + } + className={inputBase} + /> + {errors.destinationUrl ? ( +

+ {errors.destinationUrl} +

+ ) : null} +
+ { + fallbackKeyTouched.current = true; + setEditFallbackKey(v); + }} + destinations={fallback.destinations} + staticFallbackUrl={fallback.staticFallbackUrl} + isLoading={fallback.isLoading} + failed={fallback.failed} + error={errors.fallbackKey} + disabled={isSaving} + /> +

+ {t('projects.destinationUrlPropagation')} +

+ + + + void handleConfirmDelete()} + onCancel={() => setConfirmTarget(null)} + />
); } diff --git a/packages/web/src/pages/QRCodesPage.tsx b/packages/web/src/pages/QRCodesPage.tsx index d8d8821..7faac53 100644 --- a/packages/web/src/pages/QRCodesPage.tsx +++ b/packages/web/src/pages/QRCodesPage.tsx @@ -1,16 +1,12 @@ import { ArrowLeft, - ChevronLeft, - ChevronRight, - Loader, - MapPin, + Download, + Loader2, Pencil, Plus, - QrCode, + QrCode as QrCodeIcon, Trash2, - X, } from 'lucide-react'; -import QRCodeLib from 'qrcode'; import { type FormEvent, useCallback, @@ -20,13 +16,38 @@ import { } from 'react'; import { Link, useParams } from 'react-router-dom'; import { useAuthContext } from '../components/AuthProvider'; +import { ConfirmDialog } from '../components/ConfirmDialog'; +import { Modal } from '../components/Modal'; +import { Pagination } from '../components/Pagination'; import { PermissionGuard } from '../components/PermissionGuard'; +import { useToast } from '../components/ToastProvider'; import { TRACKING_LINK_API_URL } from '../config'; +import { useApiErrorMessage } from '../hooks/useApiError'; +import { useFieldErrors } from '../hooks/useFieldErrors'; +import { useListQuery } from '../hooks/useListQuery'; import { Permissions, hasPermission } from '../hooks/useStaffAuth'; -import { authFetch } from '../lib/api'; +import { ApiError, assertOk, authFetch } from '../lib/api'; +import { downloadBlob } from '../lib/download'; +import { formatDateTime, slugForFilename } from '../lib/format'; import { useTranslation } from '../lib/i18n'; +import { + deriveFallbackKey, + qrPngBlob, + qrPreviewDataUrl, + qrTargetUrl, +} from '../lib/qr'; +import { + btnPrimary, + btnRow, + btnRowDestructive, + btnSecondary, + fieldErrorText, + inputBase, + labelBase, +} from '../lib/styles'; +import { cn } from '../lib/utils'; -interface QRCode { +interface QRCodeRecord { id: string; projectId: string; name: string; @@ -39,662 +60,677 @@ interface QRCode { interface Project { name: string; projectId: string; + destinationUrl: string; + fallbackKey: string; } const PAGE_SIZE = 10; -const FWD_BASE_URL = import.meta.env.VITE_FWD_BASE_URL ?? TRACKING_LINK_API_URL; +const FIELD_MAX = 200; -/** Generates a QR code as a data URL, client-side. */ -function useQRDataUrl(text: string): { - dataUrl: string | null; - error: boolean; -} { +/** Generates a QR image client-side. Nothing about the code is ever persisted. */ +function useQRDataUrl(text: string | null) { const [dataUrl, setDataUrl] = useState(null); - const [error, setError] = useState(false); + const [failed, setFailed] = useState(false); + useEffect(() => { + if (!text) return; let cancelled = false; setDataUrl(null); - setError(false); - QRCodeLib.toDataURL(text, { - width: 300, - margin: 2, - errorCorrectionLevel: 'H', - }) + setFailed(false); + qrPreviewDataUrl(text) .then((url) => { if (!cancelled) setDataUrl(url); }) .catch(() => { - if (!cancelled) setError(true); + if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; }, [text]); - return { dataUrl, error }; -} -function QRDialog({ qr, onClose }: { qr: QRCode; onClose: () => void }) { - const { t } = useTranslation(); - const url = `${FWD_BASE_URL}/?id=${qr.id}`; - const { dataUrl: qrDataUrl, error: qrError } = useQRDataUrl(url); + return { dataUrl, failed }; +} +/** + * Row thumbnail. + * + * With one QR code issued per physical item, a list of otherwise identical-looking + * rows is hard to scan; the image is the fastest way to confirm you are about to + * edit or delete the right one. `alt=""` because the name is right next to it — + * announcing the image again would just be noise. + */ +function QRThumbnail({ + qrId, + fallbackKey, +}: { qrId: string; fallbackKey: string }) { + const url = useMemo( + () => qrTargetUrl(qrId, fallbackKey), + [qrId, fallbackKey], + ); + const { dataUrl } = useQRDataUrl(url); return ( -
-
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > -
-
-

{t('qrCodes.dialogTitle')}

-

- {qr.name} · {qr.medium} · {qr.location} -

-
- -
- -
-
- {!qrDataUrl && !qrError && ( - - )} - {qrError && ( -

- {t('qrCodes.generateFailed')} -

- )} - {qrDataUrl && ( - {t('qrCodes.dialogTitle')} - )} -
-

- {url} -

-
- - {qrDataUrl && ( - - {t('qrCodes.downloadButton')} - - )} -
+
+ {dataUrl ? ( + + ) : ( +
); } -function Pagination({ - currentPage, - totalPages, - total, - onPageChange, +function QRDialog({ + qr, + fallbackKey, + onClose, }: { - currentPage: number; - totalPages: number; - total: number; - onPageChange: (page: number) => void; + qr: QRCodeRecord; + fallbackKey: string; + onClose: () => void; }) { const { t } = useTranslation(); - if (totalPages <= 1) return null; - const start = (currentPage - 1) * PAGE_SIZE + 1; - const end = Math.min(currentPage * PAGE_SIZE, total); - const pages = Array.from({ length: totalPages }, (_, i) => i + 1) - .filter( - (p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 1, - ) - .reduce<(number | '…')[]>((acc, p, idx, arr) => { - if (idx > 0 && (arr[idx - 1] as number) < p - 1) acc.push('…'); - acc.push(p); - return acc; - }, []); + const toast = useToast(); + const url = useMemo( + () => qrTargetUrl(qr.id, fallbackKey), + [qr.id, fallbackKey], + ); + const { dataUrl, failed } = useQRDataUrl(url); + const [isDownloading, setIsDownloading] = useState(false); + + const captionLines = [ + qr.name, + [qr.medium, qr.location].filter(Boolean).join(' · '), + ].filter(Boolean); + + const handleDownload = async () => { + setIsDownloading(true); + try { + // A PNG with the name / medium / location printed underneath, saved via a + // blob. Previously this was ``: + // the file was unidentifiable in a downloads folder, the printed sheet had + // no label at all, and on iOS Safari a data: URL tends to navigate in-tab + // rather than save — destroying this dialog in the process. + const blob = await qrPngBlob(url, captionLines); + downloadBlob( + blob, + `${slugForFilename(qr.name, qr.medium, qr.location)}.png`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? t('qrCodes.generateFailed') + : t('common.genericError'), + ); + } finally { + setIsDownloading(false); + } + }; return ( -
-

- {t('pagination.range', { start, end, total })} -

-
+ onPageChange(Math.max(1, currentPage - 1))} - disabled={currentPage === 1} - className="flex h-7 w-7 items-center justify-center rounded-md border hover:bg-muted/50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" + onClick={() => void handleDownload()} + disabled={!dataUrl || isDownloading} + className={cn(btnPrimary, 'w-full')} > - - - {pages.map((p, idx) => - p === '…' ? ( - - … - + {isDownloading ? ( + ) : ( - - ), - )} - -
-
- ); -} + } + > +
+

+ {qr.name} +

+

+ {t('common.medium')}: {qr.medium} + {qr.location ? ` / ${t('common.location')}: ${qr.location}` : ''} +

-function QRCard({ - qr, - onView, - onEdit, - onDelete, - canEdit, - canDeleteThis, -}: { - qr: QRCode; - onView: () => void; - onEdit: () => void; - onDelete: () => void; - canEdit: boolean; - canDeleteThis: boolean; -}) { - const { t } = useTranslation(); - return ( -
-
-
-

{qr.name}

-

- {qr.medium} - - - {qr.location} +

+ {!dataUrl && !failed && ( +
+ +
+ )} + {failed && ( +

+ {t('qrCodes.generateFailed')} +

+ )} + {dataUrl && ( + {t('qrCodes.imageAlt', + )} +
+ +
+

+ {t('qrCodes.scanUrlLabel')} +

+

+ {url}

- - {qr.createdAt ? new Date(qr.createdAt).toLocaleDateString() : '-'} - -
-
- - {canEdit && ( - - )} - {canDeleteThis && ( - - )}
-
+ ); } function QRCodesContent() { const { user } = useAuthContext(); - const { t } = useTranslation(); - const canEdit = hasPermission( - user?.permissions ?? 0, - Permissions.TRACKING_LINK_EDIT, - ); + const { t, locale } = useTranslation(); + const toast = useToast(); + const describeError = useApiErrorMessage(); + const { id: projectId } = useParams<{ id: string }>(); + + const permissions = user?.permissions ?? 0; + const canEdit = hasPermission(permissions, Permissions.TRACKING_LINK_EDIT); const canDelete = hasPermission( - user?.permissions ?? 0, + permissions, Permissions.TRACKING_LINK_DELETE, ); - // EDIT permission: can only delete your own QR codes. DELETE permission: can delete any. - const canDeleteQR = (qr: QRCode) => + // DELETE can remove any QR code; EDIT only its own. + const canDeleteQR = (qr: QRCodeRecord) => canDelete || (canEdit && qr.creatorId === user?.sub); - const { id: projectId } = useParams<{ id: string }>(); + + const buildUrl = useCallback( + (page: number) => + `${TRACKING_LINK_API_URL}/projects/${projectId}/qrcodes?page=${page}&limit=${PAGE_SIZE}`, + [projectId], + ); + const list = useListQuery(buildUrl, PAGE_SIZE); + const [project, setProject] = useState(null); - const [qrCodes, setQrCodes] = useState([]); - const [total, setTotal] = useState(0); - const [isLoading, setIsLoading] = useState(false); + + // What actually gets baked into the QR codes on this page. Falling back to a + // value derived from the destination host is what lets projects created before + // fallback_key existed carry a usable keyword with no data migration. + const effectiveFallbackKey = project + ? project.fallbackKey || deriveFallbackKey(project.destinationUrl) + : ''; + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [name, setName] = useState(''); + const [medium, setMedium] = useState(''); + const [location, setLocation] = useState(''); const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [newName, setNewName] = useState(''); - const [newMedium, setNewMedium] = useState(''); - const [newLocation, setNewLocation] = useState(''); - const [showForm, setShowForm] = useState(false); - const [formError, setFormError] = useState(null); - const [editingQR, setEditingQR] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [dialogQR, setDialogQR] = useState(null); - - const fetchData = useCallback( - async (page: number) => { - if (!projectId) return; - setIsLoading(true); - setError(null); - try { - const [projectRes, qrRes] = await Promise.all([ - authFetch(`${TRACKING_LINK_API_URL}/projects/${projectId}`), - authFetch( - `${TRACKING_LINK_API_URL}/projects/${projectId}/qrcodes?page=${page}&limit=${PAGE_SIZE}`, - ), - ]); - if (projectRes.ok) { - const p = await projectRes.json(); - setProject(p as Project); - } - if (!qrRes.ok) throw new Error(`HTTP ${qrRes.status}`); - const data = await qrRes.json(); - setQrCodes(Array.isArray(data.data) ? data.data : []); - setTotal(typeof data.total === 'number' ? data.total : 0); - } catch (e) { - setError(e instanceof Error ? e.message : t('common.genericError')); - } finally { - setIsLoading(false); - } - }, - [projectId, t], - ); + const [deletingId, setDeletingId] = useState(null); + const [confirmTarget, setConfirmTarget] = useState(null); + const [dialogQR, setDialogQR] = useState(null); + const { + errors, + validate, + clear: clearErrors, + setFromFields, + } = useFieldErrors(); + // Separate from the list so a failure here is visible. The old code did + // `if (projectRes.ok) { … }` with no else, so a 403/404 on the project just made + // the subtitle quietly vanish. useEffect(() => { - fetchData(currentPage); - }, [fetchData, currentPage]); - - const resetForm = () => { - setNewName(''); - setNewMedium(''); - setNewLocation(''); - setEditingQR(null); - setShowForm(false); - setFormError(null); + if (!projectId) return; + let cancelled = false; + authFetch(`${TRACKING_LINK_API_URL}/projects/${projectId}`) + .then(async (res) => { + await assertOk(res); + const body = (await res.json()) as Project; + if (!cancelled) setProject(body); + }) + .catch((error: unknown) => { + if (!cancelled) toast.error(describeError(error)); + }); + return () => { + cancelled = true; + }; + }, [projectId, toast, describeError]); + + const isDirty = editing + ? name !== editing.name || + medium !== editing.medium || + location !== editing.location + : Boolean(name || medium || location); + + const openCreate = () => { + setEditing(null); + setName(''); + setMedium(''); + setLocation(''); + clearErrors(); + setFormOpen(true); }; - const openCreateForm = () => { - if (showForm && !editingQR) { - setShowForm(false); - return; - } - setNewName(''); - setNewMedium(''); - setNewLocation(''); - setEditingQR(null); - setShowForm(true); - setFormError(null); + const openEdit = (qr: QRCodeRecord) => { + setEditing(qr); + setName(qr.name); + setMedium(qr.medium); + setLocation(qr.location); + clearErrors(); + setFormOpen(true); }; - const openEditForm = (qr: QRCode) => { - setNewName(qr.name); - setNewMedium(qr.medium); - setNewLocation(qr.location); - setEditingQR(qr); - setShowForm(true); - setFormError(null); + // Guarded, because three separate paths used to wipe in-progress input with no + // warning: opening another row's edit form, hitting "New QR code", and Cancel. + const requestCloseForm = () => { + if (isDirty && !window.confirm(t('common.discardChanges'))) return; + setFormOpen(false); + setEditing(null); + clearErrors(); }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!projectId) return; - const name = newName.trim(); - const medium = newMedium.trim(); - const location = newLocation.trim(); - if (!name || !medium || !location) return; + if (!projectId || isSaving) return; + + const ok = validate({ + name: { id: 'qrName', value: name, required: true, maxLength: FIELD_MAX }, + medium: { + id: 'qrMedium', + value: medium, + required: true, + maxLength: FIELD_MAX, + }, + // Not required: one QR code per item, and staff record where it went only + // when that is useful. + location: { id: 'qrLocation', value: location, maxLength: FIELD_MAX }, + }); + if (!ok) return; setIsSaving(true); - setFormError(null); try { - const url = editingQR - ? `${TRACKING_LINK_API_URL}/projects/qrcodes/${editingQR.id}` + const url = editing + ? `${TRACKING_LINK_API_URL}/projects/qrcodes/${editing.id}` : `${TRACKING_LINK_API_URL}/projects/${projectId}/qrcodes`; const res = await authFetch(url, { - method: editingQR ? 'PUT' : 'POST', + method: editing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, medium, location }), + body: JSON.stringify({ + name: name.trim(), + medium: medium.trim(), + location: location.trim(), + }), }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error( - (data as { error?: string }).error ?? `HTTP ${res.status}`, - ); - } - resetForm(); - if (editingQR || currentPage === 1) { - await fetchData(currentPage); + await assertOk(res); + + const wasEditing = editing !== null; + setFormOpen(false); + setEditing(null); + clearErrors(); + if (wasEditing) { + await list.refresh(); } else { - setCurrentPage(1); + // New rows sort first, so page 1 is where it is. + list.setPage(1); + if (list.page === 1) await list.refresh(); } - } catch (e) { - setFormError( - e instanceof Error - ? e.message - : editingQR - ? t('qrCodes.editFailed') - : t('qrCodes.createFailed'), - ); + toast.success(wasEditing ? t('qrCodes.updated') : t('qrCodes.created')); + } catch (err) { + if (err instanceof ApiError && err.fields.length) { + // A 409 now says which field collided; previously the user got a + // hardcoded Japanese sentence with nothing highlighted and had to guess. + setFromFields(err.fields, describeError(err)); + } + // A toast, not form-local state: cancelling mid-save unmounted the form and + // the failure was written where nobody could see it. + toast.error(describeError(err)); } finally { setIsSaving(false); } }; - const handleDelete = async (qrId: string) => { - if (!confirm(t('qrCodes.deleteConfirm'))) return; + const handleConfirmDelete = async () => { + if (!confirmTarget) return; + const target = confirmTarget; + setDeletingId(target.id); try { const res = await authFetch( - `${TRACKING_LINK_API_URL}/projects/qrcodes/${qrId}`, + `${TRACKING_LINK_API_URL}/projects/qrcodes/${target.id}`, { method: 'DELETE' }, ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const newTotal = total - 1; - const newTotalPages = Math.max(1, Math.ceil(newTotal / PAGE_SIZE)); - const targetPage = Math.min(currentPage, newTotalPages); - if (targetPage !== currentPage) { - setCurrentPage(targetPage); - } else { - await fetchData(currentPage); - } - setTotal(newTotal); - } catch (e) { - setError(e instanceof Error ? e.message : t('qrCodes.deleteFailed')); + await assertOk(res); + setConfirmTarget(null); + // Refresh only. The old code then ran setTotal(total - 1), clobbering the + // count the server had just returned with one computed from a stale value. + await list.refresh(); + toast.success(t('qrCodes.deleted')); + } catch (err) { + toast.error(describeError(err)); + } finally { + setDeletingId(null); } }; - const totalPages = useMemo( - () => Math.max(1, Math.ceil(total / PAGE_SIZE)), - [total], - ); + const showEmpty = !list.isLoading && !list.error && list.items.length === 0; return ( -
-
+
+
{t('common.backToProjects')}
-
-
-

+
+
+

{t('qrCodes.heading')}

{project && ( -

+

{t('qrCodes.projectLabel', { name: project.name })}

)}
{canEdit && ( - )}
- {error && ( -
-

{error}

-
- )} - - {canEdit && showForm && ( -
-

- {editingQR ? t('qrCodes.editFormTitle') : t('qrCodes.newFormTitle')} -

-
-
-
- - setNewName(e.target.value)} - placeholder={t('qrCodes.namePlaceholder')} - required - className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
- - setNewMedium(e.target.value)} - placeholder={t('qrCodes.mediumPlaceholder')} - required - className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
- - setNewLocation(e.target.value)} - placeholder={t('qrCodes.locationPlaceholder')} - required - className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
- {formError && ( -

- {formError} -

- )} -
- +
+ ) : showEmpty ? ( +
+
-
-
- )} +
+ +
+

+ {qr.name} +

+
+
+
{t('common.medium')}:
+
{qr.medium}
+
+ {qr.location ? ( +
+
{t('common.location')}:
+
{qr.location}
+
+ ) : null} +
+
{t('common.created')}:
+
{formatDateTime(qr.createdAt, locale)}
+
+
+
+
-
-
-

{t('qrCodes.cardTitle')}

-

- {isLoading - ? t('common.loading') - : t('common.totalCount', { total })} -

-
+
+ + {canEdit && ( + + )} + {canDeleteQR(qr) && ( + + )} +
+ + ))} + + )} + + {/* Outside the non-empty branch. On this page the pagination used to be + rendered *inside* it, so emptying a page removed the only control + that could get you off it. */} + {!list.error && !showEmpty ? ( + + ) : null} +
- {isLoading ? ( -
- + + + +
+ } + > +
+
+ + setName(e.target.value)} + onBlur={() => setName((v) => v.trim())} + placeholder={t('qrCodes.namePlaceholder')} + maxLength={FIELD_MAX} + disabled={isSaving} + aria-invalid={errors.name ? true : undefined} + aria-describedby={errors.name ? 'qrName-error' : undefined} + className={inputBase} + /> + {errors.name ? ( +

+ {errors.name} +

+ ) : ( +

+ {t('qrCodes.nameHint')} +

+ )}
- ) : qrCodes.length === 0 ? ( -

- {t('qrCodes.empty')} -

- ) : ( - <> - {/* Mobile: card list */} -
- {qrCodes.map((qr) => ( - setDialogQR(qr)} - onEdit={() => openEditForm(qr)} - onDelete={() => handleDelete(qr.id)} - canEdit={canEdit} - canDeleteThis={canDeleteQR(qr)} - /> - ))} -
- {/* Desktop: table */} -
- - - - - - - - - - - - {qrCodes.map((qr) => ( - - - - - - - - ))} - -
- {t('common.name')} - - {t('qrCodes.mediumLabel')} - - {t('common.location')} - - {t('common.created')} - - {t('common.actions')} -
{qr.name} - {qr.medium} - - - - {qr.location} - - - {qr.createdAt - ? new Date(qr.createdAt).toLocaleString() - : '-'} - -
- - {canEdit && ( - - )} - {canDeleteQR(qr) && ( - - )} -
-
-
+
+ + setMedium(e.target.value)} + onBlur={() => setMedium((v) => v.trim())} + placeholder={t('qrCodes.mediumPlaceholder')} + maxLength={FIELD_MAX} + disabled={isSaving} + aria-invalid={errors.medium ? true : undefined} + aria-describedby={errors.medium ? 'qrMedium-error' : undefined} + className={inputBase} + /> + {errors.medium ? ( +

+ {errors.medium} +

+ ) : null} +
- + + setLocation(e.target.value)} + onBlur={() => setLocation((v) => v.trim())} + placeholder={t('qrCodes.locationPlaceholder')} + maxLength={FIELD_MAX} + disabled={isSaving} + aria-invalid={errors.location ? true : undefined} + aria-describedby={ + errors.location ? 'qrLocation-error' : undefined + } + className={inputBase} /> - - )} -

+ {errors.location ? ( +

+ {errors.location} +

+ ) : null} +
+ + + + void handleConfirmDelete()} + onCancel={() => setConfirmTarget(null)} + /> - {dialogQR && setDialogQR(null)} />} + {dialogQR ? ( + setDialogQR(null)} + /> + ) : null}
); } diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 0000000..7264c29 --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // jsdom, because the units under test touch localStorage, Date locale + // formatting and window — not because any component is rendered here. + environment: 'jsdom', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c525168..0265d24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: 5.9.3 vitest: specifier: 2.1.8 - version: 2.1.8(@types/node@22.20.0)(lightningcss@1.32.0) + version: 2.1.8(@types/node@22.20.0)(jsdom@25.0.1)(lightningcss@1.32.0) wrangler: specifier: ^3.90.0 version: 3.114.17(@cloudflare/workers-types@4.20260702.1) @@ -85,6 +85,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) + jsdom: + specifier: 25.0.1 + version: 25.0.1 tailwindcss: specifier: ^4.0.0 version: 4.3.2 @@ -94,12 +97,18 @@ importers: vite: specifier: ^6.0.3 version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + vitest: + specifier: 2.1.8 + version: 2.1.8(@types/node@22.20.0)(jsdom@25.0.1)(lightningcss@1.32.0) wrangler: specifier: ^3.90.0 version: 3.114.17(@cloudflare/workers-types@4.20260702.1) packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -286,6 +295,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -1154,6 +1191,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1169,6 +1210,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + baseline-browser-mapping@2.10.41: resolution: {integrity: sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==} engines: {node: '>=6.0.0'} @@ -1186,6 +1230,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -1230,6 +1278,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + concurrently@9.2.3: resolution: {integrity: sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==} engines: {node: '>=18'} @@ -1242,12 +1294,20 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1261,6 +1321,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1268,6 +1331,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1367,6 +1434,10 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + electron-to-chromium@1.5.387: resolution: {integrity: sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==} @@ -1377,9 +1448,29 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.17.19: resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} engines: {node: '>=12'} @@ -1433,11 +1524,18 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1446,12 +1544,24 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-source@2.0.12: resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1459,10 +1569,38 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hono@4.12.27: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} @@ -1470,6 +1608,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1480,6 +1621,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1571,6 +1721,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1585,6 +1738,18 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -1611,6 +1776,9 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -1626,6 +1794,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1661,6 +1832,10 @@ packages: printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -1714,9 +1889,22 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1787,6 +1975,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -1819,6 +2010,21 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1951,6 +2157,27 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -1994,6 +2221,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -2031,6 +2265,14 @@ packages: snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2209,6 +2451,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -2787,6 +3049,8 @@ snapshots: acorn@8.14.0: {} + agent-base@7.1.4: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -2799,6 +3063,8 @@ snapshots: assertion-error@2.0.1: {} + asynckit@0.4.0: {} + baseline-browser-mapping@2.10.41: {} blake3-wasm@2.1.5: {} @@ -2813,6 +3079,11 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + camelcase@5.3.1: {} caniuse-lite@1.0.30001800: {} @@ -2864,6 +3135,10 @@ snapshots: color-string: 1.9.1 optional: true + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + concurrently@9.2.3: dependencies: chalk: 4.1.2 @@ -2877,20 +3152,34 @@ snapshots: cookie@0.7.2: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} data-uri-to-buffer@2.0.2: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 decamelize@1.2.0: {} + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} defu@6.1.7: {} + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} dijkstrajs@1.0.3: {} @@ -2901,6 +3190,12 @@ snapshots: '@types/react': 18.3.31 react: 18.3.1 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + electron-to-chromium@1.5.387: {} emoji-regex@8.0.0: {} @@ -2910,8 +3205,25 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.17.19: optionalDependencies: '@esbuild/android-arm': 0.17.19 @@ -3017,13 +3329,41 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-source@2.0.12: dependencies: data-uri-to-buffer: 2.0.2 @@ -3031,23 +3371,87 @@ snapshots: glob-to-regexp@0.4.1: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hono@4.12.27: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + is-arrayish@0.3.4: optional: true is-fullwidth-code-point@3.0.0: {} + is-potential-custom-element-name@1.0.1: {} + jiti@2.7.0: {} jose@5.10.0: {} js-tokens@4.0.0: {} + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json5@2.2.3: {} @@ -3111,6 +3515,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3127,6 +3533,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime@3.0.0: {} miniflare@3.20250718.3: @@ -3154,6 +3568,8 @@ snapshots: node-releases@2.0.50: {} + nwsapi@2.2.24: {} + ohash@2.0.11: {} p-limit@2.3.0: @@ -3166,6 +3582,10 @@ snapshots: p-try@2.2.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-to-regexp@6.3.0: {} @@ -3190,6 +3610,8 @@ snapshots: printable-characters@1.0.42: {} + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -3269,10 +3691,20 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + rxjs@7.8.2: dependencies: tslib: 2.8.1 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -3355,6 +3787,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} tailwindcss@4.3.2: {} @@ -3376,6 +3810,20 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} tslib@2.8.1: {} @@ -3446,7 +3894,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 - vitest@2.1.8(@types/node@22.20.0)(lightningcss@1.32.0): + vitest@2.1.8(@types/node@22.20.0)(jsdom@25.0.1)(lightningcss@1.32.0): dependencies: '@vitest/expect': 2.1.8 '@vitest/mocker': 2.1.8(vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)) @@ -3470,6 +3918,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.0 + jsdom: 25.0.1 transitivePeerDependencies: - less - lightningcss @@ -3481,6 +3930,23 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which-module@2.0.1: {} why-is-node-running@2.3.0: @@ -3530,6 +3996,10 @@ snapshots: ws@8.18.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@4.0.3: {} y18n@5.0.8: {}