traPtitech / traPtitech/Checkin
可能な限り自作を減らす原則による技術選定の見直し(13 の決定)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 0
- Forks
- 0
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 7
Description
各決定の実装の状態はこの issue では追わない。状態は時間とともに動き、一部だけが古くなるためである。例外は T1 で、T1 は置き換えの決定ではなく、この見直しと統合の作業の前後関係を決めたものなので、その位置付けを示すために既に実行されていることを書いてある。
解く問題
「可能な限り自作を減らしライブラリを使う」という原則のもとで、次の 4 つを決める。
- どの自作をライブラリに置き換え、どれを自作のまま残すか(T2・T4・T5・T6・T7・T12・T13)。この見直しの中心である。
- 既にあるライブラリを、まだ使っていない範囲でどこまで使うか(T3・T8・T9・T10)。新しいパッケージを入れずに自作を減らす決定と、T10 のように置き換える自作が無く、今は存在しない検証をライブラリで足す決定が入る。T10 は原則の直接の適用ではないので、本文でそう断っている。
- 重複した自作を 1 つにまとめるかどうか(T11)。寄せ先がライブラリの項もあるが、自作を自作のまま 1 つにまとめる項もある。ライブラリへの置き換えではないが、同じ原則の作業として同時に決めた。
- この見直しと、統合の作業の前後関係(T1)。何を作るかではなく、いつ作るかの決定である。
スタック層(Nuxt・oRPC・Drizzle・zod・Stripe SDK・Tailwind・@nuxt/ui)は既にライブラリであり、自作していない。原則が効くのは、その上に手で書いた部分である。
サーバー側の自作を「A 汎用(ライブラリで置換可能)/B 汎用だが制約が乗る/C ドメイン固有/D 外部 SDK の薄いアダプタ」に分類した。A と B は packages/api/src/auth/ と apps/web/server/ にほぼ全部集中していた。payouts・ledger・billing・stripe には A も B も無く、jomon にあるのは http.ts の汎用部分(base URL 結合・Bearer の付与・!res.ok で throw)だけで、そこは T6 で ofetch に寄せる。つまり原則が強く当たるのは認証まわりであって、会計のドメインロジックではない。
この分類はサーバー側だけを対象にしており、クライアント側(apps/web/app)は分類していない。クライアント側の決定(T8・T9・T10・T11 の一部)は、この分類からではなく、同じ原則を個々の箇所に当てて決めた。
分類ごとの行数と、置き換えによる増減の見込みはこの issue には書かない。時間とともに動くので、判断をやり直すときは測り直す。
解かない範囲
@nuxt/ui以外の UI ライブラリへの入れ替え(PrimeVue・Vuetify 等)。UI ライブラリは現に自作しておらず@nuxt/uiを使っているので、別のライブラリへ入れ替えても自作は減らない。原則が減らす対象がこの入れ替えには無い。- 外部の有償・クローズドソースの SaaS への依存(認証では Auth0・Clerk・WorkOS・Kinde、レート制限では Unkey 等)。isct メールと traQ ID というデュアル・アイデンティティの情報や、利用状況を外部サービスへ預けるかは、下の「何を見て決めたか」に挙げる 6 つでは決まらない、運用方針の決定である。候補として調べていないので、候補も費用も示せない。T2 と T5 はどちらもこの範囲を除いたうえで決めている。
- JS 以外の実装(Go や Rust のサイドカーで認証サーバーを立てる等)。Nuxt/h3 の上で完結させる現在の構成を変える判断が要る。
payouts.vueの行ごとの操作の状態を TanStack Query に載せるかどうか。載せれば、実行中・結果・失敗を手で持っているrefの大半と、Recordから 1 キーを削る自作ヘルパーの呼び出しは不要になる。ただし行ごとに読み戻すには、行ごとのコンポーネントに切るか、useMutationStateが返す配列を畳む導出を自分で書くかが要る。前者はコンポーネント分割という設計の決定を伴うので、T9 の範囲に足さずこの見直しでは決めない。
なお、会計のドメインロジック(payouts の状態機械、ledger の二重支払い防止、billing の期の計算)を自作のまま残すことは、解かない範囲ではなく T12 の 1 で決めている。
何を見て決めたか
よく効いたのは次の 6 つである。どれが決め手になったかは決定ごとに違う。順序付きの規則を機械的に当てたのではなく、候補ごとに効く事情を比べて決めている。
- 要件を満たすか。今必要な機能を備えているか。
- 上流の開発が止まったときの費用。deprecated・長期間更新なし・上流が別プロジェクトへ統合されたものは、採ると移行費用を将来に積む。
- 移行の費用。DB スキーマの変更、既存セッションの失効、既存テストの作り直しが要るか。
- 破壊的変更の方針。
- 依存の重さ。このリポジトリが宣言する依存が増えるかと、候補のパッケージ自身がいくつ依存を持つかの 2 つを見る。クライアントバンドルに載るかも見る。
- 今後必要になる機能(レート制限、多要素、監査ログなど)。
現在の規模(部内の会員数百人規模)を、将来の必要性を否定する根拠に使わない。
決め手が 6 つの外にある決定もある。T3 の「アプリ内に CSRF の方式が 2 つ並ぶのを避ける」、T11 の「片方だけ直すと食い違う形になっている」、T1 の「作り直しの対象は A と B に限られる」がそれである。決め手は決定ごとに本文に書いてあるので、この 6 つから逆に引かないこと。
決定の一覧
T1 認証ブランチの統合は、この見直しを待たずに完了させる
この決定は既に実行されている。統合のマージコミットは main の履歴に入っている(数え方: git merge-base --is-ancestor 9911dd9 HEAD が成功する)。ここに残すのは、統合とこの見直しの前後関係をどう決めたかの記録である。
採ったもの: 統合を予定どおり完了させ、技術選定の変更は後続の変更として当てる。
退けた候補: 統合を止めて技術選定をやり直してからマージする。止めると 2 つのブランチが分岐したまま残り、どちらに変更を当てるかという問題が戻る。「どうせ作り直すなら統合は無駄」という理屈は、C に分類したドメインロジックと D のアダプタに対して成り立たない。作り直しの対象は A と B に限られる。
T2 認証・セッションを better-auth に寄せる
採ったもの: better-auth 1.7.5 + @better-auth/drizzle-adapter 1.7.5。
要件: DB セッション(サーバー側失効)・Drizzle 公式アダプタ・MySQL/MariaDB・マジックリンク・任意の OAuth2 プロバイダ・CSRF・1 セッションにデュアル・アイデンティティを載せられること、の 7 つ。7 つ目については、better-auth に手段が公式ドキュメントにあるところまでを確かめており、今の要件を満たしきるかは移行の設計で確かめる(下の「織り込むこと」の 3)。
この 7 つを揃って満たしたのは better-auth だけだった。@auth/core は 7 つ目を条件付きと評価したうえで残りを満たしたが、上流の事情で退けた。
退けた候補と理由
nuxt-auth-utils: Nuxt 公式エコシステムだが、README 自身が「secured & sealed cookies sessions」と掲げ、セッションデータを暗号化してクッキーに保存すること、h3 のuseSessionの既定をruntimeConfig.sessionで与えることを書いている。サーバー側のストアを持たないので、セッションをサーバー側で失効させる要件を満たさない。毎リクエスト DB を引く形に足せるが、それは今の自作と同じものを自分で書くことになる。@auth/core(Auth.js): 公式サイト authjs.dev が「The Auth.js project is now part of Better Auth.」と掲示している(2026-09-18 に参照)。新たに採ると、いずれbetter-authへの移行費用を背負うことが確定している。@sidebase/nuxt-auth: README が挙げる provider はauthjsとlocalの 2 つで、localは外部バックエンドの credential flow 向けなのでこの要件に当たらない。要件に当たるのはauthjsprovider だけで、それは Auth.js を包むものなので、上流の事情をそのまま引き継いだうえで層が 1 つ増える。@openauthjs/openauth: Hono 前提で Nuxt/Nitro の h3 ハンドラとは別系統。設計が「独立した認可サーバーを立ててアプリはそのクライアントになる」もので、今の構成と違う。lucia: npm で deprecated(2026-09-18 にnpm view lucia deprecatedで確認。「This package has been deprecated.」を返す)。関連するアダプタ群も同様。- 自作を続ける: 原則に反するうえ、下に挙げる S1・S2 を自分で直し続けることになる。
織り込むこと
- TIMESTAMP 列の自動属性(条件付き)。上流の issue https://github.com/better-auth/better-auth/issues/9170 (「Sessions deleting themselves after upgrading to 1.5.0 - ON UPDATE Bug (SOLVED)」、2026-05-06 に close。2026-09-19 に参照)に、生成された DDL をそのまま流したところ
expiresAtにON UPDATE CURRENT_TIMESTAMPが付き、セッションが即時失効したという報告がある。ただしこれは無条件の挙動ではない。同じ issue の報告者自身がexplicit_defaults_for_timestampを 1 にするとこの挙動が起きないと書いており、MariaDB の公式ドキュメントはこの変数の既定値を MariaDB 10.10 以降は ON、10.9 以前は OFF としている(2026-09-19 に参照)。このリポジトリのdocker-compose.ymlはimage: mariadb:11なので、開発環境では既定で ON であり、この自動付与は起きない。本番のデプロイ先の版とこの変数の設定はこのリポジトリからは確かめられないので、移行の前に確かめる。確かめるには、デプロイ先でSELECT @@global.explicit_defaults_for_timestamp;とSELECT VERSION();を実行した結果が要る。 better-authが守るのは自分のハンドラ配下だけである。oRPC エンドポイントの CSRF とレート制限は別に要る(T3・T5)。- 1 セッションに traQ 会員 ID と課金ユーザー ID の 2 つ(デュアル・アイデンティティ)を載せる手段(
session.additionalFieldsとcustomSessionプラグイン)が公式ドキュメントにあることは確かめた。手段があることまでしか確かめていないので、今の要件を満たしきるかは移行の設計で確かめる。 - NeoShowcase の forward-auth(
X-Forwarded-Userを信頼する経路)と、HttpOnly のプロキシクッキーをフルページ遷移でしか落とせないというlogoutの事情は、better-authの外に残る自作になる見込みである。移行の設計で最初に詰める。
移行の設計で確かめる事項: Drizzle の mysql2 ドライバが MariaDB を公式に対応と述べているか、既存セッションを引き継げるか(引き継ぐか一度全失効させるかは突き合わせの結果で決める)、traQ の OAuth2 仕様(discovery の有無、userinfo のレスポンスの項目)、そして版の適合である。better-auth 1.7.5 の peerDependencies は drizzle-orm を ^0.45.2 || >=1.0.0-rc.1 <2.0.0 に置いているが、packages/api/package.json の宣言は ^0.45.0 で 0.45.2 未満に解決されうる(packages/db/package.json は ^0.45.2)。今の作業ツリーでは問題にならないが、宣言を揃えるかどうかを決める(数え方: npm view better-auth peerDependencies --json と grep -n 'drizzle-orm' packages/api/package.json packages/db/package.json)。
T3 oRPC 側の CSRF は better-auth の方式に合わせる
採ったもの: better-auth と同じ Origin 検証+Fetch Metadata に揃える。ただし T2 の移行設計の中で確定する。
退けた候補と理由
nuxt-csurf: クライアント側ラッパも提供し、単体では有力である。それでも退けるのは、アプリ内に CSRF の方式が 2 つ並ぶのを避けるためである。ただし移行設計で「better-authの方式を oRPC のエンドポイントに当てる手段が無い」と判明した場合は、この候補に戻す。nuxt-security: CSRF についてはnuxt-csurfを自身の依存として取り込んでいるだけなので、CSRF だけが目的なら依存が浅い側のほうがよい。csrf-csrf/csrf-sync: API が Express のミドルウェアの形で定義されており、h3/Nitro のH3Eventにそのままは載らない。型定義がexpressからRequest・Response・NextFunctionを import している。退ける理由は型と API の形であって、依存の解決ではない。
T4 メール送信を nodemailer に置き換える
採ったもの: nodemailer 10.0.10。
理由: 今の自作は Mailer インターフェース+ログ用実装+SendGrid v3 を素の fetch で叩く実装+ドライバ選択という、transport 抽象そのものである。nodemailer はその抽象をライブラリ側に持ち、jsonTransport/streamTransport がテスト用の実装に相当する。このパッケージ自身が持つ依存は 0 である。
退けた候補と理由: @upyo/core は要件に最も正確に当たるが 0.x で、上流の開発が止まったときの費用が明確に高い。emailjs は SMTP 専用でプロバイダ非依存の抽象ではない。@sendgrid/mail と resend はプロバイダ固定。
注意: better-auth の magicLink プラグインは sendMagicLink のコールバックで送信手段を呼ぶ形なので、T2 と競合しない。
T5 レート制限は rate-limiter-flexible を採る
採ったもの: rate-limiter-flexible 11.2.1。
理由: #15 が求めているのは oRPC のエンドポイント全般を塞ぐことで、better-auth 内蔵が守るのは better-auth のハンドラ配下だけなので要件を満たさない。rate-limiter-flexible はフレームワーク非依存で任意の h3 ミドルウェアから呼べ、mysql2 のプールをそのまま渡せるので、既に使っている MariaDB を保存先にして複数プロセスで共有できる。新しいインフラは増えない。このパッケージ自身が持つ本番の依存は 0 である。
ただし DB の表が 1 つ増える。rate-limiter-flexible 11.2.1 の lib/RateLimiterMySQL.js を読むと、コンストラクタが opts.tableCreated を見て、真でなければ _createDbAndTable() を呼び、CREATE DATABASE IF NOT EXISTS と CREATE TABLE IF NOT EXISTS を発行する(2026-09-19 に jsDelivr の CDN から同版の配布物を取得して確認)。したがって Drizzle の migration の外で DDL が走るか、同じ表を作る migration を 1 つ足して tableCreated: true を渡すかのどちらかになる。移行の費用として見ると DB スキーマの変更に当たるので、採用の際にどちらにするかを決める。
退けた候補と理由
nuxt-securityの rateLimiter: 実装がuseStorageに対してgetItem→ 判定 →setItemという非原子的な read-modify-write になっており、複数プロセス・同時リクエストで上限を超えて通る。これは上流リポジトリの実装を読んで観測したことで、公式ドキュメントに書かれた仕様ではない。観測したのは 2026-09-18 で、そのときnpm view nuxt-security versionが返した版は 2.6.0 である。公開パッケージの側で同じ実装を読み直すときのパスはdist/runtime/server/middleware/rateLimiter.jsである。@unkey/ratelimit: 外部クラウドサービスへの依存を持ち込む。部内システムに外部 SaaS を足すかは方針の決定で、解かない範囲に当たる。express-rate-limit:peerDependenciesにexpressを宣言しているので h3/Nitro では使えない(数え方:npm view express-rate-limit peerDependencies --json)。better-auth内蔵: 上記のとおり範囲が足りない。ただし認証エンドポイントに限れば併用してよい。設定だけで有効になるので、T2 を採るなら既定のまま使う。
T6 外部 HTTP のリトライ・タイムアウトは ofetch に寄せる
採ったもの: ofetch 1.5.1。
理由: retry・retryDelay・retryStatusCodes・timeout・インターセプタ・create() による既定値付きインスタンス・FetchError を持ち、今は 1 件も無いタイムアウトとリトライ(S3)をここで足せる。推移依存として既に作業ツリーに入っている。
S3 の数え方: git grep -cE 'AbortSignal|maxRetries|retryDelay|backoff|timeout' -- packages/api/src apps/web/server apps/web/app ':!*.test.ts' が 1 件も返さない。-E を付けているので選択子は素の | で書く。\| と書くとリテラルの縦棒になり、主張が成り立たなくても必ず 0 件を返す。指定した 3 つのパスがいずれも実在することを別に確かめること。git grep は一致しないパス指定を黙って無視するので、0 件という結果だけでは走査できている証拠にならない。対象になる素の fetch は packages/api/src/auth/mailer.ts・packages/api/src/auth/traq.ts(2 箇所)・packages/api/src/jomon/http.ts である(数え方: git grep -n 'await fetch(' -- packages/api/src apps/web/server ':!*.test.ts')。
Stripe はこの数えの対象外である。SDK 自身が既定を持つ。この作業ツリーに入っている stripe 22.4.0 の cjs/stripe.core.js は DEFAULT_TIMEOUT = 80000(ミリ秒)を持ち、maxNetworkRetries は validateInteger の第 3 引数として既定 2 を与えている(2026-09-19 に、node_modules/.pnpm/stripe@*/node_modules/stripe/cjs/ の stripe.core.js と utils.js を読んで確認)。packages/api/src/stripe/client.ts は new Stripe(secretKey, { apiVersion }) としか書いておらず、この 2 つを上書きしていないので、Stripe の呼び出しには時間による打ち切りもリトライも既定で効く。
依存の宣言が 1 つ要る。pnpm は依存をワークスペース単位で隔離するので、依存ツリーに入っていることと、あるワークスペースから import できることは別である。packages/api/src を基点にしても apps/web/server を基点にしても require.resolve('ofetch', ...) は MODULE_NOT_FOUND を返す(2026-09-18 に確認)。したがって packages/api/package.json の dependencies に ofetch を足す必要がある。増えるのは宣言 1 つで、推移依存は既に作業ツリーにあるので新しくダウンロードされるパッケージは無い。これは T7 が zod について述べているのと同じ論点である。ky も同じく宣言が要り、got・axios はさらに重く、p-retry は ofetch のリトライと重複するので、この宣言 1 つは候補の順位を変えない。
適用範囲: packages/api/src/jomon/http.ts の汎用部分、auth/mailer.ts の SendGrid 呼び出し(T4 で消える)、auth/traq.ts の 2 つの fetch(T2 で移る見込み)。ofetch v2 は alpha なので採らない。
T7 設定の読み出しは自作を続ける
採ったもの: 自作継続。ただし zod による検証への置き換えは T2 の移行と同時に再検討する。
理由: @t3-oss/env-nuxt が検証するのは process.env であり、今のコードが検証している useRuntimeConfig() の返り値ではない。対象が違うので、採ると 2 系統を持つことになる。envalid は独自バリデータで zod ベースでない。znv は最終公開が 2025-03-24 で更新が止まっており(数え方: npm view znv time.modified。2026-09-19 に実行)、Nuxt 統合も無い。要件への適合が最も高いのは nuxt-safe-runtime-config だが、0.x であり、この規模に対して上流の開発が止まったときの費用が見合わない。
原則に反することの明示: これは「自作を減らす」原則に対する例外である。採らない根拠は「要件に合うものが無い」ではなく「合うものはあるが、この規模に対して上流の開発が止まったときの費用が見合わない」ことなので、判断が変わりうる。nuxt-safe-runtime-config の採用実績が増えたら見直す。
この層を zod に置き換える場合: zod は packages/api の依存にはあるが apps/web/package.json には無く、依存の宣言はワークスペース単位なので、apps/web の側でも宣言する必要がある(数え方: grep -c 'zod' apps/web/package.json)。T10 が同じ追加を別の用途で数えているので、両方を採る場合でも足す依存は 1 つである。
T8 一覧の表示を @nuxt/ui の UTable に寄せる(宣言する依存は増えない)
採ったもの: payments.vue と payouts.vue の手書きの <table> を UTable に寄せる。UTable の土台の @tanstack/vue-table は既に @nuxt/ui 自身が持つ依存に入っているので、このリポジトリが宣言する依存は 1 つも増えない。
理由: 行数を理由にしない。payments.vue の 2 つのテーブル(請求書タブ・決済セッションタブ)は、<table> の行から </table> の行までがどちらも 86 行で、行頭の空白を落として比較すると相違は 3 行だけである(2026-09-18 に main、HEAD=74ee63c で測定。数え方: <table の行から </table> の行までの 2 つの範囲をそれぞれ別のファイルへ書き出し、sed 's/^[[:space:]]*//' で行頭の空白を落としてから diff を取り、^[<>] に一致する行を数えると 6 行、すなわち 3 行の置き換えになる)。つまり payments.vue の削減は、局所コンポーネント 1 つに切り出すだけで同等に得られる。payouts.vue の表は <table> の行から </table> の行までが 257 行だが(同じ日・同じ作業ツリーで測定。数え方: awk '/<table/{f=1} f{c++} /<\/table>/{f=0} END{print c}' apps/web/app/pages/payouts.vue)、移行後の行数は測っていないので、減るとも横ばいとも書かない。UTable を選ぶ理由は行数ではなく、empty/loading/横スクロール/セル描画の分岐を自分で持たなくなることである。
候補を比べていない理由: この決定は既にこのリポジトリが宣言している @nuxt/ui の構成要素を使うもので、新しいパッケージを入れる決定ではない。上に挙げた対案(局所コンポーネント 1 つに切り出す)は行数では同等だが、empty/loading/横スクロール/セル描画の分岐を自分で持つことになるので採らない。
注意: 既定の td クラスが whitespace-nowrap を含むので、複数行セルのある列は meta.class.td で上書きしないと表示が変わる。virtualize は v4 の props で現在の版には無い。
T9 一覧の取得・キャッシュ・無効化・リトライ・追記読み込みを TanStack Query に寄せる(宣言する依存が 2 つ増える)
採ったもの: @tanstack/vue-query + @orpc/tanstack-query。対象は payouts.vue の 1 つと payments.vue の 2 つの、計 3 つの一覧である。
比べたこと: 8 つの軸(変更のあとの再取得・リトライ・キャッシュと鮮度・重複排除・追記読み込み・古い応答の上書き防止・SSR・依存と移行)で比べた。useAsyncData を選ぶと、リトライは自作するか諦めるかになり、カーソルの累積は今の手書きがそのまま残り、古い応答の上書きを防ぐ単調増加トークン 3 つのうち 1 つしか消えず、鮮度の方針も自作になる。上流の開発が止まったときの費用は、どちらも deprecated でも統合済みでもないので両者を分けない。移行の費用も、DB スキーマの変更・既存セッションの失効・既存テストの作り直しのどれにも当たらない。
決め手: useAsyncData を選ぶと 8 つの軸のうち 4 つで自作が残り、TanStack Query はその 4 つをすべて設定で表せることである。
代償: useAsyncData は Nuxt 組み込みなのでこのリポジトリが宣言する依存を増やさないのに対し、TanStack Query は 2 つ増やす。内訳は下の「費用」に書く。
費用: このリポジトリが宣言する依存が 2 つ増え、クライアントバンドルに載る(増分は測っていない)。QueryClient を作る Nuxt プラグインが 1 つ要る。@orpc/tanstack-query の peer は @orpc/client の版に完全一致で固定されているので、@orpc/* を上げるたびに同じ版へ上げる拘束が続く。
この決定に含めない範囲: auth.me の useAsyncData('auth-me') はそのまま残す。SSR で取って payload に載せる形が既に得られており、カーソルもリトライも要らない。
T10 フォームの検証は、契約の入力スキーマに名前を付けて共有する
採ったもの: packages/api-contract 側で入力スキーマに名前を付けて export し、oc.input() で使い、index.ts から再 export する。
背景: UForm は 3 箇所で使われているが :schema は渡しておらず、クライアント側にフィールド単位の検証が無い。つまり自作の検証があるのではなく、検証が存在しない。契約オブジェクトから実行時に取り出す経路はあるが、~ 接頭辞は oRPC の内部 API の印で公開 export に取り出す関数が無いため、依拠すべきでない。
支払う費用: (1) スキーマを値として import した瞬間にクライアントバンドルに zod が入り、apps/web が zod を宣言する必要も生じる。(2) 契約にメッセージ引数が無いのでエラー文言が英語になる。日本語にすると契約が UI の文言を持つことになる。(3) 行数は増える。得られるのは削減ではなくフィールド単位のエラー表示である。
候補を比べていない理由: 外部のフォーム検証ライブラリは候補にしていない。UForm の schema props が Standard Schema を受け、契約が既に zod で書かれているので、新しいパッケージを入れずに検証を足せるためである。この判断は @nuxt/ui の UForm を使い続けることを前提にしている。その前提を変えるなら候補の比較からやり直す。
原則との関係: これは「自作を減らす」ではなく「既にあるライブラリを使って、今は存在しない検証を足す」変更である。原則の直接の適用ではないので、採否はこの 3 つの費用を見て別に決める。
T11 重複した自作を 1 箇所に寄せる(新しい依存なし)
採ったもの: errorMessageFor が 4 ファイルに別々に定義されているものを 1 つに、CSRF ヘルパーが 2 ファイルに重複しているものを 1 つに、document.cookie の手書きパースを Nuxt 組み込みの useCookie に、確認ダイアログの自作状態を @nuxt/ui の useOverlay に、設定層の num() の重複を 1 つに寄せる。
理由: 片方だけ直すと食い違う形になっているものが 2 つある(CSRF ヘルパーは同時実行を 1 本にまとめる処理が片方にしかない。isDuplicateKeyError は 3 実装で挙動が 3 通りに分かれる)ので、寄せる価値は行数以上にある。ただし isDuplicateKeyError の統合は挙動の変更を伴うので T11 の範囲から外し、#60 で扱う。
useCookie への置き換えの成立条件: document.cookie の手書きパースは apps/web/app/composables/useCsrf.ts と apps/web/app/plugins/orpc.ts の 2 箇所にあり、どちらも「クッキーが無ければ /csrf を fetch し、その直後に読み直す」形である。Nuxt の公式ドキュメント(https://nuxt.com/docs/4.x/api/utils/refresh-cookie 、2026-09-18 に参照)は、ブラウザーで新しいクッキーの値が設定されたことが分かっている場合に useCookie の ref を更新する手段として refreshCookie(name) を挙げ、Nuxt v3.12.0 以降は実験的な cookieStore オプションが既定で有効で、ブラウザーでクッキーが変わると useCookie の値を自動で更新すると書いている。したがって置き換えるなら、この読み直しを refreshCookie の明示呼び出しで賄うか、実験的と明記されている cookieStore に依拠するかを決める必要がある。これは決定の費用に関わる。
候補を比べていない理由: 寄せ先はいずれも既にこのリポジトリに入っているもの(Nuxt 組み込みの useCookie、@nuxt/ui の useOverlay)か、同じ実装を 1 つにまとめることで、新しいパッケージを入れる決定ではない。
T12 自作を続けると決めるもの
要件に合うものが無いか、置き換えの費用が利得を上回るもの。各項は置き換え先を探した結果で、候補が見つからなかったものにはその旨を、見つかったものには候補名と退けた理由を書いている。
payouts/・ledger/・billing/。会計のドメインロジックそのもので、対応するライブラリが存在しない。jomon/のドメイン部分。部内システム固有の契約。汎用部分だけ T6 でofetchに寄せる。stripe/。既に Stripe SDK を呼ぶだけの薄いアダプタ。- Stripe webhook の冪等性。イベント id を DB の unique 制約で重複排除する実装で、DB のトランザクション境界と結びついている。oRPC/Drizzle/MariaDB に載る汎用の冪等性ライブラリは見つからなかった。探したのは npm レジストリ(名指しした候補と全文検索で浮上したもの)と、浮上した候補の公式ドキュメントである。Stripe の webhook に限れば Stripe 公式が推奨する方式(イベント id の記録)で足りており、oRPC/Drizzle を前提にした候補が浮上しなかったため、それ以上は深掘りしていない。
packages/api/src/auth/crypto.ts。export はnormalizeEmail・deriveMailHash・sha256hex・generateToken・safeEqualの 5 つである。normalizeEmailはtrim()とtoLowerCase()だけでnode:cryptoを呼ばない。sha256hexとgenerateTokenはcreateHash/randomBytesをそのまま呼ぶ。deriveMailHashはcreateHmacに正規化と秘密鍵の必須化を足したもので、safeEqualは両方の入力を SHA-256 に通してからtimingSafeEqualに渡す形になっており、この 2 つは呼び出しの上に設計判断を載せている(safeEqualの型と名前については #69)。いずれも自前の暗号プリミティブは書いておらず、標準ライブラリを使っている状態なので、置き換えても減らない。T2 で移る範囲と重なるものは自然に消える。useFormatters.ts。Intl.NumberFormat/Intl.DateTimeFormatを直接使っており、ECMA-402 の標準 API を使っている状態である。@vueuse/coreのuseDateFormatはIntlではない独自トークン書式で通貨を持たないので、置き換えると機能が劣る。useListPagination.tsの追記読み込み。UPaginationは採らない。契約が公開するのは前方カーソルだけで、UPaginationはページ番号(offset)前提の UI なので、契約が表現できない「前に戻る」を要求してしまう。useSanitizeRedirect.ts。サーバー側の実装の意図的な再実装で、「クライアントはサーバーの型だけを import し実装は import しない」という方針の産物である。ただし実装が 2 本あり、片方だけが更新されると判定が分岐しうるので、S6 として残す。
T13 E2E スクリプトを @playwright/test に寄せる
採ったもの: @playwright/test 1.63.0。移行先は scripts/e2e/ の中とし、ディレクトリの外へは出さない。着手は T2 の移行設計より後にする。harness.mjs のクッキー注入が認証の仕組みに依存しているため、T2 で認証が入れ替わると移行先の形も変わるからである。
理由: scripts/e2e/ の run_*.mjs 7 本(数え方: ls scripts/e2e/run_*.mjs。ディレクトリ全体の .mjs は harness.mjs と pay.mjs を含めて 9 本である)が、検証項目の登録・合否の記録・結果の印字という、テストランナーの機能そのものを同じ形で重複して自作している。Playwright の公式ドキュメントの Library のページ(https://playwright.dev/docs/library 、2026-09-19 に参照)は "Playwright Library" と "Playwright Test" を別のものとして説明し、後者が Parallelization・Web-First Assertions・Reporting・Retries・Easily Enabled Tracing・and more… を持つとしている。ただしこのページに playwright-core という語は現れないので(数え方: curl -s https://playwright.dev/docs/library | grep -c 'playwright-core' が 0)、この区別をパッケージ名に当てはめているのはこの見直しの側である。依存の側は独立に確かめた。@playwright/test 自身が持つ依存は playwright 1 つだけで、playwright 自身が持つ依存も playwright-core 1 つだけなので、今の playwright-core を入れ替える形になり、このリポジトリが宣言する依存の数は変わらない。
退けた候補と理由: 現状維持は、合否が終了コードに出ないのでこのまま CI に載せても失敗を検出できない。削除は、HANDOVER.md が BLOCKED として残した E2E を後から実施する手段が失われるため採らない。この理由は今も成り立つ。
織り込むこと: scripts/e2e/ の扱いを決めている宣言が 4 箇所ある。4 つは種類が違う。うち 3 つはパスを名前で書いているので git grep -n 'scripts/e2e' -- . ':!scripts/e2e' が拾う(3 ファイル・5 行で、うち 2 行は説明のコメントである)。4 つ目はパス名を含まないので、この数え方では出ない。
.gitignoreのscripts/e2e/shots/— スクリーンショットの出力先を git の追跡から外す宣言。出力先の置き場所を変えるなら書き換えが要る。eslint.config.mjsのignoresの'scripts/e2e/**'— lint の対象外にする宣言。パターンは拡張子を問わず配下に当たるので、中身が@playwright/testになってもパターン自体は書き換えを要しない。書き換えになるのはすぐ上のコメントで、そこが掛かる指摘の数え方をnpx eslint 'scripts/e2e/**/*.mjs' --no-ignoreと書いているため、ファイルの拡張子が変われば古くなる。knip.jsoncのworkspaces["."].entryの"scripts/e2e/*.mjs"— 対象外にする宣言ではなく entry point の宣言である。同ファイルのコメント自身が「entry として宣言しないと knip が未使用ファイルとして挙げる」と書いている。*.mjsというパターンなので、ファイルの拡張子が変われば書き換えが要る。なお Knip のファイル単位の除外はこのファイルのトップレベルの"ignore": ["**/.*/**"]で、これはツールが生成するドットフォルダを対象にしたものでscripts/e2eには掛かっていない。scripts/e2eについて除外の宣言は無い。- リポジトリ直下の
package.jsonのplaywright-core— スクリプトが使う依存の宣言。@playwright/testに入れ替える。
@playwright/test に移すと、少なくとも 2・3・4 の内容の書き換えが要る。1 は出力先を変えなければそのままである。宣言を置くファイルがこの 4 つで足りるかは、移行後の形が定まっていないので分からない。設定ファイル(playwright.config.ts 等)を置くかどうかもそこで決まる。harness.mjs の認証を @playwright/test の storageState に載せられるかも確かめていない。
実測で見つかった問題の行き先
技術選定の決定ではないが、この見直しの過程で実測して確認したものが 6 件ある。
次の表は索引である。どこを見ればよいかだけを示し、判断そのものは指し先の節に書く。同じ判断を 2 箇所に写さないためである。
| 内容 | 扱う節・issue | |
|---|---|---|
| S1 | 権限が広がるときにセッション ID が入れ替わらず、古いセッションも無効にならない | T2 / #73 |
| S2 | CSRF がクッキーとヘッダーの一致だけを見ており、セッションにも利用者にも紐付いていない | T2・T3 / #74 |
| S3 | 外部 HTTP にタイムアウトもリトライも無い(Jomon・SendGrid・traQ への素の fetch。Stripe は SDK の既定が効く) |
T6 |
| S4 | CI に DB のサービスが無く、DB を要求するテストが本体を実行せず「通った」と報告される | #58 |
| S5 | isDuplicateKeyError が 3 実装・3 挙動で並存している |
T11 / #60 |
| S6 | sanitizeRedirect がサーバーとクライアントに別々に実装されている |
T12 の 8 / #75 |
S4 と S5 は既存の issue に対応するので新しく作らない。S4 が #58、S5 が #60 で、どちらも本文が依拠している事実が今も成り立つことを確かめた。#58 は、3 つのテストファイルが let available で分岐し DB に接続できないときに本体を実行せず return することに依拠している(数え方: git grep -ln 'let available' -- packages apps が packages/api/src/auth/identity.test.ts・packages/api/src/ledger/ledger.test.ts・packages/api/src/payouts/execute.test.ts の 3 ファイルを返す)。#60 は isDuplicateKeyError が 3 実装あり、そのうち 2 つが drizzle 経由の重複キーを検出できないことに依拠している。実装が 3 つあることは git grep -c 'function isDuplicateKeyError' -- packages apps が 3 ファイルで 1 件ずつ一致することで確かめた。3 つの答えが 3 通りに分かれることも 2026-09-19 に測った。測定の内容は #60 の本文にあるので、ここには写さない。したがって新しく作るのは S1・S2・S6 の 3 件である。
スコープ外/関連
- S1 と S2(詳細は #73・#74)は、T2 を採れば移行の中で解消する見込みである。T2 を採らない場合は個別に直すことになる。この issue は、個別に直さず基盤の作り直しの中で解消するという判断の根拠を記録している。
- S3 は T6 で解消する。S4 はこの見直しの決定のどれにも含まれない。S5 は挙動の変更を伴うので T11 の範囲から外した。
- S6(詳細は #75)は、T12 の 8 のとおり自作のまま残す決定なので、T2 を採っても解消しない。個別に直すかどうかは #75 の側で決める。
- #16(認証サブシステムの実装)は、T2 が入れ替えの対象とする実装そのものを指している。T2 を採る場合、#16 の「やること」のうち、ワンタイムトークンの発行・検証・消費、クッキーの発行と検証、状態を変えるリクエストの CSRF 検証は
better-authの設定に置き換わる。メール送信基盤は T4 の範囲で、nodemailerに置き換わる。traQ ID をX-Forwarded-Userから取る forward-auth だけは T2 の外に残る見込みである。 - #15(認可・レート制限)の「やること」のうち、
/rpc手前でのレート制限は T5 がrate-limiter-flexibleを指定する。認可付きの base procedure はpackages/api/src/orpc.tsにuserProc(認証必須)とadminProc(管理者必須)として既にある。#15 に残っているのは、オブジェクト単位の認可、list と create の customer をセッションから導出すること、冪等キーのサーバー側名前空間化などで、T2 も T5 もそのどれも対象にしていない。 - #58・#60 は上の表のとおり S4・S5 に対応する。この見直しでは新しく作らない。
- #69(
safeEqualの型と名前)は、T2 がpackages/api/src/auth/を入れ替える範囲に含まれる。T2 を採るとsafeEqualの呼び出し側が消えるかは移行の設計で決まるので、この issue では扱わない。 - 有償の認証 SaaS の採否と、JS 以外の実装は、上の「解かない範囲」のとおりこの見直しでは候補として調べていない。必要になった場合は別に決める。
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read the T1–T13 decisions first, then inspect the mentioned paths: packages/api/src/auth/, apps/web/server/, apps/web/app, package manifests, and docker-compose.yml. Compare each decision with the current code and dependencies; this issue explicitly does not define implementation tracking or a single completion condition, so the finished work must be established elsewhere.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- mariadb, nuxt, tailwind, typescript
- Domain
- authentication, backend, databases, frontend, tooling
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100