diff --git a/.gitignore b/.gitignore index 46e8f011..c36b7842 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,10 @@ firebase-service-account.json .claude/ .granite/ .playwright-mcp/ + +# 이미지/스크린샷 +*.png +*.psd +*.jpg +*.jpeg +supabase/.temp/ diff --git a/.mcp.json b/.mcp.json index 040a4a9f..fc28f6f3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -16,6 +16,13 @@ "supabase": { "type": "http", "url": "https://mcp.supabase.com/mcp?project_ref=mksvyxbnpowolmguaiye" + }, + "pixellab": { + "type": "http", + "url": "https://api.pixellab.ai/mcp", + "headers": { + "Authorization": "Bearer a9203b01-384c-461f-a0f8-345ab8befa42" + } } } } diff --git a/docs/adsense-appeal-log.md b/docs/adsense-appeal-log.md new file mode 100644 index 00000000..e3b60e09 --- /dev/null +++ b/docs/adsense-appeal-log.md @@ -0,0 +1,117 @@ +# AdSense 계정 정지 & 이의신청 기록 + +## 계정 정보 +- **게시자 코드**: pub-6117642671440103 +- **이메일**: designartkor@gmail.com +- **상태**: 무효 트래픽으로 영구 정지 + +--- + +## 타임라인 + +| 날짜 | 이벤트 | +|------|--------| +| 2026-02-05 | adService.ts 광고 모듈 추가 (git: a46a8e5) | +| 2026-02-07 | AdSense 승인 심사 1차 거부 | +| 2026-02-10 | 정책위반 수정 (robots.txt, noindex, 개인정보처리방침 등) | +| 2026-02-?? | AdSense 승인 심사 2차 거부 | +| 2026-02-15 | 계정 사용중지 통보 (무효 트래픽) | +| 2026-02-15 | localhost 스크립트 차단 수정 (git: 7f9b1b2) | +| 2026-02-15 | 1차 이의신청 제출 | +| 2026-02-?? | 2차 이의신청 제출 | +| 2026-02-24 | 2차 이의신청 기각 메일 수신 | +| 2026-02-24 | 3차 이의신청 제출 (아래 내용) | + +--- + +## 정지 원인 분석 + +### 진짜 원인: localhost에서 AdSense 스크립트 로드 + +``` +adService.ts의 loadAdScript() + ↓ +환경 체크 없이 pagead2.googlesyndication.com 로드 + ↓ +localhost:9002에서 개발할 때마다 스크립트 반복 로드 + ↓ +Google이 "무효 트래픽"으로 자동 감지 + ↓ +승인도 안 된 상태에서 계정 영구 정지 +``` + +### 핵심 팩트 +- **광고 승인**: 2번 거부됨 (승인된 적 없음) +- **프로덕션 광고 노출**: 0회 +- **수익**: $0 (한 푼도 벌지 못함) +- **부정클릭**: 불가능 (광고가 표시된 적 없으므로) +- **외부 트래픽**: Threads에 공유해서 일부 방문자 있었으나, 광고 미승인이라 노출 없음 + +### 공수달력(work-schedule-calculator)은 무관 +- AdSense 코드 자체가 없음 +- Toss 자체 광고 SDK (@apps-in-toss/framework) 사용 +- 광고 ID: `ait.v2.live.5793197bc89e40e4` (토스 발급, AdSense 아님) +- 수익 정산도 토스를 통해서 (Google 계정 연동 없음) +- [공식 문서 확인](https://developers-apps-in-toss.toss.im/ads/develop.html): 개발자 AdMob/AdSense 계정 불필요 + +### 수정 완료 (2026-02-15) +```typescript +// src/lib/adService.ts - loadAdScript() +if (process.env.NODE_ENV !== 'production') { + console.log('[AdService] 프로덕션 환경이 아니므로 광고 스크립트를 로드하지 않습니다.'); + resolve(); + return; +} +``` + +--- + +## 3차 이의신청 내용 (2026-02-24 제출) + +### 트래픽 경로 +> This is a small indie game project (text-based RPG). I shared the site link on Threads (social media) to get early feedback from friends and followers. No paid traffic or promotional campaigns were used. The site is still in early development with minimal organic traffic. + +### 정책 위반 여부 (이전 이의신청 정정 포함) +> CORRECTION FROM PREVIOUS APPEAL: In my previous appeals, I mentioned another app project (a Toss mini-app called "work-schedule-calculator"). After thorough investigation, I confirmed that app uses Toss's own proprietary ad SDK (@apps-in-toss/framework), which is 100% unrelated to Google AdSense. It has no connection to my AdSense account whatsoever. +> +> The only project connected to my AdSense account is "GIB" (https://gib-game-phi.vercel.app). My AdSense application was REJECTED TWICE during review - ads were never approved and never served in production. The ad module (adService.ts) was added on Feb 5, 2026 to prepare for future monetization, but the script inadvertently loaded on localhost during development because there was no environment check. + +### 무효 활동 원인 +> The invalid traffic was caused by a development environment error in my game project "GIB". +> +> Technical details: +> - On Feb 5, 2026 (git commit: a46a8e5), I added an ad service module (src/lib/adService.ts) that loads the AdSense script from pagead2.googlesyndication.com. +> - The loadAdScript() function did NOT have an environment check, so the script loaded every time I ran the development server on localhost:9002. +> - This caused repeated script loads from my development machine, which Google's system detected as invalid traffic. +> - My AdSense application was rejected twice - ads were NEVER approved, NEVER displayed in production, and NEVER clicked by anyone. +> - Total ad impressions: 0. Total earnings: $0. +> - There was absolutely zero motivation for click fraud because no ads were ever shown to any user. +> +> This was purely a coding mistake by a solo developer, not intentional invalid activity. + +### 트래픽 품질 개선 계획 +> The issue has already been permanently fixed. +> +> On Feb 15, 2026 (git commit: 7f9b1b2), I added an environment check to block the AdSense script from loading outside production: +> +> if (process.env.NODE_ENV !== 'production') { +> console.log('[AdService] Not production - blocking ad script load'); +> return; +> } +> +> This ensures the AdSense script can NEVER load on localhost or any non-production environment again. The fix has been deployed and verified. + +### 의심스러운 IP/리퍼러 +> The invalid traffic primarily originated from my personal development machine (localhost:9002). During development, the AdSense script loaded repeatedly from pagead2.googlesyndication.com without an environment check. +> +> Note: I did share the site URL on Threads (social media), so there was some external visitor traffic. However, since my AdSense application was REJECTED TWICE and never approved, NO ads were ever displayed to any visitor. The AdSense script loaded in the background but served zero impressions. Therefore, no invalid clicks could have occurred from external visitors either — there were simply no ads to click. +> +> The traffic pattern from my development machine (single IP, repeated script loads during coding sessions) is the most likely source of the detected invalid activity. + +--- + +## 다음 단계 + +- [ ] 3차 이의신청 결과 대기 +- [ ] 기각 시 → 90일 후 (2026-05-25 이후) 재이의 검토 +- [ ] 대안 광고 플랫폼 검토 (카카오 애드핏, 쿠팡 파트너스 등) diff --git a/docs/reports/2026-02-17/verification-report.md b/docs/reports/2026-02-17/verification-report.md index 7fec5ac2..267bd357 100644 --- a/docs/reports/2026-02-17/verification-report.md +++ b/docs/reports/2026-02-17/verification-report.md @@ -1,18 +1,18 @@ -# GIB Verification Report (2026-02-17) +# GIB Verification Report (2026-02-17) - Run 3 -## Overall Score: 68/100 (Grade C) +## Overall Score: 76/100 (Grade B) -| Skill | Score | PASS | WARN | FAIL | Grade | -|-------|-------|------|------|------|-------| -| verify-clean-architecture | 91 | 9 | 2 | 0 | A | -| verify-security | 81 | 5 | 3 | 0 | B | -| verify-code-duplication | 67 | 3 | 2 | 1 | C | -| verify-test-coverage | 50 | 2 | 3 | 2 | C | -| verify-type-safety | 50 | 3 | 0 | 3 | C | +| Skill | Score | PASS | WARN | FAIL | Grade | vs Run 2 | +|-------|-------|------|------|------|-------|----------| +| verify-clean-architecture | 88 | 9 | 3 | 0 | B | 0 | +| verify-security | 94 | 7 | 1 | 0 | A | 0 | +| verify-code-duplication | 51 | 5 | 8 | 5 | C | -16 | +| verify-test-coverage | 75 | 4 | 4 | 0 | B | 0 | +| verify-type-safety | 75 | 3 | 1 | 2 | B | 0 | --- -## verify-clean-architecture (91/100 - A) +## verify-clean-architecture (88/100 - B) | # | Check Item | Result | Details | |---|-----------|--------|---------| @@ -20,118 +20,174 @@ | 2 | 1.2 Data Layer Direction | PASS | Zero forbidden imports in src/data/ | | 3 | 1.3 Presentation Direction | PASS | Zero forbidden imports in src/presentation/ | | 4 | 2.1 Supabase Type Leakage | PASS | No Supabase types in domain layer | -| 5 | 2.2 Repository Compliance | PASS | All Supabase repos implement domain interfaces | +| 5 | 2.2 Repository Compliance | WARN | StorageRepository interface defined in data/ instead of domain/repositories/ | | 6 | 2.3 Entity Independence | PASS | Entities are pure type definitions | -| 7 | 3.1 Game Formulas Location | WARN | Known legacy: useInventoryManager.tsx (HP/MP recovery), useGameActions.ts (death penalty), useBattleLogic.ts (mergeRewardInventory) | -| 8 | 3.2 Component State Mutation | PASS | No direct player state mutation in components (display-only Math usage found) | -| 9 | 3.3 UseCase Purity | PASS | Zero side effects in usecases (console.warn removed in working tree) | -| 10 | 4.1 Legacy Service Usage | WARN | dataService still used in admin pages (expected - migration P2). Game pages/components/hooks clean | +| 7 | 3.1 Game Formulas Location | WARN | Known legacy: useInventoryManager.tsx (HP/MP recovery), useGameActions.ts (death penalty), useBattleLogic.ts (HP reduction) | +| 8 | 3.2 Component State Mutation | WARN | StorageView.tsx (gold transfer) and CharacterView.tsx (gold deduction) mutate player state directly | +| 9 | 3.3 UseCase Purity | PASS | Zero side effects in usecases (console.warn/error removed) | +| 10 | 4.1 Legacy Service Usage | PASS | dataService in admin pages only (expected P2) | | 11 | 4.2 Legacy Hook in New Code | PASS | Zero legacy hook imports in src/presentation/ | -**INFO (4.3)**: 30 domain usecases vs 11 legacy hook files. Game pages still import from `@/hooks/` (useGameEngine, useAuth, useDemoCleanup). +**INFO (4.3)**: 27 domain usecases vs 13 legacy hook files. --- -## verify-security (81/100 - B) +## verify-security (94/100 - A) | # | Check Item | Result | Details | |---|-----------|--------|---------| | 1 | Hardcoded Secrets | PASS | Zero hardcoded secrets in src/ | -| 2 | Env File Git Safety | PASS | `.env*` in .gitignore, no env files tracked | -| 3 | NEXT_PUBLIC_ Audit | PASS | Only safe vars: SUPABASE_URL, SUPABASE_ANON_KEY, R2_URL, ADSENSE_CLIENT_ID, APP_VERSION | -| 4 | API Route Auth | WARN | `/api/admin/asset-generation/health/route.ts` missing `requireAdmin()` - exposes env var status | -| 5 | Supabase RLS | WARN | `wsc_*` 3 tables RLS disabled + `legends`, `lore_entries`, `quests` have `Anyone can INSERT/UPDATE/DELETE` (anon users can modify game data) | -| 6 | Service Role Key Usage | PASS | supabaseAdmin only in src/app/api/ and src/lib/supabaseAdmin.ts | +| 2 | Env File Git Safety | PASS | `.env*` in .gitignore, no env files tracked, no history | +| 3 | NEXT_PUBLIC_ Audit | PASS | Only safe vars exposed | +| 4 | API Route Auth | PASS | All 23 routes protected (requireAdmin added to health endpoint) | +| 5 | Supabase RLS | WARN | `wsc_*` 3 tables RLS disabled + `user_credits`, `quest_chapters`, `assets`, `worldbuilding_logs` have overly permissive policies | +| 6 | Service Role Key Usage | PASS | supabaseAdmin only in server-side code | | 7 | Response Data Leak | PASS | No secrets in API responses | -| 8 | Rate Limiting | WARN | No rate limiting anywhere. `/api/demo/start` allows unlimited anonymous character creation | - -**Critical Recommendations:** -- `legends`, `lore_entries`, `quests` RLS: Change "Anyone can" to admin-only policies -- `/api/demo/start`: Add IP-based rate limiting -- `wsc_*` tables: Enable RLS or remove from public schema +| 8 | Rate Limiting | PASS | IP-based rate limiting on /api/demo/start (3 req / 5 min) | --- -## verify-code-duplication (67/100 - C) +## verify-code-duplication (51/100 - C) - DEEP SCAN + +> Run 3: Full deep scan with 6 parallel agents. Previous runs only did surface-level checks. | # | Check Item | Result | Details | |---|-----------|--------|---------| -| 1 | Component Family | WARN | SelectorDialog family: 7 members (Monster, Item, Skill, World, Dungeon, Recipe, Asset) with 80%+ structural similarity. All use `isOpen/onOpenChange/onSelect` pattern consistently, but no shared base component | -| 2 | Duplicate Components | PASS | No duplicate components across directories | -| 3 | Utility Functions | PASS | No duplicate utility functions found | -| 4 | Type Definitions | FAIL | `ChatMessage` defined in BOTH `src/lib/types.ts:19` AND `src/domain/entities/ChatMessage.ts:7` | -| 5 | Business Logic | WARN | 25+ admin hooks share identical CRUD/search/sort patterns (searchTerm, setSearchTerm, useState boilerplate). 6 settings hooks and 11 journal/worldbuilding hooks follow same template | -| 6 | Constants/Config | PASS | No significant constant duplication | - -**Recommended Actions:** -- Remove `ChatMessage` from `src/lib/types.ts` (use domain entity only) -- Consider extracting `useGenericAdmin` base hook for shared CRUD patterns -- Consider `GenericSelectorDialog` base component (lower priority - pattern is consistent) +| 1 | SelectorDialog Family | WARN | **8 members, 92% similarity, 1,719 lines total.** All share identical Dialog+Search+Sort+Table+Footer pattern. No base component exists. | +| 2 | AdminSection Family | WARN | **6 members, 88% similarity, 3,114 lines total.** All share identical CRUD+Table+Dialog+DeleteConfirm pattern. No base component exists. | +| 3 | ClientErrorBoundary wrapper | FAIL | 8-line wrapper (`ClientErrorBoundary.tsx`) that just re-exports ErrorBoundary. Adds zero value. | +| 4 | Equipment slot arrays | WARN | Identical LEFT/CENTER/RIGHT slot arrays in `CharacterView.tsx` and `character-selection/page.tsx` | +| 5 | Other duplicate components | PASS | No true duplicate components found across directories | +| 6 | Admin CRUD hooks (17 hooks) | FAIL | **95%+ identical boilerplate** across 17 hooks (settings 6 + journal 6 + worldbuilding 4+). Same useState/useCallback/useMemo structure. ~4,500 lines. | +| 7 | Sort/filter logic | FAIL | **100% identical** `requestSort()` + `sortEntities()` implementation in all 17 admin hooks | +| 8 | Skill legacy compat code | WARN | `getSkillDetails()` duplicated in ProcessSkillAction.ts and SkillFilterUtils.ts; legacy activation check scattered | +| 9 | R2 utils, formatNumber | PASS | Well consolidated in src/lib/r2.ts and src/lib/utils.ts | +| 10 | AssetCategory type | FAIL | Domain (8 categories) vs AssetSelectorDialog (6 categories) with different values. 20+ files affected, alias workaround in assets/page.tsx | +| 11 | Other type definitions | PASS | Domain entities well organized in entities/index.ts (31 types) | +| 12 | Inventory capacity check | FAIL | Same check repeated **3 times within** ProcessMonsterReward.ts (lines 140-210) + once in useInventoryManager.tsx | +| 13 | Case conversion functions | WARN | Identical toCamelCase/toSnakeCase in both `dataService.ts` and `data/utils/caseConverter.ts` | +| 14 | Domain usecases | PASS | CalculateDamage, BattleGauge etc. well separated with single responsibility | +| 15 | R2 URL hardcoded | WARN | `pub-xxx.r2.dev` in r2.ts, r2Config.ts, next.config.ts (4+ places) | +| 16 | Supabase table names | WARN | String literals ('characters', 'monsters', etc.) in 20+ files without constants | +| 17 | Bearer token parsing | WARN | `authHeader.startsWith('Bearer ')` + `slice(7)` repeated in 4 files | +| 18 | Battle/timing constants | PASS | ATTACK_THRESHOLD, MIN_GAUGE_RATE already properly defined | + +### Score Calculation +- PASS: 5 items x 100 = 500 +- WARN: 8 items x 50 = 400 +- FAIL: 5 items x 0 = 0 +- Total: 900 / 18 = 50 -> rounded to **51** --- -## verify-test-coverage (50/100 - C) +### Deep Scan Details + +#### SelectorDialog Family (8 members) + +| Component | Lines | Specialization | +|-----------|-------|---------------| +| MonsterSelectorDialog | 179 | Image + level | +| ItemSelectorDialog | 239 | Multi-select + type filter + disabled reasons | +| SkillSelectorDialog | 299 | Category filter + multi-sort | +| WorldSelectorDialog | 129 | Simplest | +| DungeonSelectorDialog | 297 | Multi-select + recommended level | +| RecipeSelectorDialog | 270 | Multi-select | +| AssetSelectorDialog | 170 | API-based, grid layout | +| DungeonMonsterSelectorDialog | 136 | Slider for spawn chance | + +**Identical code in all 8:** +``` +useState: searchTerm, sortConfig +useMemo: filteredAndSorted with .toLowerCase().includes() +JSX: +Empty: "등록된 X가 없습니다." / "검색 결과가 없습니다." +Image: getAssetUrl() + fallback icon +``` + +**Recommendation:** `GenericSelectorDialog` base component -> 1,719 lines to ~450 (74% reduction) + +#### Admin CRUD Hooks (17 hooks, ~4,500 lines) + +**Settings:** useMonsterAdmin (355), useSkillAdmin (245), useItemAdmin (279), useDungeonAdmin (259), useVillageAdmin (244), useCraftingRecipeAdmin (244) + +**Journal:** useQuestAdmin (200), useLegendAdmin (185), useLoreEntryAdmin (185), useQuestChapterAdmin (180), useNpcAdmin (160), useDialogueAdmin (160) + +**Worldbuilding:** useSceneAdmin, useStoryCharacterAdmin, useCharacterRelationshipAdmin, usePlotThreadAdmin + +**Identical boilerplate (100%):** +``` +1. useState: entities[], isLoading, error, editing, isDialogOpen, searchTerm, sortConfig +2. useCallback: loadEntities -> repository.getAll() +3. useCallback: addEntity -> new entity with Date.now() ID +4. useCallback: editEntity -> shallow clone +5. useCallback: handleChange -> setState with [field]: value +6. useCallback: saveEntity -> repository.save() -> loadEntities() +7. useCallback: deleteEntity -> repository.delete() -> loadEntities() +8. useCallback: closeDialog +9. useCallback: requestSort -> toggle direction +10. useMemo: sortEntities -> generic string/number sort +11. useMemo: filteredEntities -> search + sort +``` + +**Recommendation:** `useAdminEntity()` generic hook -> 4,500 lines to ~600 (87% reduction) + +--- + +## verify-test-coverage (75/100 - B) | # | Check Item | Result | Details | |---|-----------|--------|---------| -| 1 | Domain UseCases (Must) | PASS | 30/30 covered | -| 2 | Data Repositories (Must) | FAIL | 21/25 covered. Missing: SupabaseCharacterRelationshipRepository, SupabasePlotThreadRepository, SupabaseSceneRepository, SupabaseStoryCharacterRepository | -| 3 | Presentation Hooks (Must) | FAIL | 3/7 covered. Missing: useBattleFeedback, useDamagePopup, useQuestBoard, useTutorial | +| 1 | Domain UseCases (Must) | PASS | 27/27 covered (100%) | +| 2 | Data Repositories (Must) | PASS | 25/25 covered (100%) | +| 3 | Presentation Hooks (Must) | PASS | 7/7 covered (100%) | | 4 | Domain Entities (Should) | WARN | 2/8 logic files covered. Missing: AssetGenerationJob, CharacterClass, CraftingRecipe, Dungeon, Village, World | -| 5 | Admin Hooks (Should) | WARN | 6/17 covered. Missing: 6 journal hooks + 4 worldbuilding hooks + useWorldEditor | -| 6 | Lib Utilities (Should) | WARN | Missing: r2Config.ts, adService.ts | -| 7 | Test Execution | PASS | 817 tests in 68 files, ALL PASSING | - -**Test Statistics:** 817 tests, 68 files, 0 failures, 9.5s duration - -**Missing Must-Test Files (8):** -- `src/data/repositories/SupabaseCharacterRelationshipRepository.test.ts` -- `src/data/repositories/SupabasePlotThreadRepository.test.ts` -- `src/data/repositories/SupabaseSceneRepository.test.ts` -- `src/data/repositories/SupabaseStoryCharacterRepository.test.ts` -- `src/presentation/hooks/useBattleFeedback.test.ts` -- `src/presentation/hooks/useDamagePopup.test.ts` -- `src/presentation/hooks/useQuestBoard.test.ts` -- `src/presentation/hooks/useTutorial.test.ts` +| 5 | Admin Hooks (Should) | WARN | 10/17 covered. Missing: useNpcAdmin, useDialogueAdmin, useSceneAdmin, useStoryCharacterAdmin, useCharacterRelationshipAdmin, usePlotThreadAdmin, useWorldEditor | +| 6 | Lib Utilities (Should) | WARN | 3/10 covered. Missing: utils, r2, r2Config, apiAuth, adService, freesound, supabase | +| 7 | Test Execution | PASS | 928 tests in 76 files, ALL PASSING | --- -## verify-type-safety (50/100 - C) +## verify-type-safety (75/100 - B) | # | Check Item | Result | Details | |---|-----------|--------|---------| -| 1 | Explicit `any` | FAIL | MUST-check dirs: `(baseStats as any)` in CalculateBuffBonus.ts:65, `Record` in SupabaseSkillRepository.ts:38-51, multiple `as any` in SupabasePlayerRepository.ts:41-54,65 | -| 2 | Type Assertions (`as`) | FAIL | SupabasePlayerRepository.ts: 14 `as any` casts for JSONB columns. SupabaseSkillRepository.ts: `as any` for row conversion | -| 3 | Suppression Directives | PASS | No `@ts-ignore` or `@ts-nocheck` found in src/ | -| 4 | Non-null Assertions | PASS | No non-null assertions in MUST-check dirs | +| 1 | Explicit `any` | FAIL | ~66 `any` in MUST-check dirs. Top: CalculatePlayerStats.ts (10), SupabasePlayerRepository.ts (12), SupabaseMonsterRepository.ts (11) | +| 2 | Type Assertions (`as`) | WARN | ~47 type assertions in domain/data. Mostly JSONB column casts | +| 3 | Suppression Directives | FAIL | 3 `@ts-ignore` in admin pages | +| 4 | Non-null Assertions | PASS | All 7 have prior null checks | | 5 | Untyped Parameters | PASS | All exported functions properly typed | -| 6 | Dual Type Definitions | FAIL | `ChatMessage` defined in both `src/lib/types.ts` and `src/domain/entities/ChatMessage.ts` | - -**Top Offenders (MUST-check):** -- `src/data/repositories/SupabasePlayerRepository.ts` - 14x `as any` (JSONB column casts) -- `src/data/repositories/SupabaseSkillRepository.ts` - 4x `Record` (key conversion) -- `src/domain/usecases/player/CalculateBuffBonus.ts:65` - 1x `(baseStats as any)` - -**Migration Progress (INFO):** -- Legacy `any` count tracked but not scored (src/hooks/, src/components/, src/app/) +| 6 | Dual Type Definitions | PASS | No duplicates in domain layer | --- ## Priority Actions -### High Priority (FAIL items) -1. **Add 8 missing must-test files** - 4 worldbuilding repos + 4 presentation hooks -2. **Fix ChatMessage dual definition** - Remove from `src/lib/types.ts` -3. **Fix `as any` in domain** - Add proper type for `baseStats` in CalculateBuffBonus.ts -4. **Fix RLS policies** - `legends`, `lore_entries`, `quests` should NOT allow "Anyone" writes - -### Medium Priority (WARN items) -5. **Add rate limiting** - `/api/demo/start` needs IP throttling -6. **Add requireAdmin()** - `/api/admin/asset-generation/health` -7. **Add 23 missing should-test files** - entities, admin hooks, lib utilities -8. **Reduce `as any`** in Supabase repositories - use proper typed mappers - -### Low Priority (Tech Debt) -9. Migrate legacy hook business logic to domain usecases -10. Extract shared base for SelectorDialog/admin hook patterns -11. Enable RLS on `wsc_*` tables +### High Priority + +| # | Action | Impact | Effort | +|---|--------|--------|--------| +| 1 | **Extract `useAdminEntity` generic hook** | -3,900 lines (17 hooks -> 1 generic + thin wrappers) | Medium | +| 2 | **Extract `GenericSelectorDialog` base** | -1,270 lines (8 dialogs -> 1 base + thin wrappers) | Medium | +| 3 | **Unify AssetCategory type** | Fix 20+ file inconsistency | Low | +| 4 | **Extract `checkInventoryCapacity()`** | Fix 3x self-duplication in ProcessMonsterReward.ts | Low | +| 5 | **Reduce `as any` in MUST-check dirs** | Fix 66 type safety violations | Medium | + +### Medium Priority + +| # | Action | Impact | Effort | +|---|--------|--------|--------| +| 6 | **Extract sort/filter utility** (`entitySort.ts`) | Remove identical code from 17 hooks | Low | +| 7 | **Fix RLS policies** (user_credits, quest_chapters, assets, worldbuilding_logs) | Security improvement | Low | +| 8 | **Consolidate case converters** (remove from dataService.ts) | Single source of truth | Low | +| 9 | **Extract Bearer token util** | Remove 4-file duplication | Low | +| 10 | **Create DB table name constants** | 20+ files using string literals | Low | + +### Low Priority + +| # | Action | Impact | Effort | +|---|--------|--------|--------| +| 11 | Remove ClientErrorBoundary wrapper | -8 lines, cleaner imports | Trivial | +| 12 | Extract equipment slot constants | Shared constant for 2 files | Trivial | +| 13 | Centralize R2 URL to r2Config.ts | Remove 4 hardcoded instances | Low | +| 14 | Add missing should-test files (20) | Improve coverage | Medium | +| 15 | Replace 3 `@ts-ignore` with `@ts-expect-error` | Type safety hygiene | Low | diff --git a/e2e-admin-quest-linked.png b/e2e-admin-quest-linked.png deleted file mode 100644 index 9d5f0c82..00000000 Binary files a/e2e-admin-quest-linked.png and /dev/null differ diff --git a/e2e-ingame-dialogue-narration.png b/e2e-ingame-dialogue-narration.png deleted file mode 100644 index 343d1f60..00000000 Binary files a/e2e-ingame-dialogue-narration.png and /dev/null differ diff --git a/e2e-ingame-dialogue-npc.png b/e2e-ingame-dialogue-npc.png deleted file mode 100644 index cfe73560..00000000 Binary files a/e2e-ingame-dialogue-npc.png and /dev/null differ diff --git a/e2e-ingame-dialogue-player.png b/e2e-ingame-dialogue-player.png deleted file mode 100644 index 257ed370..00000000 Binary files a/e2e-ingame-dialogue-player.png and /dev/null differ diff --git a/e2e-ingame-quest-accepted.png b/e2e-ingame-quest-accepted.png deleted file mode 100644 index 6ad0eef0..00000000 Binary files a/e2e-ingame-quest-accepted.png and /dev/null differ diff --git a/src/app/admin/assets/page.tsx b/src/app/admin/assets/page.tsx index 7428c219..13bb93ad 100644 --- a/src/app/admin/assets/page.tsx +++ b/src/app/admin/assets/page.tsx @@ -18,7 +18,7 @@ import { supabase } from '@/lib/supabase'; import { getAssetUrl, getAssetPath } from '@/lib/r2'; import { useAssetGeneration } from '@/presentation/hooks/useAssetGeneration'; import type { AssetCategory } from '@/domain/entities/AssetGenerationJob'; -import { AssetSelectorDialog, type AssetCategory as SelectorAssetCategory } from '@/app/admin/settings/components/AssetSelectorDialog'; +import { AssetSelectorDialog } from '@/app/admin/settings/components/AssetSelectorDialog'; import { FreesoundTab } from './components/FreesoundTab'; import { SoundPlayer } from './components/SoundPlayer'; diff --git a/src/app/admin/settings/components/AssetSelectorDialog.tsx b/src/app/admin/settings/components/AssetSelectorDialog.tsx index ec6c1b45..8e46a9b2 100644 --- a/src/app/admin/settings/components/AssetSelectorDialog.tsx +++ b/src/app/admin/settings/components/AssetSelectorDialog.tsx @@ -15,8 +15,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Image as ImageIcon, Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { supabase } from '@/lib/supabase'; - -export type AssetCategory = 'items' | 'skills' | 'monsters' | 'npcs' | 'villages' | 'worldmaps'; +import type { AssetCategory } from '@/domain/entities/AssetGenerationJob'; +export type { AssetCategory }; export interface AssetItem { name: string; diff --git a/src/app/api/cron/demo-cleanup/route.ts b/src/app/api/cron/demo-cleanup/route.ts index 17b7e26e..6ac915fc 100644 --- a/src/app/api/cron/demo-cleanup/route.ts +++ b/src/app/api/cron/demo-cleanup/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabaseAdmin'; +import { extractBearerToken } from '@/lib/apiAuth'; // NOTE: demo_sessions 테이블과 characters.is_demo 컬럼은 // 마이그레이션 적용 후 database.types.ts 재생성하면 타입 안전해짐. @@ -16,7 +17,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'CRON_SECRET 환경변수가 설정되지 않았습니다.' }, { status: 500 }); } - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + const token = extractBearerToken(authHeader); if (token !== expectedSecret && cronSecret !== expectedSecret) { return NextResponse.json({ error: '인증 실패' }, { status: 401 }); } diff --git a/src/app/api/demo/cleanup/route.ts b/src/app/api/demo/cleanup/route.ts index b0055de2..bda3c4a1 100644 --- a/src/app/api/demo/cleanup/route.ts +++ b/src/app/api/demo/cleanup/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabaseAdmin'; +import { extractBearerToken } from '@/lib/apiAuth'; // NOTE: demo_sessions 테이블과 characters.is_demo 컬럼은 // 마이그레이션 적용 후 database.types.ts 재생성하면 타입 안전해짐. @@ -7,8 +8,7 @@ import { supabaseAdmin } from '@/lib/supabaseAdmin'; export async function POST(req: NextRequest) { try { // 1. JWT 검증 - const authHeader = req.headers.get('authorization') || ''; - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + const token = extractBearerToken(req.headers.get('authorization')); if (!token) { return NextResponse.json({ error: '인증 토큰이 필요합니다.' }, { status: 401 }); diff --git a/src/app/api/demo/start/route.ts b/src/app/api/demo/start/route.ts index 8f0b4133..164934a7 100644 --- a/src/app/api/demo/start/route.ts +++ b/src/app/api/demo/start/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabaseAdmin'; +import { extractBearerToken } from '@/lib/apiAuth'; // NOTE: demo_sessions 테이블과 characters.is_demo 컬럼은 // 마이그레이션 적용 후 database.types.ts 재생성하면 타입 안전해짐. @@ -45,8 +46,7 @@ export async function POST(req: NextRequest) { } // 1. JWT 검증 - const authHeader = req.headers.get('authorization') || ''; - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + const token = extractBearerToken(req.headers.get('authorization')); if (!token) { return NextResponse.json({ error: '인증 토큰이 필요합니다.' }, { status: 401 }); diff --git a/src/app/character-selection/page.tsx b/src/app/character-selection/page.tsx index 49349947..851472df 100644 --- a/src/app/character-selection/page.tsx +++ b/src/app/character-selection/page.tsx @@ -9,6 +9,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardFooter, CardContent } import { Button } from '@/components/ui/button'; import { useAuth } from '@/hooks/useAuth'; import type { Player, Item, CharacterClass, Equipment, CombatAlgorithm } from '@/domain/entities'; +import { EQUIPMENT_SLOT_LAYOUT } from '@/domain/entities'; import type { InfoDialogState } from '@/lib/types'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; import { @@ -63,10 +64,9 @@ function getRelativeTime(dateString?: string): string { return '방금 전'; } -// 장비 슬롯 순서 (3열 레이아웃용) -const LEFT_SLOTS = ['necklace', 'weapon_right', 'gloves', 'ring1'] as const; -const CENTER_SLOTS = ['helmet', 'armor', 'pants', 'shoes'] as const; -const RIGHT_SLOTS = ['earring', 'weapon_left', 'bracelet', 'ring2'] as const; +const LEFT_SLOTS = EQUIPMENT_SLOT_LAYOUT.left; +const CENTER_SLOTS = EQUIPMENT_SLOT_LAYOUT.center; +const RIGHT_SLOTS = EQUIPMENT_SLOT_LAYOUT.right; export default function CharacterSelectionPage() { const { user, loading: authLoading, handleLogout } = useAuth(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4b1bcfe4..ded7c6fd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { Toaster } from "@/components/ui/sonner"; import { QueryClientProvider } from "@/providers/QueryClientProvider"; -import ClientErrorBoundary from "@/components/ClientErrorBoundary"; +import ErrorBoundary from "@/components/ErrorBoundary"; export const metadata: Metadata = { title: "텍스트 아레나", @@ -48,9 +48,9 @@ export default function RootLayout({ - + {children} - + diff --git a/src/components/ClientErrorBoundary.tsx b/src/components/ClientErrorBoundary.tsx deleted file mode 100644 index 038bc3d0..00000000 --- a/src/components/ClientErrorBoundary.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import ErrorBoundary from "./ErrorBoundary"; -import type { ReactNode } from "react"; - -export default function ClientErrorBoundary({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/src/components/views/CharacterView.tsx b/src/components/views/CharacterView.tsx index eb136de0..0c8a9186 100644 --- a/src/components/views/CharacterView.tsx +++ b/src/components/views/CharacterView.tsx @@ -21,6 +21,7 @@ import { cn, getUniqueItemId, formatNumber } from '@/lib/utils'; import { GoldDisplay } from '@/components/ui/GoldDisplay'; import { getAssetUrl } from '@/lib/r2'; import type { Equipment, EquipmentSlot, Stat } from '@/domain/entities'; +import { EQUIPMENT_SLOT_LAYOUT } from '@/domain/entities'; import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; type CharacterTab = 'info' | 'inventory' | 'spirit'; @@ -537,10 +538,9 @@ const CharacterView = (props: any) => { ); case 'inventory': - // 장비창용 슬롯 배열 (캐릭터>장비 탭과 동일한 3열 엇갈린 레이아웃) - const invLeftSlots: EquipmentSlot[] = ['necklace', 'weapon_right', 'gloves', 'ring1']; - const invCenterSlots: EquipmentSlot[] = ['helmet', 'armor', 'pants', 'shoes']; - const invRightSlots: EquipmentSlot[] = ['earring', 'weapon_left', 'bracelet', 'ring2']; + const invLeftSlots = EQUIPMENT_SLOT_LAYOUT.left; + const invCenterSlots = EQUIPMENT_SLOT_LAYOUT.center; + const invRightSlots = EQUIPMENT_SLOT_LAYOUT.right; const compactSlotClass = "w-20 h-20"; // 슬롯 크기 const compactRingClass = "w-16 h-16"; // 반지는 약간 작게 const getCompactSlotClass = (s: EquipmentSlot) => s === 'ring1' || s === 'ring2' ? compactRingClass : compactSlotClass; diff --git a/src/domain/entities/Equipment.ts b/src/domain/entities/Equipment.ts index 4b206021..07a738f7 100644 --- a/src/domain/entities/Equipment.ts +++ b/src/domain/entities/Equipment.ts @@ -19,6 +19,13 @@ export type EquipmentSlot = | 'bracelet' | 'shoes'; +/** 장비 UI 레이아웃: 좌/중/우 슬롯 배치 */ +export const EQUIPMENT_SLOT_LAYOUT = { + left: ['necklace', 'weapon_right', 'gloves', 'ring1'] as const satisfies readonly EquipmentSlot[], + center: ['helmet', 'armor', 'pants', 'shoes'] as const satisfies readonly EquipmentSlot[], + right: ['earring', 'weapon_left', 'bracelet', 'ring2'] as const satisfies readonly EquipmentSlot[], +} as const; + export interface Equipment extends Item { type: 'equipment'; slot: EquipmentSlot; diff --git a/src/lib/apiAuth.ts b/src/lib/apiAuth.ts index e30eafd0..2cca6097 100644 --- a/src/lib/apiAuth.ts +++ b/src/lib/apiAuth.ts @@ -7,6 +7,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabaseAdmin'; +/** + * Authorization 헤더에서 Bearer 토큰 추출 + */ +export function extractBearerToken(authHeader: string | null): string { + if (!authHeader || !authHeader.startsWith('Bearer ')) return ''; + return authHeader.slice(7); +} + /** * 어드민 API 공통 인증 체크 * Bearer 토큰 → JWT 검증 → is_admin 확인 @@ -14,8 +22,7 @@ import { supabaseAdmin } from '@/lib/supabaseAdmin'; * @returns user 객체 (성공) 또는 NextResponse (에러) */ export async function requireAdmin(req: NextRequest): Promise<{ id: string; email?: string } | NextResponse> { - const authHeader = req.headers.get('authorization') || ''; - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + const token = extractBearerToken(req.headers.get('authorization')); if (!token) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); diff --git a/src/lib/entitySort.test.ts b/src/lib/entitySort.test.ts new file mode 100644 index 00000000..bd24c7a2 --- /dev/null +++ b/src/lib/entitySort.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { + toggleSortDirection, + genericSort, + genericSearch, + type SortConfig, +} from './entitySort'; + +interface TestEntity { + id: string; + name: string; + level: number; + category?: string; +} + +const testItems: TestEntity[] = [ + { id: '1', name: '슬라임', level: 1, category: '일반' }, + { id: '2', name: '고블린', level: 5, category: '일반' }, + { id: '3', name: '드래곤', level: 50, category: '보스' }, + { id: '4', name: '오크', level: 10, category: '일반' }, +]; + +describe('toggleSortDirection', () => { + it('새 키를 클릭하면 ascending으로 시작', () => { + const prev: SortConfig = { key: 'name', direction: 'ascending' }; + const result = toggleSortDirection(prev, 'level'); + expect(result).toEqual({ key: 'level', direction: 'ascending' }); + }); + + it('같은 키를 클릭하면 ascending -> descending', () => { + const prev: SortConfig = { key: 'name', direction: 'ascending' }; + const result = toggleSortDirection(prev, 'name'); + expect(result).toEqual({ key: 'name', direction: 'descending' }); + }); + + it('descending 상태에서 같은 키 클릭하면 ascending으로 돌아감', () => { + const prev: SortConfig = { key: 'name', direction: 'descending' }; + const result = toggleSortDirection(prev, 'name'); + expect(result).toEqual({ key: 'name', direction: 'ascending' }); + }); + + it('null key에서 시작해도 동작', () => { + const prev: SortConfig = { key: null, direction: 'ascending' }; + const result = toggleSortDirection(prev, 'name'); + expect(result).toEqual({ key: 'name', direction: 'ascending' }); + }); +}); + +describe('genericSort', () => { + it('key가 null이면 원본 순서 유지', () => { + const config: SortConfig = { key: null, direction: 'ascending' }; + const result = genericSort(testItems, config); + expect(result.map((i) => i.id)).toEqual(['1', '2', '3', '4']); + }); + + it('문자열 ascending 정렬', () => { + const config: SortConfig = { key: 'name', direction: 'ascending' }; + const result = genericSort(testItems, config); + expect(result.map((i) => i.name)).toEqual(['고블린', '드래곤', '슬라임', '오크']); + }); + + it('문자열 descending 정렬', () => { + const config: SortConfig = { key: 'name', direction: 'descending' }; + const result = genericSort(testItems, config); + expect(result.map((i) => i.name)).toEqual(['오크', '슬라임', '드래곤', '고블린']); + }); + + it('숫자 ascending 정렬', () => { + const config: SortConfig = { key: 'level', direction: 'ascending' }; + const result = genericSort(testItems, config); + expect(result.map((i) => i.level)).toEqual([1, 5, 10, 50]); + }); + + it('숫자 descending 정렬', () => { + const config: SortConfig = { key: 'level', direction: 'descending' }; + const result = genericSort(testItems, config); + expect(result.map((i) => i.level)).toEqual([50, 10, 5, 1]); + }); + + it('원본 배열을 변경하지 않음', () => { + const original = [...testItems]; + const config: SortConfig = { key: 'level', direction: 'descending' }; + genericSort(testItems, config); + expect(testItems).toEqual(original); + }); + + it('customAccessor로 파생 값 정렬', () => { + const items: TestEntity[] = [ + { id: '1', name: 'ab', level: 1 }, + { id: '2', name: 'abcde', level: 5 }, + { id: '3', name: 'a', level: 50 }, + { id: '4', name: 'abc', level: 10 }, + ]; + const config: SortConfig = { key: 'nameLength', direction: 'ascending' }; + const result = genericSort(items, config, (item, key) => { + if (key === 'nameLength') return item.name.length; + return (item as Record)[key] as string | number; + }); + expect(result.map((i) => i.name)).toEqual(['a', 'ab', 'abc', 'abcde']); + }); +}); + +describe('genericSearch', () => { + it('빈 검색어면 전체 반환', () => { + const result = genericSearch(testItems, '', ['name']); + expect(result).toHaveLength(4); + }); + + it('이름으로 검색', () => { + const result = genericSearch(testItems, '고블린', ['name']); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('고블린'); + }); + + it('부분 일치 검색', () => { + const result = genericSearch(testItems, '라', ['name']); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('슬라임'); + }); + + it('대소문자 무시', () => { + const items = [{ id: '1', name: 'Dragon', level: 50 }]; + const result = genericSearch(items, 'dragon', ['name']); + expect(result).toHaveLength(1); + }); + + it('여러 필드에서 검색', () => { + const result = genericSearch(testItems, '보스', ['name', 'category']); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('드래곤'); + }); + + it('일치 없으면 빈 배열', () => { + const result = genericSearch(testItems, '없는몬스터', ['name']); + expect(result).toHaveLength(0); + }); + + it('undefined 필드값 안전 처리', () => { + const items = [{ id: '1', name: '슬라임', level: 1 }]; + const result = genericSearch(items, '일반', ['name', 'category']); + expect(result).toHaveLength(0); + }); +}); diff --git a/src/lib/entitySort.ts b/src/lib/entitySort.ts new file mode 100644 index 00000000..1be0d940 --- /dev/null +++ b/src/lib/entitySort.ts @@ -0,0 +1,74 @@ +/** + * 제너릭 엔티티 정렬/필터 유틸리티 + * 어드민 훅에서 반복되는 정렬/검색 로직을 통합 + */ + +export type SortDirection = 'ascending' | 'descending'; + +export interface SortConfig { + key: K | null; + direction: SortDirection; +} + +/** + * 정렬 방향 토글 (ascending -> descending -> ascending) + * 다른 키를 클릭하면 ascending으로 리셋 + */ +export function toggleSortDirection( + prev: SortConfig, + key: K +): SortConfig { + let direction: SortDirection = 'ascending'; + if (prev.key === key && prev.direction === 'ascending') { + direction = 'descending'; + } + return { key, direction }; +} + +/** + * 제너릭 엔티티 정렬 + * 문자열은 localeCompare, 숫자는 산술 비교 + */ +export function genericSort( + items: T[], + config: SortConfig, + customAccessor?: (item: T, key: string) => string | number | undefined +): T[] { + if (!config.key) return items; + + const key = config.key; + const dir = config.direction === 'ascending' ? 1 : -1; + + return [...items].sort((a, b) => { + const valA = customAccessor ? customAccessor(a, key) : (a as Record)[key]; + const valB = customAccessor ? customAccessor(b, key) : (b as Record)[key]; + + if (typeof valA === 'string' && typeof valB === 'string') { + return valA.localeCompare(valB) * dir; + } + if (typeof valA === 'number' && typeof valB === 'number') { + return (valA - valB) * dir; + } + return 0; + }); +} + +/** + * 제너릭 검색 필터 + * 지정된 필드들에서 검색어를 찾음 + */ +export function genericSearch( + items: T[], + searchTerm: string, + searchFields: (keyof T)[] +): T[] { + if (!searchTerm) return items; + + const term = searchTerm.toLowerCase(); + return items.filter((item) => + searchFields.some((field) => { + const val = item[field]; + return typeof val === 'string' && val.toLowerCase().includes(term); + }) + ); +} diff --git a/src/services/dataService.ts b/src/services/dataService.ts index 3ffb9f27..483e738b 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -13,55 +13,7 @@ */ import { supabase } from '@/lib/supabase'; - -// camelCase → snake_case 변환 (Supabase 테이블 컬럼명 호환용) -function toSnakeCase(str: string): string { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); -} - -// snake_case → camelCase 변환 (Supabase에서 읽을 때) -function toCamelCase(str: string): string { - return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); -} - -// 객체의 모든 키를 snake_case로 변환 -function convertKeysToSnakeCase(obj: any): any { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(convertKeysToSnakeCase); - } - - const converted: Record = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = toSnakeCase(key); - // 중첩 객체는 그대로 유지 (JSONB로 저장됨) - // 최상위 필드만 snake_case로 변환 - converted[snakeKey] = value; - } - return converted; -} - -// 객체의 최상위 키를 camelCase로 변환 (JSONB 내부는 그대로 유지) -function convertKeysToCamelCase(obj: any): any { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(convertKeysToCamelCase); - } - - const converted: Record = {}; - for (const [key, value] of Object.entries(obj)) { - const camelKey = toCamelCase(key); - // 중첩 객체(JSONB)는 그대로 유지 - 이미 camelCase로 저장됨 - converted[camelKey] = value; - } - return converted; -} +import { convertKeysToCamelCase, convertKeysToSnakeCase } from '@/data/utils/caseConverter'; // 테이블 이름 매핑 const TABLE_MAP: Record = { diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 00000000..1dd61787 --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.75.0 \ No newline at end of file