Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
a9b6be1
[feat] トースト通知と共通モーダル・削除確認ダイアログ・安全なlocalStorageを追加
haruto-kamijo Jul 25, 2026
7278208
[feat] APIエラーをi18nメッセージに正規化しセッション切れをログイン画面へ誘導
haruto-kamijo Jul 25, 2026
b01d14f
[feat] 全画面をモバイル対応しフォーム検証・削除確認・一覧取得を共通化
haruto-kamijo Jul 25, 2026
bdaff32
[chore] vitestを導入し無言で退行する純粋ロジックにテストを追加
haruto-kamijo Jul 25, 2026
60c5e52
[chore] k6による負荷テスト基盤とシードスクリプトを追加
haruto-kamijo Jul 25, 2026
17e7437
Merge release/api-hardening into release/web-ux
haruto-kamijo Jul 26, 2026
0797f58
[feat] QRコードにフォールバック用のキーワードを埋め込む
haruto-kamijo Jul 26, 2026
8b6b518
Merge release/api-hardening into release/web-ux
haruto-kamijo Jul 27, 2026
b52f3ca
[feat] フォールバック用キーワードを環境変数から引いたプルダウンにする
haruto-kamijo Jul 27, 2026
fc63b05
Merge release/api-hardening into release/web-ux
haruto-kamijo Jul 27, 2026
28d8abb
[docs] 本番負荷テストの規模を見直し、S2の主張を実測で訂正する
haruto-kamijo Jul 27, 2026
878be8a
[docs] 本番負荷テストを実施し、結果と実測による訂正を記録する
haruto-kamijo Jul 27, 2026
48dc2cb
[docs] 負荷テストの結果と懸念点のレポートを追加
haruto-kamijo Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <scratchpad>/pw && cd <scratchpad>/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.
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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ダウンロード(現在は無効化中)を確認可能

```
Expand Down Expand Up @@ -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 <token>`として使用します。
Expand Down
5 changes: 4 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading