Repository navigation
feat: auth-collection を統合し、8 capability を契約ファーストに揃える - #68
Conversation
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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
| */ | ||
| export function safeEqual(a: string, b: string): boolean { | ||
| const ah = createHash('sha256').update(a).digest() | ||
| const bh = createHash('sha256').update(b).digest() |
|
CodeQL がこのブランチに対して alert を 1 件出している。規則は 現在の使われ方では、この指摘は当たらないと判断した。根拠は次の 2 点である。
この alert は dismiss していない。2026-09-18 に 判断が今は正しくても、 |
何をしたか
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で数えた)。採った決定
health.checkは契約に入れず削除する(mainが feat(api): Stripe(価格・商品・請求・Checkout)を oRPC 契約ファーストで公開する #38 で契約と実装の両方から削除済み)。main側の Stripe 4 機能(prices・products・invoices・checkout)を残す。pubをimplement(contract).$context<Context>()に一本化し、appRouterはpub.router({ 8 キー })で組み立てる。StripeClientに閉じる。apiVersionの明示的な指定を保つ。理由は後述の訂正のとおりに書き直した。mainを基礎に auth 側の追加を合併する。mutationsEnabled/assertMutationsEnabled)を残す。main側 4 機能は認可を持たないままなので、認可・レート制限を導入し、無認証アクセスを塞ぐ #15 までこのガードで塞ぐ。main側の新しい版を採る(stripe 18→22、vitest 2→4、ESLint 9→10、TypeScript 5.9→6、pnpm 10→11、zod 3→4、@types/node22→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で確かめた)。invoices.listは customer が ID のみ、payments.listInvoicesは顧客名付き)。{ data, nextCursor }に統一し、hasMoreを返さない。scripts/e2e/を残す。退けた候補と理由
matched: falseになるため、型エラーにも実行時エラーにもならないまま応答しない死んだコードが残る。Router<T>が契約の全キーを要求するので、契約定義だけを切り出した PR が型として成立しない。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の設定が有効にしている検査に適合していなかったものである。noPropertyAccessFromIndexSignaturetsconfig.base.jsonstrictTemplatesapps/web/nuxt.config.tsv-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-Versionheader, and it will no longer be possible to ask stripe-node to use your account's default version.」と書いてある。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 で終わった。pnpm lintpnpm knippnpm typechecktypecheckを持つ 4 ワークスペース)pnpm testpnpm build公開面はテストで固定してある。
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