Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ error: opening lock file "~/.cache/nix/fetcher-locks/...lock": Operation not per

- **コメント・ドキュメント**: コード内コメント・docstring・JSDoc はすべて**日本語**で記述する。
- **エラーメッセージ**: HTTPException の `detail` 等、ユーザーに返すメッセージはすべて**日本語**。
- **エラーメッセージのハードコード禁止**: ts/tsx でリテラル日本語を直接書かない(`throw new Error("...")` / `setError("...")` / `toast.error("...")` 等)。frontend 完結のメッセージは `frontend/src/constants/messages.ts` の定数を参照、API 経由のエラーは backend の `messages.json` 由来の `AppErrorResponse.message` を表示する。詳細: `.claude/rules/frontend/messages.md`
- **例外の握りつぶし禁止**: `except SomeException: pass` は禁止。最低でも `logger.debug/warning/error` でログを残す。補助処理(通知生成など)で抑制する場合も `logger.warning` でログを出すこと。
- **過剰な抽象化を避ける**: PEP8 を守るな、PEP8 を理解した上で抽象化しろ。

Expand Down Expand Up @@ -103,9 +104,6 @@ CI 定義: `.github/workflows/ci.yml`
| 種別 | 名前 |
|---|---|
| 職務経歴書(career history) | `Resume` / `resumes` テーブル |
| 履歴書(personal CV) | `Rirekisho` / `rirekisho` テーブル |

> `rirekisho` は日本語ローマ字のため cSpell の警告が出るが無視してよい。

## 環境変数

Expand Down
2 changes: 0 additions & 2 deletions .claude/rules/backend/auth-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ paths:
- 認証 Cookie 属性は `COOKIE_SECURE` / `COOKIE_SAMESITE` で制御する

## 暗号化

- 履歴書(Rirekisho)の個人情報フィールド(email / phone / postal_code / address)は `app/core/encryption.py` で暗号化保存
- 鍵は `FIELD_ENCRYPTION_KEY` 環境変数(Fernet)

## セキュリティ
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/backend/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ paths:

# DB設計ルール

- `basic_info` / `resumes` / `rirekisho` は **1ユーザー1件** を前提にし、`user_id` を一意制約で縛ること
- `basic_info` / `resumes` は **1ユーザー1件** を前提にし、`user_id` を一意制約で縛ること
- 可変長データを JSON カラムへ増やさないこと。資格・学歴・職歴・職務経歴の明細・ブログタグは子テーブルへ正規化すること
- 日付は可能な限り DB の `DATE` / `TIMESTAMP` を使うこと
- `blog_articles` は `account_id` 起点で管理し、`user_id` や `platform` を冗長保持しないこと
Expand Down
101 changes: 101 additions & 0 deletions .claude/rules/frontend/messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# メッセージ管理ルール (frontend)

ts/tsx でユーザーに表示される文字列を**リテラルで直接書かない**。
必ず Single Source of Truth から取得すること。

## SSoT の責務分離

| メッセージの種類 | 正本 | 取得経路 |
|---|---|---|
| **API 経由のエラー** (backend → frontend) | `backend/app/messages.json` | `AppErrorResponse.message` を `api/client.ts:buildApiError` 経由でそのまま表示 |
| **API レスポンスに message が無い時の補完** | `frontend/src/constants/errorMessages.ts` (`ERROR_CONFIG`) | `ErrorCode` を引いて補完(既存実装) |
| **frontend 完結のメッセージ** | `frontend/src/constants/messages.ts` | import して定数参照 |

`messages.json` から frontend 用 TS 定数を build-time 生成する仕組みは**入っていない**。
`ERROR_CONFIG` は `backend/app/core/errors.py:ErrorCode` enum と**手動同期**する設計(型エラーで漏れを検出)。

## frontend 完結のメッセージとは

backend を経由しない以下のような文言:

- **フォームの事前バリデーション**: `payloadBuilders.ts` の「氏名を入力してください」など
- **catch ブロックの fallback メッセージ**: `e instanceof Error ? e.message : "..."` の `...` 部分
- **ネットワーク層の fallback**: `api/client.ts` で 5xx / fetch 例外時に出す文言
- **JSX 直書きの UI 文言**: `ErrorBoundary` のタイトルなど
- **開発者向け内部エラー**: `import_id が未設定です` のような状態管理エラー

これらは `frontend/src/constants/messages.ts` に集約する。カテゴリ別の定数:

- `VALIDATION_MESSAGES` — 入力バリデーション
- `NETWORK_MESSAGES` — ネットワーク / API クライアント層
- `FALLBACK_MESSAGES` — catch fallback / toAppError fallback
- `UI_MESSAGES` — JSX 直書き文言
- `INTERNAL_MESSAGES` — 開発者向け内部エラー
- `downloadFailureMessage(filename)` — 動的パラメータが必要なケースは関数

## 新規メッセージ追加の手順

### API 経由のエラー(backend が発火)

1. `backend/app/messages.json` の `error.<category>` にキー追加
2. backend で `get_error("category.key", **kwargs)` または `raise_app_error(code=...)` で使う
3. frontend 側はとくに変更不要(`AppErrorResponse.message` が自動的に画面に出る)

### frontend 完結のメッセージ

1. `frontend/src/constants/messages.ts` の適切なカテゴリに定数追加
2. 使用箇所で import して参照
3. リテラルを書かない

## やってはいけないこと(再発防止対象)

以下は **ESLint または `make lint-frontend-messages` で自動検知され CI で fail する**:

```ts
// ✗ ESLint で error
throw new Error("入力してください");
throw new Error(`${field} を入力してください`);

// ✗ make lint-frontend-messages で error
setError("失敗しました");
setErrorMessage("不正な値です");
setAccountError("取得に失敗");
toast.error("エラー");
alert("確認してください");
```

正しい書き方:

```ts
import { VALIDATION_MESSAGES, FALLBACK_MESSAGES } from "../constants/messages";

throw new Error(VALIDATION_MESSAGES.FULL_NAME_REQUIRED);
setError(FALLBACK_MESSAGES.SAVE);
```

## 例外: 検知から外れているもの

以下はリテラルを書いても検知されない(許容するが推奨しない):

- 英語の開発者向けメッセージ (`throw new Error("invariant violated")`)
- `console.error` / `console.warn`(UI に表示されないログ用途)
- テストファイル (`*.test.*`, `test/**`)
- `constants/messages.ts` 自身

## 検証

```bash
make lint-frontend # ESLint(no-restricted-syntax 含む)
make lint-frontend-messages # grep ベースの追加チェック
```

両方を pass させることが「テスト OK」条件の前提(`.claude/rules/frontend/test.md` 参照)。

## 参考

- `frontend/src/constants/messages.ts` — frontend 完結メッセージの SSoT
- `frontend/src/constants/errorCodes.ts` / `errorMessages.ts` — backend ErrorCode 連携
- `backend/app/messages.json` — backend のメッセージ正本
- `backend/app/core/errors.py` — ErrorCode enum
- `scripts/lint-frontend-messages.sh` — grep ベースの検知スクリプト
- `frontend/eslint.config.js` — no-restricted-syntax ルール定義
130 changes: 130 additions & 0 deletions .claude/rules/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
paths:
- backend/**
- frontend/**
- infra/**
---

# セキュリティルール(全領域横断)

このファイルは backend / frontend / infra すべての領域に適用される横断セキュリティルール。
認証・JWT・Cookie 属性・暗号化・Rate Limit の詳細は `.claude/rules/backend/auth-security.md` を参照し、ここでは重複させない。

---

## 秘密情報管理(Secrets Management)

### Git に含めてはいけないもの

- `.env` / `.env.*` — ローカル開発用環境変数
- `*.pem` / `*.key` — 秘密鍵・証明書
- `*.json` のうち GCP サービスアカウント鍵に該当するもの
- `turso-auth-token` 等の認証トークンをハードコードした設定ファイル

### 環境変数の正本

- **定数名の定義**: `backend/app/core/env_keys.py`
- **用途と注入経路の一覧**: `docs/api.md` の「環境変数」セクション
- **本番注入**: `infra/modules/cloud_run/main.tf` の `env` ブロック(Secret Manager 参照形式)
- **ローカル**: `docker-compose.yml`

backend 内で文字列リテラル `os.getenv("XXX")` を使うことは禁止。`env_keys.XXX` 経由で参照する。

### ログへの秘密情報出力禁止

認証トークン・API キー・パスワード・個人情報(メールアドレス含む)をログに出力しない。
デバッグ目的でも `logger.debug` にこれらを含めないこと。

---

## 入力バリデーション・出力エスケープ

### Backend

- **Pydantic バリデーション必須**: API エンドポイントへの入力はすべて `app/schemas/` の Pydantic モデルで型・制約を検証する。`Any` 型や `dict` 型の素通しは避ける
- **SQL インジェクション防止**: SQLAlchemy ORM / Core のパラメータバインドを使う。文字列連結でクエリを組み立てることは禁止
- **LLM プロンプトへのユーザー入力**: ユーザー由来の文字列を LLM プロンプトに埋め込む場合は `backend/app/services/llm/sanitizer.py` を通す

### Frontend

- **`dangerouslySetInnerHTML` は原則禁止**: 外部コンテンツや動的文字列を `innerHTML` / `dangerouslySetInnerHTML` に渡さない。React の自動エスケープを信頼する
- **外部リンク**: `<a target="_blank">` には必ず `rel="noopener noreferrer"` を付ける(タブナビゲーション攻撃の防止)
- **URL パラメータの扱い**: `window.location.search` 等から取得した値を DOM に直接レンダリングしない。必ず React の `state` / `props` 経由で扱う

---

## Frontend セキュリティ

### トークン管理

- アクセストークン・リフレッシュトークンは `HttpOnly` + `Secure` Cookie で管理する(詳細: `.claude/rules/backend/auth-security.md`)
- `localStorage` / `sessionStorage` にトークンを保存しない(XSS で盗取されるリスク)
- Redux の `store` にも生のトークン文字列を乗せない

### XSS 対策まとめ

1. `dangerouslySetInnerHTML` 禁止(上述)
2. Markdown レンダラー等を使う場合は sanitize オプションを有効化する
3. `eval()` / `Function()` コンストラクタの使用禁止

### 依存関係

- `npm audit` で High / Critical CVE が検出された場合は PR マージ前に対処する
- CI が `npm audit --audit-level=high` で落ちた場合は、`--force` で無視せず脆弱なパッケージを更新すること

---

## Backend セキュリティ(追加事項)

認証・JWT・Rate Limit・CORS・INTERNAL_SECRET については `.claude/rules/backend/auth-security.md` を参照。

### ファイルアップロード

ファイルアップロード機能を追加する場合は以下をすべて実装する:

1. **MIME タイプ検証**: `Content-Type` ヘッダだけでなくファイルのバイト列(magic bytes)で検証する
2. **ファイルサイズ上限**: エンドポイント側で上限を設け、OOM を防ぐ
3. **ファイル名サニタイズ**: パストラバーサル(`../` 等)を除去する。UUIDv4 でリネームするのが最もシンプル
4. **保存先**: 本番ではローカルファイルシステムに保存せず GCS 等の外部ストレージを使う

### 依存関係

- `pip audit` / `safety` で定期的に CVE チェックを行う
- CI に `pip-audit` を組み込むことを推奨(High 以上を fail 条件にする)

---

## Infra セキュリティ

### IAM 最小権限

- Cloud Run のサービスアカウントには必要最小限のロールのみ付与する(`infra/modules/service_account/`)
- 新規ロールを付与する際は「なぜそのロールが必要か」をコメントで残す
- `roles/owner` / `roles/editor` 等の広範なロールを Cloud Run SA に付与しない

### Secret Manager

- DB 接続 URL・API キー・JWT 秘密鍵等はすべて Secret Manager に格納し、Cloud Run の `secretEnv` / `secretVolume` 経由で注入する
- Terraform state に平文のシークレットが乗らないよう、`sensitive = true` を必ず付与する
- Turso auth token: state 漏洩防止のため `turso CLI` で発行 → Secret Manager に手動投入(詳細: `.claude/rules/infra/opentofu.md`)

### デプロイ制限

- `tofu apply -auto-approve` をローカルから本番環境に直接流さない(詳細: `.claude/rules/infra/test.md`)
- `lifecycle { prevent_destroy = true }` 付きリソースへの破壊的変更は実行前に必ず確認する

---

## セキュリティレビューチェックリスト

AI エージェントがコードを変更した後に確認する項目:

- [ ] 秘密情報(トークン・キー・パスワード)が Git に含まれていないか
- [ ] 環境変数を文字列リテラルで直接参照していないか(`env_keys.XXX` 経由か)
- [ ] 入力バリデーションが境界(API エンドポイント・フォーム)で行われているか
- [ ] `dangerouslySetInnerHTML` / `innerHTML` の新規使用がないか
- [ ] ログに個人情報・認証情報が出力されないか
- [ ] 新規エンドポイントに認証ガード(`get_current_user` 依存)が付いているか
- [ ] 高コスト処理(外部 API 呼び出し・LLM 実行)に rate limit があるか(`slowapi`)
- [ ] `target="_blank"` に `rel="noopener noreferrer"` が付いているか
- [ ] 新規 IAM ロール付与に最小権限の原則を守っているか
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,15 @@
"turso",
"libsql",
"Qiita",
"pyproject"
"pyproject",
"pdfgen",
"pdfplumber"
],
"flake8.args": [
"--max-line-length=100"
],
"editor.linkedEditing": true,
"html.autoClosingTags": true,
"js/ts.autoClosingTags.enabled": true,
"[terraform]": {
"editor.defaultFormatter": "hashicorp.terraform",
"editor.formatOnSave": true,
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
setup install-hooks install-backend install-frontend generate-keys \
dev dev-build dev-down dev-frontend preview-frontend dev-proxy dev-proxy-only \
test test-backend test-frontend \
lint lint-backend lint-frontend lint-fix \
lint lint-backend lint-frontend lint-frontend-messages lint-fix \
format format-check \
ci \
dupe-check dupe-check-html dupe-clean \
Expand Down Expand Up @@ -38,6 +38,7 @@ help:
@echo " lint 全リント (backend + frontend)"
@echo " lint-backend Backend: ruff check"
@echo " lint-frontend Frontend: eslint"
@echo " lint-frontend-messages Frontend: setError等にリテラル日本語が渡っていないか検知"
@echo " lint-fix リント自動修正 (ruff + eslint)"
@echo " format Prettier で整形"
@echo " format-check Prettier チェック"
Expand Down Expand Up @@ -125,14 +126,19 @@ test-backend:
test-frontend:
nix develop --command bash -c "cd frontend && npm test"

lint: lint-backend lint-frontend
lint: lint-backend lint-frontend lint-frontend-messages

lint-backend:
nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check app tests alembic_migrations"

lint-frontend:
nix develop --command bash -c "cd frontend && npm run lint"

# ts/tsx で setError/toast.error/alert にリテラル日本語を直接渡していないか検知。
# ESLint は throw new Error の AST しか拾えないため、関数呼び出し系をここで補完する。
lint-frontend-messages:
nix develop --command bash scripts/lint-frontend-messages.sh

lint-fix:
nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check --fix app tests alembic_migrations"
cd frontend && npm run lint:fix
Expand Down
56 changes: 56 additions & 0 deletions backend/alembic_migrations/versions/0032_add_resume_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""add resume_imports table

Revision ID: 0032_add_resume_imports
Revises: 0031_add_warning_message_to_github_analysis_cache
Create Date: 2026-05-22 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0032_add_resume_imports"
down_revision: Union[str, None] = "0031_add_warning_message_to_github_analysis_cache"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"resume_imports",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("pdf_blob", sa.LargeBinary, nullable=True),
sa.Column("result_json", sa.Text, nullable=True),
sa.Column("is_resume_flag", sa.Boolean, nullable=True),
sa.Column("judge_reason", sa.Text, nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
Comment on lines +46 to +51

@coderabbitai coderabbitai Bot May 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the ORM model defines onupdate for updated_at

rg -A 5 "class ResumeImport" --type py --glob '**/models/resume_import.py'

# Look for updated_at column definition in the model
rg "updated_at.*Column" --type py --glob '**/models/resume_import.py' -A 2

Repository: yusuke0610/devforge

Length of output: 440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the migration's updated_at column definition
echo "=== Migration: updated_at column ==="
sed -n '1,120p' backend/alembic_migrations/versions/0032_add_resume_imports.py | nl -ba | sed -n '35,80p' || true

# Inspect the model for ResumeImport.updated_at and any onupdate/onupdate events
echo "=== Model: backend/app/models/resume_import.py (updated_at occurrences) ==="
rg -n "updated_at" backend/app/models/resume_import.py || true

echo "=== Model: lines around updated_at definition ==="
rg -n "updated_at" -n backend/app/models/resume_import.py -n --context 10 || true

# Also scan for mapper events / SQLAlchemy event listeners that might update updated_at
echo "=== Model: SQLAlchemy event hooks near ResumeImport ==="
rg -n "event\.listen|mapper|before_update|onupdate" backend/app/models/resume_import.py || true

Repository: yusuke0610/devforge

Length of output: 1135


Fix the updated_at auto-update concern—ORM already updates it

backend/app/alembic_migrations/versions/0032_add_resume_imports.py defines updated_at with only server_default=sa.func.now() (no DB-side update trigger), but backend/app/models/resume_import.py sets updated_at with onupdate=func.now(), so SQLAlchemy ORM-generated UPDATEs will refresh updated_at.

If resume_imports rows can be modified outside the ORM (raw SQL/other jobs), updated_at won’t auto-update in that path—add a DB trigger if that’s a requirement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/alembic_migrations/versions/0032_add_resume_imports.py` around lines
46 - 51, The migration sets updated_at with only server_default=sa.func.now()
while the model (resume_import.py) relies on SQLAlchemy's onupdate=func.now();
to cover non-ORM updates add a DB-side UPDATE mechanism: modify the migration
(0032_add_resume_imports.py) to create a trigger function that sets
NEW.updated_at = now() on row UPDATE and attach a trigger to the resume_imports
table in the upgrade() and drop it in downgrade(); keep the existing Column(...)
definition but add the trigger creation SQL (and trigger drop) so updated_at is
updated for raw SQL/other clients as well.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スキップしました — このコードベースでは検出結果が無効です。

理由:

  1. データベースエンジンの不一致。データベースはTurso/libSQL(SQLite互換)であり、PostgreSQLではありません。提案された修正では、PL/pgSQLのトリガー関数構文(CREATE FUNCTION内のNEW.updated_at = now())が使用されていますが、libSQLはこの構文をサポートしていません。SQLiteのトリガーは異なる形式を使用しており、backend/alembic_migrations/versions/ のマイグレーションではトリガーは一切使用されていません。これは確立されたパターンからの例外的な変更となります。

  2. ORM以外のライターが存在しません。resume_imports / ResumeImportをgrepした結果、すべての書き込みパスがSQLAlchemy ORM(routers/resume_imports.py、
    services/tasks/handlers/resume_import.py、およびdb_session経由のテスト)を経由していることが分かりました。 ORMのみを使用するライターの場合、モデルのonupdate=func.now() (resume_import.py:57) は更新のたびに実行されます。

この発見で想定されている「生のSQL/その他のクライアント」は存在しません。

  1. 兄弟テーブルとの一貫性。blog.py、cache.py、resume.pyはすべて、データベース側のトリガーなしで、onupdate=func.now()のみを使用する同じパターンを使用しています。resume_importsにのみトリガーを追加すると、

実際の問題を解決することなく、一貫性のない規約を作成することになります。

コードの変更はありません。検証対象はありません。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusuke0610 ご説明ありがとうございます。3点の理由はいずれも正確で、私の指摘は誤りでした。Turso/libSQL(SQLite 互換)環境では PL/pgSQL のトリガー構文は使用できず、書き込みパスもすべて SQLAlchemy ORM 経由であるため onupdate=func.now() で十分に機能します。また、既存テーブルとの一貫性を保つ判断も理にかなっています。失礼しました。

(´・ω・`)


✏️ Learnings added
Learnt from: yusuke0610
Repo: yusuke0610/devforge PR: 265
File: backend/alembic_migrations/versions/0032_add_resume_imports.py:46-51
Timestamp: 2026-05-22T06:21:50.368Z
Learning: The database engine for this project (yusuke0610/devforge) is Turso/libSQL (SQLite-compatible), NOT PostgreSQL. PostgreSQL-specific features such as PL/pgSQL trigger functions (CREATE FUNCTION ... RETURNS TRIGGER) are not supported. All migration files under backend/alembic_migrations/versions/ use SQLite-compatible SQL only, and no DB-side triggers are used anywhere in the codebase. All write paths go through the SQLAlchemy ORM, so onupdate=func.now() in model definitions (e.g., backend/app/models/) is sufficient for auto-updating timestamp columns like updated_at. Sibling models (blog.py, cache.py, resume.py) all follow this same pattern without DB-side triggers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

)


def downgrade() -> None:
op.drop_table("resume_imports")
Loading
Loading