Skip to content

feat: auth-collection を統合し、8 capability を契約ファーストに揃える - #68

Merged
reiroop merged 31 commits into
mainfrom
feat/merge-auth-collection
Sep 18, 2026
Merged

reiroop merged 31 commits into
mainfrom
feat/merge-auth-collection

Conversation

@reiroop

@reiroop reiroop commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

何をしたか

origin/claude/checkin-auth-collection を main へマージし、デフォルトブランチを開発の基点にした。両側の実装を残したまま、公開面を packages/api-contract の契約に一本化した。契約は 8 つの capability(prices・products・invoices・checkout・auth・membership・payments・payouts)を宣言し、packages/api の appRouter がそれを実装する。

差分の規模は 258 ファイル、26105 行の追加と 131 行の削除である(2026-09-18 に git diff origin/main...HEAD --shortstat で測定。コミットは 31 件で、git rev-list --count origin/main..HEAD で数えた)。

採った決定

  • D1 マージで統合する(force push で置き換えない)。両側の履歴を残す。
  • D2 auth 側 13 プロシージャの契約化を統合の範囲に含める。health.check は契約に入れず削除する(main が feat(api): Stripe(価格・商品・請求・Checkout)を oRPC 契約ファーストで公開する #38 で契約と実装の両方から削除済み)。
  • D3 main 側の Stripe 4 機能(prices・products・invoices・checkout)を残す。
  • D4 pub を implement(contract).$context<Context>() に一本化し、appRouter は pub.router({ 8 キー }) で組み立てる。
  • D5 Stripe SDK への到達経路を StripeClient に閉じる。
  • D6 apiVersion の明示的な指定を保つ。理由は後述の訂正のとおりに書き直した。
  • D7 設定は main を基礎に auth 側の追加を合併する。
  • D8 変更系ガード(mutationsEnabled / assertMutationsEnabled)を残す。main 側 4 機能は認可を持たないままなので、認可・レート制限を導入し、無認証アクセスを塞ぐ #15 までこのガードで塞ぐ。
  • D9 依存は main 側の新しい版を採る(stripe 18→22、vitest 2→4、ESLint 9→10、TypeScript 5.9→6、pnpm 10→11、zod 3→4、@types/node 22→24)。zod は packages/api の宣言も後から ^4.4.3 に揃えたので、リポジトリが解決する zod は 4.4.3 だけである(pnpm-lock.yaml に zod 4.4.3 以外の項目が無いことを 2026-09-18 に grep -n 'zod@' pnpm-lock.yaml で確かめた)。
  • D10 2 つの PR に分ける(refactor(api): Stripe SDK への到達経路を Context の stripe.sdk に統一する #52 が Stripe SDK の到達経路、この PR がマージ本体)。
  • D11 同じ Stripe Invoice に対する 2 つの公開範囲を許す(invoices.list は customer が ID のみ、payments.listInvoices は顧客名付き)。
  • D12 一覧の封筒を { data, nextCursor } に統一し、hasMore を返さない。
  • D13 auth 由来の scripts/e2e/ を残す。

退けた候補と理由

  • 契約化を後続 issue へ回す案。契約に無いキーは RPC の経路表に載らず matched: false になるため、型エラーにも実行時エラーにもならないまま応答しない死んだコードが残る。
  • PR を 4 つに分ける案。Router<T> が契約の全キーを要求するので、契約定義だけを切り出した PR が型として成立しない。
  • 依存を auth 側の版に揃える案。main の CI と dependabot の設定が新しい版を前提にしている。

契約に載せなかったもの

  • payouts.processApproved の listError。ドメイン層の要約型は、Jomon の一覧取得そのものが失敗したときの理由の文字列を listError に持つが、契約の processApprovedView には載せていない。Jomon の一覧・HTTP・zod の失敗の内容が公開契約を通って外へ出る経路を閉じるためである。運用中の原因調査はサーバーのログで行う。契約が載せるのは 9 個の件数と 2 つの参照の配列だけで、errors に入るのは例外で隔離された項目の jomon_ref であって例外の内容ではない。
  • 一覧の hasMore。packages/api-contract/src/params.ts の listEnvelope は { data, nextCursor } だけを返す。続きがあるかどうかは nextCursor !== null から導けるので、同じ情報を 2 つのフィールドで持たない。導出は apps/web/app/composables/useListPagination.ts が担う。

入力の受理範囲が狭まったこと

payments の 2 つの一覧(listInvoices と listCheckoutSessions)の入力を、packages/api-contract/src/params.ts の pagination に揃えた。その結果、受理範囲は次の規則で狭まった。

  • limit は整数の 1 以上 100 以下だけを受理する(z.number().int().min(1).max(100).optional())。
  • startingAfter は長さ 1 以上の文字列だけを受理する(z.string().min(1).optional())。

packages/api-contract/src/payments.ts の paymentsContract は、listInvoices と listCheckoutSessions のどちらも .input() の中で同じ pagination を展開している。2 つの一覧で展開しているものが同一なので、この狭まりは両方に同じく効く。

件数では書けない。狭まった値の集合(整数でない数、0 以下、100 超、空文字列)は有限ではないためである。

依存の版上げへの適合の内訳

適合が必要になった指摘は、由来が 2 つに分かれる。

main 側の設定に由来するもの(D7)。auth 由来のコードが、main の設定が有効にしている検査に適合していなかったものである。

設定 位置 適合の内容
noPropertyAccessFromIndexSignature tsconfig.base.json インデックスシグネチャ由来のプロパティをブラケット表記法で参照する
strictTemplates apps/web/nuxt.config.ts コンポーネントが prop として宣言していない属性を v-bind のオブジェクト経由に書き換える
厳格ルールと型考慮ルール eslint.config.mjs 各ルールの指摘に合わせて書き換える

依存のメジャー更新に由来するもの(D9)。1 件だけである。stripe 18→22 の範囲(SDK 20.1.0)で InvoiceLineItem.pricing.price_details.price の型が string から展開可能な string | Price に広がったもので、packages/api/src/payments/normalize.ts の invoiceToRow が、到着した形のどちらからでも id を読む形になっている。請求書の一覧を取るアダプタ(packages/api/src/stripe/listing.ts)が expand に渡すのは data.customer だけなので、このフィールドは実行時には文字列である。

合計の件数について。統合時に記録した合計は 235 件で、うち 234 件が設定由来、1 件が依存の更新由来だった。この合計はこのブランチの HEAD からは数え直せない。適合を当てる前のツリーはマージコミットの内側にしかなく、独立したコミットとして残っていないためである。数え方は cd packages/api && npx tsc --noEmit 2>&1 | grep -c 'error TS' と npx eslint <対象> で、HEAD ではどちらも指摘が無い(2026-09-18 に pnpm typecheck と pnpm lint が終了コード 0 で終わることを確かめた)。

コミットメッセージの訂正

zod の版上げのコミット(99bad68)の本文に、誤りが 2 件ある。コミットメッセージは書き換えられないので、ここで訂正する。

訂正 1。本文は「z.number().int().positive() が 2**53 を拒否するようになる」「受理・拒否する値の集合は、この 2**53 の 1 件を除いて変わらない」と書いている。これは誤りである。正しくは、Number.MAX_SAFE_INTEGER を超える整数がすべて拒否に変わる。zod 4 の int() は safeint で、安全な整数の範囲を超える整数を 1 つも受理しないためである。同じ段落が根拠に挙げている safeint の規則は上限のない集合を指しているので、根拠と結論が両立していなかった。

2026-09-18 に、zod 3.25.76 と zod 4.4.3 を 1 つのスクリプトから読み込み、z.number().int().positive() に同じ値を通して比べた。1・1000・Number.MAX_SAFE_INTEGER はどちらの版も受理する。2**53・2**53+2・2**60・1e21 はどれも zod 3 が受理し zod 4 が拒否する。2**53 から 2**1023 までの 2 のべき乗 971 個は、971 個すべてが同じ向きに変わる。Infinity・-1・0・1.5・NaN はどちらの版も拒否する。差が出る向きは「より厳しく拒否する側」だけで、zod 3 が拒否して zod 4 が受理する値は測った範囲に 1 つも無かった。zod を読み込む実装は packages/api/src/jomon/http.ts だけで、このスキーマが受けるのは Jomon の金額なので、この拒否は正しい挙動である。

訂正 2。本文は「移設で受理範囲が変わったのは payments の 2 つの一覧の 7 入力だけ」と書いている。これも誤りである。7 という数は、この版上げで削除したテストが 2 つの一覧に非対称な入力集合を与えた結果であって、契約の性質ではない。移設前のスキーマは 2 つの一覧で同一、契約側も同一なので、狭まり方は 2 つで同じである。正しい記述は上の「入力の受理範囲が狭まったこと」の規則である。

apiVersion を明示する理由の訂正

packages/api/src/stripe/client.ts の StripeClient は、SDK を apiVersion を明示して初期化する。main に入っていたコメントは、その理由を「省略するとアカウントの既定の API バージョンに従う」と説明していた。これは誤りなので書き直した。

stripe-node は v12 以降、常に Stripe-Version ヘッダーを送り、アカウントの既定のバージョンを使わせることはできない。apiVersion を指定しない場合は、その SDK のリリースに固定されたバージョンが使われる。出典は次の 2 つである。

  • stripe-node の v12 移行ガイド(https://github.com/stripe/stripe-node/wiki/Migration-guide-for-v12)の Version pinning の Details。「Starting in v12, stripe-node will always send a Stripe-Version header, and it will no longer be possible to ask stripe-node to use your account's default version.」と書いてある。
  • stripe 22.4.0 の README の設定表の apiVersion の行。「Stripe API version to be used. If not set, stripe-node will use the latest version at the time of release.」と書いてある。

実際に成り立つ理由はこうである。apiVersion の型は Stripe.LatestApiVersion で、SDK は出荷する API バージョンごとにこの型を作り直す。したがって stripe を上げるとこのリテラルが型エラーになり、通信の形式が変わることを黙って通さない。省略しても版が固定されないわけではなく、省略すると版の変更が黙って起きるようになる、というのが正しい説明である。この挙動は packages/api/src/stripe/client.test.ts が固定している。

公開してよいと判断した 3 件

auth 由来の文書に、このリポジトリの外を指す参照が残っている。いずれも公開リポジトリに出て問題のない情報で、資格情報・署名付き URL・オブジェクトキー・個人情報を含まない。残すと判断した理由を書き残す。

  • DEPLOY-NEOSHOWCASE.md の個人アカウントのフォーク kaitoyama/Jomon への参照。残す理由は、この手順が対象にしている Jomon が上流ではなくこのフォークだからである。名前を伏せると、手順を実行する人が上流の Jomon を登録し、forward-auth の載っていないビルドをデプロイして、Checkin との連携が成り立たない。公開の GitHub アカウント名とリポジトリ名で、リポジトリの URL の一部として既に公開されている情報である。
  • 同じ文書の local/checkin-dev-env ブランチと、そこにある 2 つの commit への参照。残す理由は、forward-auth と Bearer と Swift フォールバックが載っているのがこのブランチで、修正の内容を辿る先がこの 2 つの commit だからである。このリポジトリからは辿れないが、辿れないことは記録を消す理由にならない。どのブランチをビルドするかは手順の必須の入力である。
  • E2E-SCENARIOS.md の「Jomon v1 ローカルで」という言及。残す理由は、これがシナリオを実施した条件の記述であり、特定の機械に固有の値を含まないからである。どの Jomon の版に対して測ったかが分からないと、検証結果を後から読む人が適用範囲を誤る。

既知の穴

isDuplicateKeyError が 3 つの実装で並存している。共有版の packages/api/src/mysql-result.ts は投げられた値の cause の連鎖を 5 段まで辿るが、packages/api/src/stripe/connect.ts と packages/api/src/webhook/events.ts の 2 つは最上位しか読まない。drizzle は mysql2 のエラーを包み、code と errno を持つものを .cause に置くので、浅い 2 つは重複キーを検出できない。この形は drizzle-orm 0.45.2 と mysql2 3.23.2 を MariaDB 11 に対して実行して測ったもので、測定の内容は共有版の関数のコメントに書いてある。

この統合では 3 つを統一していない。統一は auth 由来のコードの振る舞いの変更になるためである(今まで 500 になっていた経路が、設計どおりの分岐に入るようになる)。

影響は 2 箇所とも競合時の経路に限る。recordStripeEventOnce の呼び出し元は 2 つとも、記録の前に hasProcessedStripeEvent で照会して早期に返るので、通常の再配信は照会で止まる。重複キーの分岐に入るのは、同時に届いた 2 つが両方とも照会を通り抜けたときだけで、そのとき負けた側は例外が外へ出て 500 になり、Stripe の再送で処理される(再送時は照会で弾かれる)。getOrCreateConnectedAccount は、確保の競合に負けたときに分岐へ落ちる代わりに例外が外へ出て、作成済みの Connect アカウントが孤児として残る。

到達しない分岐であることは、両方のファイルに KNOWN GAP のコメントとして書いてある。統一は後続 issue #60 で扱う。

検証

2026-09-18 に、このブランチの HEAD(d8feaca)で 5 つのゲートを実行し、すべて終了コード 0 で終わった。

ゲート コマンド 結果
lint pnpm lint 終了コード 0
未使用の検出 pnpm knip 終了コード 0
型チェック pnpm typecheck 終了コード 0(typecheck を持つ 4 ワークスペース)
テスト pnpm test 終了コード 0(30 ファイル・265 テスト)
ビルド pnpm build 終了コード 0

公開面はテストで固定してある。

  • 契約が RPC の経路表を閉じること。packages/api/src/contract-surface.test.ts の「契約が RPC の公開面を塞ぐ」が、契約に載っているキーの経路が一致すること、契約に無いキーを実装に置いてもその経路が一致しないこと、定義していないキーの経路が一致しないことを見ている。展開して作り直したルーターでは契約に無いキーの経路が一致する、という対照も置いてあり、塞いでいるのが契約であることを示している。
  • 出力から余分なキーが剥がされること。同じファイルの「payouts.processApproved の応答のフィールド」が、ドメイン層の listError が応答に現れないこと、契約に無いフィールドが弾かれずに剥がされること、応答のフィールドの集合が契約で載せると決めた集合と一致することを見ている。「payouts.list の応答の形」は、封筒が data と nextCursor だけを持ち、実装が足した items と hasMore が現れないことを見ている。
  • appRouter のキー集合が契約と一致すること。packages/api/src/router.test.ts が Object.keys(appRouter).sort() と Object.keys(contract).sort() を突き合わせる。期待値に 8 という数を書かず契約のキーそのものと比べているのは、数を書くと capability を足した日にこのテストだけが古くなり、しかも数が合っている限り名前の食い違いを見逃すためである。
  • health を含まないこと。契約は health を宣言していない(稼働確認は公開面の約束の対象ではない、という理由が packages/api-contract/src/router.ts に書いてある)。上のキー集合の一致により、appRouter に health を足せばテストが落ちる。加えて packages/api/src/capability-routers.test.ts の「health.check を移さなかったこと」が、4 つの capability のルーターに /health/check の経路が無いことを直接見ている。

マージの方式

この PR はマージコミットを作成して入れる。スカッシュしてマージしない。

理由は、スカッシュしてマージすると親が 2 つあるマージコミットが潰れて auth ブランチの履歴が main の系譜から消えるためである。D1 が「両側の履歴を残す」ために決めたことが、そこで失われる。

このリポジトリの main の直近 6 件のコミットは、いずれも親が 1 つで、件名が PR 番号で終わっている(2026-09-18 に git log origin/main -6 で件名と親を出して確かめた)。親が 2 つのマージコミットは 1 件も無い。この PR だけ方式が違う。

この統合が作った issue

2026-09-18 に、この統合の過程で気付いたことを issue にした。新規に作成したのは 15 件(#53〜#67)で、これとは別に既存の issue 19 件にコメントを書き足し、そのうち #17 と #37 は close した。数え方は、gh issue list --repo traPtitech/Checkin --state all --limit 200 --json number で列挙した各 issue に対して gh issue view <番号> --repo traPtitech/Checkin --json comments を実行し、2026-09-18 に作成されたコメントを持つものを数える。

新規に作成した 15 件が扱うものは次のとおりである。

🤖 Generated with Claude Code

kaitoyama and others added 30 commits June 19, 2026 23:38
Implements the first two Checkin capabilities, spec-driven via OpenSpec.

add-auth-foundation:
- isct email magic-link sessions: HMAC-SHA256 mail_hash (no plaintext
  email stored), server-side sessions in __Host- cookies, double-submit
  CSRF, Mailer adapter (log/SendGrid).
- traQ OAuth (PKCE) admin login gated by an env traQ-ID allow-list.
- requireUser / requireAdmin authorization helpers on the oRPC Context.

add-membership-collection:
- Stripe adapter layer (lazy client; Customer get-or-create; Invoice
  create -> finalize -> send) isolated from the Stripe-independent
  billing domain (term/price/authorize).
- membership.issueInvoice (self, mail_hash-matched) and
  issueSpecialInvoice (admin-only special price), with Stripe
  idempotency keys and draft cleanup on failure.
- invoice.paid webhook: raw-body signature verify + event-id
  idempotency + accountant Notifier.

DB (Drizzle/MariaDB): users / sessions / email_verifications /
stripe_events, with users.stripe_customer_id. Migrations 0000, 0001.

Tooling: @fission-ai/openspec, zod, vitest, stripe. Both changes were
Codex-reviewed, fixes applied, delta specs synced to openspec/specs/
and archived. Gates green: lint, typecheck, build, test (26). Live
Stripe E2E pending test-mode keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add-payment-listing (OpenSpec): accountant-only, read-only payments
listing backed by Stripe (no DB writes).

- Stripe adapter: listInvoices / listCheckoutSessions (lazy client,
  cursor pagination, customer + line-item price expansion).
- Stripe-independent payments domain: PaymentRow/PaymentPage DTOs,
  invoice/checkout-session normalizers (null-safe), buildDashboardUrl
  (test/live), limit clamp + nextCursor.
- oRPC payments.listInvoices / payments.listCheckoutSessions.

Authorization hardening (Codex review): introduce userProc / adminProc
oRPC middleware so the auth check runs BEFORE input validation (no input
shape leak to unauthenticated callers); migrate membership.* procedures
to them. Checkout-session rows now carry an explicit product/Price
reference (or null) instead of {}.

Codex-reviewed, fixes applied, delta spec synced to openspec/specs/ and
archived. Gates green: lint, typecheck, build, test (48). Live Stripe
E2E pending test-mode keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add-connect-onboarding (OpenSpec): connected-account JIT onboarding,
the prerequisite for payouts. Jomon-independent; actual payouts land in
a later change.

- Stripe Connect adapter: race-safe getOrCreateConnectedAccount
  (Express; fresh re-read + conditional compare-and-set claim; orphan
  account cleanup on lost race), createAccountOnboardingLink (hosted
  Account Link), Connect-secret signature verify.
- Stripe-independent onboarding logic: isPayoutsReady (payouts_enabled
  + no currently_due), nextOnboardingStatus (none/requested/done,
  terminal & idempotent).
- account.updated webhook: Connect-secret raw-body verify + event-id
  idempotency + flag-based requested->done (never "event = done").
- oRPC payouts.createOnboardingLink / onboardingStatus (admin-only).
- DB: users.stripe_connected_account_id (UNIQUE) + payout_onboarding_status.
  Migrations 0002, 0003.

Codex-reviewed (race + uniqueness findings fixed), delta specs synced to
openspec/specs/ and archived. Gates green: lint, typecheck, build, test
(62). Live Connect E2E pending Connect-enabled test keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add-payout-execution (OpenSpec): pull approved transfer requests from
Jomon, pay them out via Stripe Connect transfers, write results back.
Responsibility split (design §5.3): approval is Jomon's, Checkin only
executes. Pull-based; Jomon integrated stub-first with v1/v2 adapters.

- Jomon adapter: JomonClient interface, StubJomonClient (dev/tests),
  v1/v2 HTTP clients (Bearer service token) with STRICT zod response
  validation that fails loudly rather than mis-mapping a payee.
- payouts table + state machine (pending / onboarding_waiting /
  processing / paid / failed), jomon_ref UNIQUE, jomon_written_back_at.
- Execution: ingest (idempotent upsert) -> resolve payee by mail_hash
  (userId immutable once set) -> onboarding gate -> atomic
  claim-to-processing -> Stripe transfer (idempotency key
  payout:<ref>) -> paid/failed -> Jomon write-back (retryable without
  re-transfer). Per-item error isolation; failed rows retried only by
  admin single execute, never auto-retried by the batch.
- Stripe transfers adapter (createTransfer). oRPC payouts.processApproved
  / list / execute (admin-only). Migrations 0004, 0005.

Codex-reviewed (1 critical + 2 high + 3 medium money-movement findings
fixed: atomic execution claim, retryable write-back, strict Jomon
validation, batch isolation, userId immutability, explicit failed-retry
policy). Delta spec synced to openspec/specs/ and archived. Gates green:
lint, typecheck, build, test (94). Live Jomon/Stripe E2E pending the
Jomon Bearer-token endpoint and Connect-enabled test keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add-traq-member-auth (OpenSpec): rework auth so a session can carry BOTH
a traQ identity and an isct user identity, so Jomon payouts (keyed by
traQ ID) can resolve to our users.

- Dual-identity session: SessionIdentity { traqId, isAdmin, userId,
  mailHash }. traQ login now establishes a MEMBER session for any traQ
  user; accountant (isAdmin) is the env allow-list subset (was: reject
  non-allowlisted). requireMember/requireUser/requireAdmin +
  memberProc/userProc/adminProc (auth before input validation).
- users.traq_id (UNIQUE) + race-safe linkTraqId (compare-and-set,
  dup-key via .cause → 'conflict', never overwrites/relinks).
- Linkage: traQ login resolves a linked user; isct confirm on an
  existing traQ session links the two (link-first, and on conflict mint
  a plain user session instead of a corrupted dual one); issueInvoice
  links the caller's own traQ id and stamps it into Customer metadata
  (never a conflicting/foreign traq id).
- auth.me → { authenticated, member, admin, hasUser, traqId }.
  Migration 0006 (sessions.is_admin, users.traq_id; drop actor_type).

Codex-reviewed (link-before-attach ordering + metadata-poisoning on
conflict fixed). Delta specs synced (admin-authorization/session
MODIFIED, identity/membership-billing ADDED) and archived. Gates green:
lint, typecheck, build, test (104). Uncommitted member UI adjusted
minimally to compile; it is reworked in add-member-ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix-jomon-payout (OpenSpec): correct the Jomon adapter to the real
traPtitech/Jomon API and resolve payees via users.traq_id.

- List approved via GET /api/applications (v1 ?current_state=accepted,
  v2 ?status=approved); drop the fictional /transfer-requests.
- Payee = traQ ID: v1 repaid_to_user.trap_id; v2 ApplicationTarget.target
  (User UUID) resolved through GET /api/users → User.name. Strict zod
  parsing (fail-loud, no field defaulting). currency hardcoded jpy.
- jomon_ref: v2 = target.id; v1 = `${appId}:${trapId}`. Skip already
  repaid_at / paid_at items.
- Payee resolution now getUserByTraqId (was deriveMailHash(email));
  unresolved traQ ID → pending/要対応, no transfer.
- Write-back: v1 PUT .../states/repaid/{trapId}; v2 unsupported
  (JomonWriteBackUnsupportedError) → kept paid, jomon_written_back_at
  NULL, retried, never re-transfers (Jomon v2 needs a per-payee endpoint).
- Batch resilience (Codex): list-fetch error returns summary.listError
  instead of aborting; v1 per-application detail failure is logged+skipped.

Notes: real Jomon has only traQ OAuth/cookie auth (no Bearer receiver)
and no v2 write-back — live v1/v2 needs Jomon-side additions; default
driver stays stub. Codex-reviewed (0 critical/high). Delta synced
(payout-execution MODIFIED) and archived. Gates green: lint, typecheck,
build, test (111).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add-member-ui (OpenSpec): Nuxt 4 + @nuxt/ui member UI over the
dual-identity auth.

- Layout/header with auth.me state (member/admin/traqId) + CSRF-aware
  logout; @nuxt/ui + Tailwind setup, app.config theme, UApp wrapper.
- / : state-based entry (anonymous → pay / login; logged-in → status).
- /verify-email : isct email → auth.requestEmailVerification, redirect
  preserved (sanitized), domain/error feedback, no double-submit.
- /membership : design §5.1 branching off auth.me —
  anonymous → 新規入部/再入部 (verify-email) or 現役 (traQ /login);
  member && !hasUser → verify-email to link; hasUser → invoice form
  → membership.issueInvoice → hostedInvoiceUrl. 区分→feeType mapping;
  special ¥2,000 hidden (accountant-only); errors mapped, submit guarded.

Codex-reviewed (only a stale design note fixed). Smoke-tested: pages
render, magic link emitted (LogMailer), confirm → hasUser, invoice
error path handled. Delta synced (member-ui ADDED) and archived. Gates
green: lint, typecheck, build, test (111). Real Stripe issuance E2E
pending test-mode keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Current state, OpenSpec+subagent+Codex workflow, what's built (8 changes
/ 11 specs), key invariants (money/identity safety), prioritized remaining
work (live Jomon coordination, Stripe E2E, accountant UI, hardening), env,
local run/verify, and gotchas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the accountant-facing pages on top of the existing adminProc oRPC:
- /payments: 請求書/決済セッション tabs, status filter, append-style
  pagination, Stripe Dashboard links. Stale-response guard via request
  sequence tokens so a filter change can't be overwritten by an in-flight
  fetch (Codex finding).
- /payouts: Jomon ingest (processApproved), status-filtered list, per-row
  execute, onboarding link issue + status. createOnboardingLink refetches.
- useFormatters composable (currency/date), accountant nav in header/home
  shown only when auth.me.admin. 通貨 column added (Codex finding).

Server adminProc is the real authorization; the UI gate is for UX. The
USelect "すべて" option uses an 'all' sentinel (empty string crashes
@nuxt/ui hydration). Verified non-admin gate + flows with Playwright.

OpenSpec: new capability accountant-ui; change archived
2026-06-21-add-accountant-ui. Gates green (lint/typecheck/build/test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChAsb7VghL4Mw3oJLrzku9
GET /dev/login mints a session WITHOUT traQ OAuth / email verification so
the UI flows can be exercised locally (?as=admin|member|user|both,
?email= override for a fresh customer, ?redirect=).

Hard-gated as an auth bypass that must NEVER reach production: requires
import.meta.dev (baked to false in the production build → unconditional
404, verified against the built server) AND CHECKIN_DEV_LOGIN=1. Documents
the flag in .env.example with a danger note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChAsb7VghL4Mw3oJLrzku9
Members could pay twice for the same membership coverage — the only guard
was Stripe's ~24h idempotency window. Adds a persistent half-period ledger
keyed UNIQUE(user_id, activity_year, half) as the duplicate-payment guard.

Model (user-confirmed rules): ¥4,000 = 通期 (both half-slots), ¥2,000 =
one half. Standard self-serve (entry/recovery 前期→full, 後期→kouki;
継続→full, covers NEXT year). Special accountant ¥2,000 picks zenki|kouki.
issueSpecialInvoice gains coverage + activityYear inputs.

Issuance is gated by the ledger: paid→reject, open exact-match→reuse the
existing invoice URL, partial overlap→reject, free→issue. invoice.paid
flips the charge's slots to paid (idempotent, 台帳外 = 200 no-op).

Money-safety (two Codex review rounds): draft-first ordering — create a
NON-payable draft, reserve the slot WITH its id, THEN finalize. A payable
invoice therefore never exists without a slot guarding it. Draft creation
is non-idempotent (concurrent issuers get distinct ids so the reservation
loser voids its OWN draft). finalizeAndSend throws ONLY while still a draft
(email send is best-effort, concurrent-finalize tolerant), so the
void+release path can never strip a payable invoice's guard.

Verified E2E with real Stripe + stripe listen: issue → reuse-while-unpaid →
pay → re-issue rejected; 通期 paid → 後期 special rejected. 9 ledger tests;
gates green (lint/typecheck/build/test 120). OpenSpec: new capability
issuance-ledger, membership-billing/payment-webhook updated, change
archived 2026-06-23-add-issuance-ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChAsb7VghL4Mw3oJLrzku9
Offer customer_balance / jp_bank_transfer alongside card on every
membership invoice (createDraftInvoice payment_settings), so members
can pay dues by bank transfer. Settlement is async (invoice stays
open until funds arrive) and rejoins the existing invoice.paid path —
issuance-ledger paid-confirmation, accountant notification, and the
duplicate-payment guard are unchanged; it only adds a payment method.

OpenSpec: add-bank-transfer-payment (membership-billing delta synced
to specs + archived). Codex money-safety review: no blocking issues.
Verified end-to-end in Stripe test mode: issue -> hosted page offers
card + 銀行振込 -> simulated transfer -> invoice.paid -> both ledger
slots paid + accountant notified; paid re-issue rejected (409).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v1 driver was guessed and rejected every live application: it required
a per-payee `amount` on each repayment log, but real Jomon v1 has none —
the amount lives on the application (`current_detail.amount`). Verified
against a local Jomon v1 (master branch). Now:

- v1 reads the amount from `current_detail.amount`; the schema no longer
  requires a per-log amount.
- Decide by TOTAL payees on an application (not just unpaid count): exactly
  one payee → pay the application amount; MORE THAN ONE payee → never
  auto-pay (the application total can't be split safely, even when only one
  remains unpaid — would overpay). Multi-payee apps are flagged needs-review
  via `summary.multiPayeeRefs`; `/payouts` shows a warning alert. Operations
  guarantee one payee per application; this guards the exception.
- `executePayout` also refuses multi-payee markers.

Codex money-safety review caught the partial-repaid overpay (HIGH) on the
first pass; fixed by the total-payee gate and re-reviewed clean.

OpenSpec: fix-jomon-v1-amount-multipayee (payout-execution delta synced +
archived). Verified end-to-end on local Jomon v1: single payee -> ingest ->
resolve -> onboarding gate -> real Stripe transfer (tr_…, ¥1,500) -> paid ->
`repaid` write-back to Jomon (live 200); re-run is idempotent (already_paid).
Multi-payee and partial-repaid -> multiPayeeRefs + UI alert, no transfer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `membership.issueSpecialInvoiceByEmail` (adminProc): selects the
target by email and get-or-creates the person row by mail_hash, so a
first-time member can be issued the 継続特別 ¥2,000 invoice without a
prior login / userId. The Customer is created from the same email, so
the spec's "管理者が指定したメール=対象者" mail_hash match holds by
construction; the email domain must be allowed (avoids junk rows).

Extract the special-issuance core into `issueSpecialForRow` so the
existing userId-based `issueSpecialInvoice` stays intact (DRY).

Add the accountant page `/special-invoice` (email / name / coverage /
activityYear) surfacing the hosted invoice URL to forward to the member,
plus an admin nav link. Coverage keeps 前期のみ/後期のみ; the 後期のみ
option is labelled for its two-stage case (前期特別→後期追加).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
`<UInput type="number">` coerces v-model to a JS number, so onSubmit's
`form.activityYear.trim()` threw TypeError, was swallowed by the catch, and the
RPC never fired — the accountant saw "発行に失敗しました" and could not issue a
next-year (継続特別 collected in 後期) special invoice from the UI. Normalize to
a string before trimming. The server API was already correct.

Verified via Playwright: activityYear=2027 now issues (metadata.activity_year=
2027); empty still defaults to the current year.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
…ession tests

The dedup→markPaid→notify→record logic lived only in the Nitro route handler
and had no unit coverage (event-id idempotency, notify-before-record ordering).
Extract it into a dependency-injected pure function `processInvoicePaid(ops,
verified)` (packages/api/src/webhook/process.ts); the route now just binds the
real db/notifier ops. Behavior is byte-for-byte equivalent (order, return
shapes, notify text, objectId null guard).

Adds process.test.ts (DI spies, DB-free): paid → markPaid→notify→record in
order; duplicate event → no side effects; notify failure → not recorded (Stripe
retries); ledger-less invoice (objectId null) → skip markPaid, still 200.
126 → 131 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
Playwright (playwright-core) harness driving the dev server at :13000 for
UI-level verification of 入部/継続/特別/払い戻し + cross-cutting flows. Mints dev
sessions and injects the __Host-/Secure session + CSRF cookies (Chromium drops
them from Set-Cookie over http://localhost) and pays test invoices with a test
card to exercise the invoice.paid → ledger-paid loop.

- E2E-SCENARIOS.md: 94-scenario verification ledger (Codex-reviewed for gaps)
- scripts/e2e/: harness.mjs, pay.mjs, run_*.mjs per feature, RESULTS.md
- eslint: ignore scripts/e2e/** (machine-specific helpers, absolute paths)
- .gitignore: scripts/e2e/shots/ (binary screenshots)
- add playwright-core devDependency

Result: 84/94 green (54 UI/HTTP + 30 unit), 0 FAIL, 10 blocked by environment
only (system clock / env restart / DB pre-state / bank-transfer settlement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
To deploy behind NeoShowcase "Soft" member-auth without wiring our own traQ
OAuth, trust the reverse proxy's `X-Forwarded-User` as the traQ identity when
`CHECKIN_TRUST_FORWARD_AUTH=1`.

- session: `applyForwardedIdentity` (pure) merges the forwarded traQ identity
  (and accountant flag via the allow-list) onto the cookie session; the isct
  user identity (userId/mailHash) still rides on the email-verification cookie.
- buildRequestContext: when enabled, read X-Forwarded-User (fallback
  X-Showcase-User) and apply it over the resolved cookie session.
- /login: when enabled, redirect to the platform's `/_oauth/login?redirect=...`
  instead of our traQ OAuth, so unauthenticated users get prompted to log in.
- config/runtimeConfig: `trustForwardAuth` (env CHECKIN_TRUST_FORWARD_AUTH).

Only trust the header where the app is reachable solely via the trusted proxy.
Default (flag off) keeps the existing traQ OAuth behavior. 4 unit tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
…auth)

Step-by-step for deploying both as 1 Runtime app each, using NeoShowcase Soft
member-auth (X-Forwarded-User) instead of self-auth, built-in MariaDB, no Swift
(Jomon falls back to local storage), no Dockerfile for Checkin (Command/
Buildpack), and Checkin→Jomon Bearer for service calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
- Checkin env: switch to NUXT_-prefixed names (runtimeConfig is baked at build;
  NUXT_* overrides at runtime) and export NUXT_DATABASE_URL in the entrypoint.
- Jomon: concrete `administrators` seed SQL (fresh prod DB has no admin → 403).
- Troubleshooting: the two real startup panics (MARIADB_* unset → DB connection
  refused; local-storage dir missing → fixed in Jomon b41808e, rebuild needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
…h/login)

Under Soft member-auth the Jomon Vue client drove its own traQ OAuth (genpkce)
and failed with a blank ClientID. Documented the fix (client now redirects to
the platform forward-auth login; member-auth stays Soft so Bearer still works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
Under Soft forward-auth the traQ identity comes from the proxy's X-Forwarded-User,
not our session — so POST /logout destroying the session left the user logged in
(the header re-asserted the identity on the next request). When trustForwardAuth
is on, /logout now returns a redirect to the platform logout (/_oauth/logout) and
the client follows it with a full-page navigation (the proxy auth cookie is
HttpOnly and only dropped that way). Non-forward-auth deployments are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
Jomon now seeds administrators from INITIAL_ADMIN_TRAP_IDS (+ SERVICE_USER_TRAP_ID)
at startup, so the manual `INSERT INTO administrators` is no longer required.
Documented the env and kept the SQL as an optional fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
Jomon refunds are keyed by traQ ID; previously a payee who had never done isct
email verification was "unresolved" and never paid, because a person row could
only be born from a mail_hash. Make a person identifiable by EITHER mail_hash OR
traq_id:

- schema: `users.mail_hash` is now nullable (unique; migration 0008). A
  payout-only recipient has a traq_id and no mail_hash.
- identity: `getOrCreateUserByTraqId` mints a payout-only row; `setUserMailHash`
  race-safely fills a null mail_hash; `resolveUserForEmailVerify` unifies email
  verification — create / link a member / MERGE a payout-only row gaining its
  email / refuse to auto-merge two separate rows (conflict) — keeping one row
  per person.
- payout: `resolvePayee` auto-creates the traQ-only row instead of leaving it
  unresolved → the recipient just needs Connect onboarding (no membership).
- connect: the Express account is labelled with whichever non-PII key exists
  (traq_id when there is no mail_hash).
- billing stays mail_hash-gated: `requireUser`/session need mail_hash, and the
  special-invoice path rejects a payout-only target — traQ-only rows can't be
  billed.

DB-backed tests cover the merge matrix and the payout auto-create. All gates
green (typecheck/lint/build, 142 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM
オンボーディング未完了の受取人に会計が手で銀行振込したケースを
「手動振込済み」として記録できるようにする。

- DB: payouts に payout_method/manual_paid_at/manual_paid_note/manual_paid_by を追加
- 確定: Stripe transfer を発行せず、claim と同じ claimable 述語の単一原子的
  UPDATE で paid 確定(paid/processing は拒否、Stripe 実行と競合しても二重支払い不可)
- Jomon: 既存 write-back を再利用し manual note で settled を書き戻し(transfer_id 非依存)
- API: payouts.markManuallyPaid(adminProc + CSRF、実行者はセッションから解決)
- UI: /payouts 各行「手動振込済みにする」(参照メモ+確認モーダル、手動振込バッジ+実行者/日時)

OpenSpec change add-manual-bank-payout を実装・sync・archive 済み。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
origin/claude/checkin-auth-collection を main へマージし、デフォルトブランチを
開発の基点にする。両側の実装を残したまま、公開面を packages/api-contract の
契約に一本化した。

## 採った決定

- D1 マージで統合する(force push で置き換えない)。両側の履歴を残す。
- D2 auth 側 13 プロシージャの契約化を統合の範囲に含める。health.check は
  契約に入れず削除する(main が #38 で契約と実装の両方から削除済み)。
- D3 main 側の Stripe 4 機能(prices/products/invoices/checkout)を残す。
- D4 pub を implement(contract).$context<Context>() に一本化し、appRouter は
  pub.router({ 8 キー }) で組み立てる。
- D5 Stripe SDK への到達経路を StripeClient に閉じる。
- D6 apiVersion の明示的な指定を保つ。
- D7 設定は main を基礎に auth 側の追加を合併する。
- D8 変更系ガード(mutationsEnabled / assertMutationsEnabled)を残す。
  main 側 4 機能は認可を持たないままなので、#15 までこのガードで塞ぐ。
- D9 依存は main 側の新しい版を採る(stripe 18→22、vitest 2→4、ESLint 9→10、
  TypeScript 5.9→6、pnpm 10→11、zod 3→4、@types/node 22→24)。
- D10 2 つの PR に分ける(#52 = sdk 経路、本 PR = マージ本体)。
- D11 同じ Stripe Invoice に対する 2 つの公開範囲を許す(invoices.list は
  customer が ID のみ、payments.listInvoices は顧客名付き)。
- D12 一覧の封筒を { data, nextCursor } に統一し、hasMore を返さない。
- D13 auth 由来の scripts/e2e/ を残す。

## 退けた候補と理由

- 契約化を後続 issue へ回す案: 契約に無いキーは RPC の経路表に載らず
  matched: false になるため、型エラーにも実行時エラーにもならないまま
  応答しない死んだコードが残る。
- PR を 4 つに分ける案: Router<T> が契約の全キーを要求するので、契約定義だけ
  を切り出した PR が型として成立しない。
- 依存を auth 側の版に揃える案: main の CI と dependabot の設定が新しい版を
  前提にしている。

## 適合の内訳(PR 本文に詳細)

型と lint の指摘 235 件のうち 234 件は D7(main の tsconfig の
noPropertyAccessFromIndexSignature、nuxt.config の strictTemplates、
eslint.config の型考慮ルール)に由来し、D9 の版上げに由来するのは
stripe 18→22 の 1 件(InvoiceLineItem.pricing.price_details.price が
string から expandable(Price) になった)だけだった。

## 検証

5 ゲート(pnpm lint / knip / typecheck / test / build)がすべて通る。
契約の 8 capability と appRouter のトップレベルキーが一致し、health を
含まないことをテストで固定した。契約が RPC の経路表を閉じること、契約に
無いフィールドが出力から剥がされることも、@orpc/server の内部に触れない
形でテストにしてある。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 つのテストが、名前で述べている主張の一部を測っていなかった。

- capability-routers.test.ts: 移した 13 プロシージャの検査が、同じファイル内の
  リテラル配列を数えるだけだったので、4 つの capability のルーターに 14 個目が
  現れても落ちなかった。ルーターのキーから経路の一覧を作り、移した 13 と集合として
  突き合わせる。減った場合と増えた場合の両方で落ちる。
- contract-input-parity.test.ts: 「移設前を zod 3、契約側を zod 4 で組んで比べる」
  ことに依拠しながら、packages/api が解決する zod が major 3 であることを
  見ていなかった。上げると zod 4 どうしの自己比較に静かに変わり、受理範囲が
  変わっても差が出ない。前提そのものを検査に載せる。
- orpc.test.ts: 認可が落ちる側の requireUser と requireAdmin が同じコードで
  投げていたので、ビルダーが 2 つを取り違えても同じ結果になっていた。落とす側の
  コードを分け、どちらが呼ばれたかを観測できるようにする。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
isDuplicateKeyError が cause の連鎖を辿る根拠は、これまで「drizzle は mysql2 の
エラーを包み、元のものを cause に持つ」という記述だけで、測ったものではなかった。

MariaDB 11 に移行を適用し、membership_slots_user_year_half_uq に違反する行を
drizzle-orm 0.45.2 + mysql2 3.23.2 で 2 回 insert して測った。投げられるのは
DrizzleQueryError で、それ自身は code も errno も持たず、1 段下の cause が
mysql2 の Error (code: ER_DUP_ENTRY, errno: 1062) である。したがって最上位だけを
読む実装は重複キーを検出できない。

共有版のコメントを測った形に書き直し、テストのフィクスチャも測った形に合わせる。
stripe/connect.ts と webhook/events.ts の浅い 2 実装は、統一すると auth 由来の
コードの振る舞いが変わるので触らない。影響と統一の判断は後続 issue に残す。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
統合後のツリーは `packages/api` が zod 3(`^3.24.0`、解決版 3.25.76)、`packages/api-contract`
が zod 4(`^4.4.3`)を宣言していて、1 つのモノレポに zod のメジャーが 2 つ入っていた。
`origin/main` の `packages/api/package.json` に zod の宣言が無く、auth ブランチの `^3.24.0` が
コンフリクトせずそのまま通ったためである。承認済みの計画の D9 は「依存は main 側の新しい版を
採る」と定め、メジャーが動く 7 つに zod 3→4 を挙げている。同居を許す判断はこの D9 を覆すもの
だったので撤回し、D9 のとおり `packages/api` の宣言を `^4.4.3` に上げた。`pnpm-lock.yaml` から
zod 3.25.76 の項目が消え、リポジトリが解決する zod は 4.4.3 だけになった。

`packages/api` で zod を import する実装は `src/jomon/http.ts` だけで、同ファイルが使う
`z.object`・`z.string().trim().min()`・`z.number().int().positive()`・`z.union`・`z.array`・
`z.unknown()`・`.transform()`・`.nullish()`・`.safeParse` は zod 4.4.3 でそのまま通るので、実装の
修正は要らなかった。上げた結果として変わるものは 2 つある。`z.number().int().positive()` が
`2**53` を拒否するようになること(zod 4 の `int()` は `safeint` で `Number.MAX_SAFE_INTEGER` を
超える整数を受理しない。Jomon の金額に対してはこの拒否が正しい挙動である)と、`error.message`
の文字列の形が変わること(zod 3.25.76 は `"code":"too_small","type":"string"`、zod 4.4.3 は
`"origin":"string","code":"too_small"`)で、後者は `jomon/http.ts` が例外の文面へ埋めているので
例外文の文字列が変わる。スキーマが受理・拒否する値の集合は、この `2**53` の 1 件を除いて
変わらない。

`src/contract-input-parity.test.ts` は削除した。このテストは移設前のスキーマを `packages/api` の
zod 3 で組み直し、契約側の zod 4 と受理範囲を比べるもので、`packages/api` も 4 になると zod 4
どうしの自己比較になり、測っていた主張が消える。このテストが測った結果(移設で受理範囲が
変わったのは `payments` の 2 つの一覧の 7 入力だけで、それは `params.ts` の `pagination` に
揃えた意図した変更である)は PR-2 の本文に載せる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 つのコメントが、この統合が決めたことと両立していなかった。

payouts/execute.ts は、一覧取得に失敗したときの summary について「callers/tests
see the fetch failed」と書いていたが、この統合が payouts.processApproved の出力
契約から listError を落としたので、RPC の呼び出し側にはこのキーが届かない。
プロセス内の呼び出し元とテストは読むこと、RPC の呼び出し側には届かないこと、
その理由(失敗の文面を公開面に出さない)と、運用中の原因は直後の console.error
の行にあることを書く。契約が剥がすことは contract-surface.test.ts が固定している。

orpc.test.ts は、冒頭の docblock だけが「認可が落ちる Context では UNAUTHORIZED が
返る」と古いままだった。落とす側の requireUser と requireAdmin を別のコードで
投げ分けて取り違えを観測できるようにした後の実態に合わせる。

どちらもコメントだけで、振る舞いは変えていない。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
webhook/events.ts と stripe/connect.ts のコメントが、同じパッケージの
mysql-result.ts に記録した実測と両立していなかった。この 2 ファイルが持つ
isDuplicateKeyError は最上位しか読まないが、drizzle は mysql2 のエラーを包み、
code と errno を持つ側を cause に置く。したがって実際の重複キーでは false を
返し、コメントが説明している回復経路には入らず例外を投げ直す。

recordStripeEventOnce は「false を返す」と書いているが、その分岐に入らない。
どちらの呼び出し側も hasProcessedStripeEvent で照会してから呼ぶので、通常の
再配信は insert に届かず、同時に届いた 2 つが両方とも照会を通り抜けたときだけ
競って、負けた側がエラーになって Stripe の再送で処理される。

connect.ts は、通常の負け筋が claimConnectedAccountId の戻り値 false であって
この catch ではないので、到達しないのは catch の中の won = false だけである。
外側のコメントも、その扱いが現に働いているように読めたので直した。

どちらもコメントだけで、実行される行は 1 行も変えていない。3 実装の統一は
振る舞いの変更になるので、統一を扱う後続 issue に残す。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 6dbb54c6-4ebc-4286-b489-a421484704b9


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

*/
export function safeEqual(a: string, b: string): boolean {
const ah = createHash('sha256').update(a).digest()
const bh = createHash('sha256').update(b).digest()
@reiroop

reiroop commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

CodeQL がこのブランチに対して alert を 1 件出している。規則は js/insufficient-password-hash、security severity は high、指している場所は packages/api/src/auth/crypto.ts の safeEqual である。

現在の使われ方では、この指摘は当たらないと判断した。根拠は次の 2 点である。

  • safeEqual を呼んでいる実装は 2 箇所(apps/web/server/routes/login/callback.get.ts の OAuth コールバックのハンドラーと、apps/web/server/utils/auth.ts の isCsrfValid)で、どちらも比べているのは generateToken が node:crypto の randomBytes から作る値である。generateToken の JSDoc はこれを "a high-entropy, URL-safe opaque token" と述べている。パスワードではない。
  • safeEqual が SHA-256 を挟むのは保存のためではなく、長さを揃えるためである。timingSafeEqual は長さの違う入力に対して例外を投げるが、長さで早期に分岐すると長さがタイミングから漏れる。

この alert は dismiss していない。2026-09-18 に gh api repos/traPtitech/Checkin/code-scanning/alerts/3 を実行して、dismissed_at・dismissed_by・dismissed_reason・dismissed_comment がいずれも null であることを確かめた。

判断が今は正しくても、safeEqual は @checkin/api の公開 API で、型は (a: string, b: string) => boolean なので、パスワードを渡すことを型も名前も拒めない。これは #69 で追跡する。

@reiroop
reiroop merged commit 9911dd9 into main Sep 18, 2026
5 of 6 checks passed
@reiroop
reiroop deleted the feat/merge-auth-collection branch September 18, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants