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: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ public/generated/*

tsconfig.tsbuildinfo
worklog/
status/
status/
.playwright-mcp/
.playwright-cli/
35 changes: 35 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# LESSONS

開発で得た教訓・パターンの記録。同じミスを繰り返さないためのルール集。

## PRレビュー・マージ運用 (2026-03-17)

### Jules (AI) 生成PRは型安全性とロギングの品質チェックが必須
- Jules が生成したPR #42 (logger) と #43 (バッチ最適化) で、`any` 型の乱用やスタックトレース消失など品質問題が複数発見された
- **ルール**: AI生成PRは特に以下を重点チェックする — (1) `any` 型が不必要に使われていないか (2) Error処理でスタック情報が失われていないか (3) テストが正しいランナー(Vitest)で実行されているか

### logger で Error オブジェクトを加工する際はスタックトレースを保持する
- PR #42 の `formatArg` が `err.message` のみ返却し、スタックトレースが完全に消失していた。デバッグが極めて困難になる
- **ルール**: Error を文字列化する場合は `arg.stack || arg.message` を使う。`arg.message` 単体での変換は禁止

### 競合する複数PRのマージ順序を事前に計画する
- PR #42 (logger) と #43 (バッチ最適化) が `auto-adopt.ts` で競合。#43を先にマージ → #42をリベースして解決した
- **ルール**: 同じファイルを変更する複数PRがある場合、依存関係の少ない方を先にマージし、残りをリベースする。マージ前にファイル重複を `gh pr diff` で確認する

### サブエージェントの worktree は権限不足で失敗することがある
- `isolation: "worktree"` でエージェントを起動したが、Edit/Bash 権限が拒否されて作業できなかった
- **ルール**: worktree エージェントが権限問題で失敗した場合は、メインプロセスで直接対応に切り替える。権限モードは `auto` または `bypassPermissions` を使う

## セキュリティ修正 (2026-03-29)

### adopt/reject 投票は「誰でも可」が意図された設計
- スキーマにルームメンバーシップテーブルがなく、認証済みユーザーなら誰でも投票可能。IDORに見えるが仕様
- **ルール**: adopt/reject ルートにルームメンバーシップ制限を追加しない。将来メンバーシップテーブルを追加する場合は vote ルートも合わせて変更する

### 高コスト操作の新規エンドポイントにはレートリミッターを使う
- `src/lib/rate-limit.ts` にインメモリ Map ベースの `checkRateLimit(limiterId, key, max, windowMs)` が実装済み
- **ルール**: 画像生成など外部API費用が発生する操作には必ず `checkRateLimit()` を適用する。サーバー再起動でリセットされるが単一インスタンスには十分

### tile.imageUrl は常にローカルパス。HTTP URL を loadReferenceImage に渡さない
- SSRF防止のため `loadReferenceImage()` は HTTP/HTTPS URL を意図的に拒否する。DALL-E URLのダウンロードは `downloadAndUpload()` が直接行う
- **ルール**: `tile.imageUrl` に外部URLを格納しない。`/placeholder.png` または `/generated/{cuid}.png` のみ。HTTP対応をこの関数に追加しないこと
8 changes: 7 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@
- [x] Issue #21: `run/route.ts` の認可チェック不足を修正
> expansion 作成者でもルームオーナーでもないユーザーは 403 を返すよう修正。`forbidden` を import 追加。`route.test.ts` の prisma モックに `room.findUnique` を追加し、既存テストの expansion モックに `createdByUserId` を設定。

### Steps(セキュリティ修正 — 2026-03-29)
- [x] `GET /api/rooms`: 認証チェックなしで全ルーム一覧が取得可能だった問題を修正(`getUserIdFromSession` 追加)
- [x] SSRF防止: `loadReferenceImage()` の HTTP/HTTPS フェッチを削除(tile.imageUrl は常にローカルパスのみ)
- [x] レートリミッター新設: `src/lib/rate-limit.ts`(インメモリ Map ベース)
> `/api/expansions/[id]/run`: 5回/10分/ユーザー、`/api/users`: 20回/時間/IP

### Steps(初期タイル拡張セル表示不具合修正 — PR #31)
- [x] `page.tsx`: generate-initial 完了後に `refetch()` を呼び、リロードなしで拡張セルを表示
- [x] `use-room.ts`: SSE ポーリング制御の修正(`ready` イベント依存 → `room_update` + `onopen` で停止)
Expand Down Expand Up @@ -140,7 +146,7 @@
- [x] `openai` npm パッケージの追加
- [x] `sharp` npm パッケージの追加(マスク画像生成・RGBA変換に必須)
- [x] `DallE2ImageGenProvider` の実装 (`src/lib/image-gen/dalle2-provider.ts`)
- `referenceImageUrl` の外部 URL(http/https)対応済み。`loadReferenceImage()` で fetch/readFileSync を分岐(Issue #8 解決)
- ~~`referenceImageUrl` の外部 URL(http/https)対応済み。`loadReferenceImage()` で fetch/readFileSync を分岐(Issue #8 解決)~~ → SSRF防止のため HTTP/HTTPS フェッチを削除済み(2026-03-29)。tile.imageUrl は常にローカルパスのみ
- 参照画像を PNG (RGBA) へ変換(`sharp` を使用)
- `direction`(`N`/`E`/`S`/`W`)に応じてアルファチャンネル付きマスク画像を生成(境界辺を保持側に修正済み)
- OpenAI `images.edit()` へ参照画像・マスク・プロンプトを渡す
Expand Down
21 changes: 21 additions & 0 deletions VISION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# VISION

## なぜ作るのか

「複数人が一緒に使える AI アプリケーション」を作りたかった。

多くの AI ツールは一人で使うものだが、Gen-Jigsaw は複数人が同じキャンバスに集まり、それぞれが AI を使って世界を広げていく体験を目指している。AI は個人の道具ではなく、共同創造の素材。

## 何を作るのか

参加者が協力して一つの世界を描き続けるアプリ。

- 誰かがタイルを生成すると、その隣に別の誰かが続きを生成できる
- AI(アウトペインティング)が境界を自然につなぎ、世界は継ぎ目なく広がっていく
- 生成された候補はみんなで投票して採用・却下する

## 大切にすること

- **参加すること自体が楽しい** — 生成結果の善し悪しより、誰かと一緒に作っている感覚
- **世界の一貫性** — タイルがつながって見えること。隣接コンテキストを活用したシームレスな生成
- **シンプルな操作** — AI や技術の知識がなくても、すぐに参加できる
19 changes: 19 additions & 0 deletions src/app/api/expansions/[id]/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import { getImageGenProvider } from "@/lib/image-gen";
import { getUserIdFromSession } from "@/lib/auth";
import { emitRoomEvent } from "@/lib/sse-emitter";
import { logger } from "@/lib/logger";
import { checkRateLimit } from "@/lib/rate-limit";
import type { Direction } from "@/types";

const IMAGE_GEN_RATE_LIMIT_MAX = 5;
const IMAGE_GEN_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10分

export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
Expand All @@ -33,6 +37,21 @@ export async function POST(
});
if (!expansion) return notFound("Expansion not found");

if (!checkRateLimit("image-gen", userId, IMAGE_GEN_RATE_LIMIT_MAX, IMAGE_GEN_RATE_LIMIT_WINDOW_MS)) {
// QUEUED のまま残すと UI でセルが永久にブロックされるため FAILED に変更してロックを解放する
await prisma.$transaction([
prisma.expansion.updateMany({
where: { id, status: "QUEUED" },
data: { status: "FAILED" },
}),
prisma.lock.deleteMany({
where: { roomId: expansion.roomId, x: expansion.targetX, y: expansion.targetY },
}),
]);
emitRoomEvent(expansion.roomId, "room_update");
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}

if (
expansion.createdByUserId !== userId &&
expansion.room.ownerUserId !== userId
Expand Down
5 changes: 4 additions & 1 deletion src/app/api/rooms/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { CreateRoomSchema } from "@/lib/validation";
import { createId } from "@paralleldrive/cuid2";
import { getUserIdFromSession } from "@/lib/auth";

export async function GET() {
export async function GET(req: NextRequest) {
const userId = await getUserIdFromSession(req);
if (!userId) return unauthorized("Login required");

const rooms = await prisma.room.findMany({
orderBy: { createdAt: "desc" },
include: { owner: { select: { displayName: true } } },
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@ import { badRequest } from "@/lib/errors";
import { CreateUserSchema } from "@/lib/validation";
import { createId } from "@paralleldrive/cuid2";
import { setSession } from "@/lib/session";
import { checkRateLimit } from "@/lib/rate-limit";

const USER_CREATE_RATE_LIMIT_MAX = 20;
const USER_CREATE_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1時間

export async function POST(req: NextRequest) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
// プロキシなし環境では全リクエストが "unknown" になりグローバルブロックが起きるためスキップ
if (ip !== "unknown" && !checkRateLimit("user-create", ip, USER_CREATE_RATE_LIMIT_MAX, USER_CREATE_RATE_LIMIT_WINDOW_MS)) {
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}

const body = await req.json().catch(() => null);
const parsed = CreateUserSchema.safeParse(body);
if (!parsed.success) return badRequest(parsed.error.message);
Expand Down
4 changes: 1 addition & 3 deletions src/lib/image-gen/dalle2-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ async function sleep(ms: number) {

export async function loadReferenceImage(imageUrl: string): Promise<Buffer> {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
const res = await fetch(imageUrl);
if (!res.ok) throw new Error(`Failed to fetch reference image: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
throw new Error("External image URLs are not supported");
}
const trimmed = imageUrl.startsWith("/") ? imageUrl.slice(1) : imageUrl;
const publicDir = join(process.cwd(), "public");
Expand Down
46 changes: 46 additions & 0 deletions src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
interface RateLimitEntry {
count: number;
resetAt: number;
}

const stores = new Map<string, Map<string, RateLimitEntry>>();

// 期限切れエントリを5分ごとに削除してメモリリークを防ぐ
setInterval(() => {
const now = Date.now();
for (const store of stores.values()) {
for (const [key, entry] of store.entries()) {
if (now > entry.resetAt) {
store.delete(key);
}
}
}
}, 5 * 60 * 1000);

/**
* シンプルなインメモリレートリミッター。
* @param limiterId リミッターの識別子(例: "image-gen")
* @param key ユーザーIDやIPアドレスなどのキー
* @param maxRequests ウィンドウ内の最大リクエスト数
* @param windowMs ウィンドウのミリ秒
* @returns true = 許可, false = 超過
*/
export function checkRateLimit(
limiterId: string,
key: string,
maxRequests: number,
windowMs: number
): boolean {
if (!stores.has(limiterId)) stores.set(limiterId, new Map());
const store = stores.get(limiterId)!;
const now = Date.now();
const entry = store.get(key);

if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs });
return true;
Comment on lines +39 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prune stale limiter buckets to bound memory growth

The new in-memory limiter never evicts old keys from stores; it only overwrites entries when the same key is seen again. With endpoints that accept arbitrary caller keys (notably /api/users), repeated requests using unique keys will make this map grow indefinitely even after windows expire, which can become a memory-exhaustion path over time. Add expiration cleanup (sweep or bounded cache) so stale buckets are removed.

Useful? React with 👍 / 👎.

}
if (entry.count >= maxRequests) return false;
entry.count++;
return true;
}
Comment on lines +28 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

このインメモリレートリミッターの実装は、エントリをクリーンアップする仕組みがないため、時間とともにメモリリークを引き起こす可能性があります。storesマップは、新しいキー(ユーザーIDやIPアドレス)が追加されるたびに増大し続けます。期限切れのエントリは、再度アクセスされるまでメモリから削除されません。

この問題を解決するために、期限切れのエントリを定期的に削除するクリーンアップ処理を実装することをお勧めします。例えば、setInterval を使用して、数分ごとに stores マップを走査し、now > entry.resetAt となったエントリを削除する方法が考えられます。

// クリーンアップ処理の例(別途実装が必要)
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5分ごと

setInterval(() => {
  const now = Date.now();
  for (const store of stores.values()) {
    for (const [key, entry] of store.entries()) {
      if (now > entry.resetAt) {
        store.delete(key);
      }
    }
  }
}, CLEANUP_INTERVAL_MS);

Loading