memi-board 0.4.5 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,32 +1,57 @@
1
1
  # memi-board
2
2
 
3
- Nuxt 4 + Nuxt UI 프로젝트에 Firebase 기반 게시판을 바로 붙이는 패키지. 게시글 CRUD, 첨부파일(Storage), 댓글, 권한(작성자 본인/관리자), Firebase AI Logic(Gemini) 기반 AI 검열을 제공한다.
3
+ Nuxt 4 + Nuxt UI + Firebase 프로젝트에 **클라이언트 전용 게시판**을 붙이는 npm 패키지.
4
4
 
5
- 클라이언트 전용이다 — 서버나 Firebase Admin SDK가 필요 없다.
5
+ - 게시글 CRUD · 댓글 · Storage 첨부 · 카테고리
6
+ - 작성자/관리자 권한 (Firestore Rules)
7
+ - 로컬 비속어 + Firebase AI Logic(Gemini) 검열 (선택)
8
+
9
+ **서버 / Firebase Admin 불필요.** 권한은 Rules, 검열은 브라우저(우회 가능 — 아래 한계 참고).
10
+
11
+ 같은 스택(Nuxt 4 · `@nuxt/ui` · `nuxt-vuefire` · Firebase)이면 **다른 호스트 앱에도 그대로 재사용** 가능하다. Firebase 프로젝트는 호스트마다 달라도 되고, 같은 프로젝트를 공유해도 `collectionPrefix`로 컬렉션만 나누면 된다.
12
+
13
+ ## 플랫폼 요구사항
14
+
15
+ | 항목 | 요구 |
16
+ |------|------|
17
+ | 런타임 | Nuxt **4+** (3.x peer 는 허용하나 playground/검증은 4 기준) |
18
+ | UI | `@nuxt/ui` **4+** (컴포넌트가 `UButton` 등 auto-import 전제) |
19
+ | 데이터 | Firebase **Firestore + Auth + Storage** |
20
+ | Vue | `vue` 3.5+, `vuefire` 3.2+, `firebase` 12+ |
21
+ | 모듈 | `nuxt-vuefire` (호스트가 설치·설정) |
22
+
23
+ **의도적으로 하지 않는 일:** Firebase 초기화, 라우트/미들웨어 등록, App Check 초기화, 서버 검열.
6
24
 
7
25
  ## 구조 (0.4+)
8
26
 
9
27
  | 엔트리 | 역할 |
10
28
  |--------|------|
11
- | `memi-board` | **core** — `configureMemiBoard`, composables, types |
12
- | `memi-board/nuxt` | **thin Nuxt 모듈** — UI SFC 를 호스트 파이프라인에 등록만 함 |
29
+ | `memi-board` | **core** — `configureMemiBoard`, composables, types (**Vue 컴포넌트 export 없음**) |
30
+ | `memi-board/nuxt` | **thin Nuxt 모듈** — SFC를 호스트 Vite에 등록 → `MemiBoardList` 등 auto-import |
13
31
 
14
- **의도적으로 하지 않는 일:** `nuxt-vuefire` 설치, Firebase config, 자동 라우트/미들웨어.
15
- Firebase·인증 설정 지점은 항상 **호스트 하나**. UI 는 호스트가 SFC 를 컴파일해야 Nuxt UI auto-import(`UButton` 등)가 동작한다 — 그래서 prebundle 하지 않고 thin 모듈로 올린다.
32
+ 런타임 설정은 모듈 options가 아니라 호스트 플러그인의 `configureMemiBoard()` 한곳.
16
33
 
17
- 런타임 설정(`collectionPrefix` 등)은 모듈 options 가 아니라 호스트 플러그인의 `configureMemiBoard()` 한곳.
34
+ ```
35
+ 호스트 Nuxt 앱
36
+ ├── nuxt-vuefire + firebaseConfig ← 인증·Firestore
37
+ ├── (선택) App Check 플러그인 ← AI Logic Enforce 시 필수에 가까움
38
+ ├── memi-board/nuxt ← UI 등록
39
+ ├── plugins/memi-board.ts ← configureMemiBoard
40
+ ├── pages/board/* ← 라우트는 호스트 소유
41
+ └── firestore.rules + storage.rules ← docs 예시 병합 후 배포
42
+ ```
18
43
 
19
44
  ## 설치
20
45
 
21
- **사전 준비물**: Node.js + pnpm, Nuxt 4+ / `@nuxt/ui` 4+, Firestore·Storage·Authentication을 켜둔 Firebase 프로젝트. 아직 없다면 → [docs/firebase-setup.md](docs/firebase-setup.md).
46
+ **사전 준비:** Node + pnpm, Firebase 프로젝트(Firestore·Storage·Auth). 처음부터면 → [docs/firebase-setup.md](docs/firebase-setup.md).
22
47
 
23
48
  ```bash
24
49
  pnpm add memi-board @nuxt/ui nuxt-vuefire vuefire firebase
25
50
  ```
26
51
 
27
- `vuefire`/`firebase` 는 peer — 호스트에 직접 설치해야 모듈 인스턴스가 하나다.
52
+ `vuefire` / `firebase` / `nuxt-vuefire` 는 **호스트에 직접 설치** — peer 이중 인스턴스가 나면 인증·Firestore가 깨진다.
28
53
 
29
- ### 1. modules + vuefire (호스트)
54
+ ### 1. modules + vuefire
30
55
 
31
56
  ```ts
32
57
  // nuxt.config.ts
@@ -34,9 +59,11 @@ export default defineNuxtConfig({
34
59
  modules: [
35
60
  '@nuxt/ui',
36
61
  'nuxt-vuefire',
37
- 'memi-board/nuxt', // UI SFC 등록 (vuefire/라우트 안 건드림)
62
+ 'memi-board/nuxt',
38
63
  ],
39
64
  vuefire: {
65
+ // 콘솔 → 프로젝트 설정 → 내 앱 → 웹 앱 SDK snippet 과 동일해야 함
66
+ // (appId 가 다른 웹 앱이면 App Check / AI Logic 이 실패한다)
40
67
  config: {
41
68
  apiKey: '...',
42
69
  authDomain: '...',
@@ -50,7 +77,7 @@ export default defineNuxtConfig({
50
77
  })
51
78
  ```
52
79
 
53
- ### 2. configureMemiBoard (플러그인)
80
+ ### 2. configureMemiBoard
54
81
 
55
82
  ```ts
56
83
  // app/plugins/memi-board.ts
@@ -58,14 +85,24 @@ import { configureMemiBoard } from 'memi-board'
58
85
 
59
86
  export default defineNuxtPlugin(() => {
60
87
  configureMemiBoard({
61
- collectionPrefix: 'board', // boardPosts, boardUsers, boardSettings
62
- auth: { providers: ['google', 'apple'] },
63
- moderation: { enabled: true, onError: 'allow' },
88
+ // Firestore: {prefix}Posts, {prefix}Users, {prefix}Settings
89
+ collectionPrefix: 'board',
90
+ auth: { providers: ['google', 'apple'] }, // + 'emailPassword'
91
+ moderation: {
92
+ enabled: true,
93
+ model: 'gemini-3.5-flash-lite', // 권장. 2.5-flash 는 신규 프로젝트에서 미제공일 수 있음
94
+ onError: 'block', // 'allow' | 'block' — AI 실패 시 저장 허용/거부 (기본 allow)
95
+ moderateImages: false,
96
+ useLimitedUseAppCheckTokens: false, // App Check 세션 토큰으로도 가능
97
+ // localBlocklist: ['금칙어'],
98
+ },
64
99
  })
65
100
  })
66
101
  ```
67
102
 
68
- ### 3. Tailwind `@source`
103
+ ### 3. Tailwind `@source` (필수)
104
+
105
+ 패키지 SFC 안의 클래스가 purge되지 않도록:
69
106
 
70
107
  ```css
71
108
  /* app/assets/css/main.css */
@@ -74,11 +111,44 @@ export default defineNuxtPlugin(() => {
74
111
  @source "../../../node_modules/memi-board/dist/runtime/components";
75
112
  ```
76
113
 
77
- > ⚠️ Firestore/Storage Security Rules 도 배포해야 글쓰기가 된다 — [Security Model](#security-model--반드시-읽어야-하는-부분).
114
+ ### 4. Security Rules + 인덱스 배포
115
+
116
+ 1. [docs/firestore.rules.example](docs/firestore.rules.example) → 호스트 `firestore.rules`에 병합
117
+ (`memiBoard*` 이름을 쓰는 `collectionPrefix`에 맞게 치환)
118
+ 2. [docs/storage.rules.example](docs/storage.rules.example) → `storage.rules` 병합
119
+ 3. 카테고리 필터 목록을 쓰면 복합 인덱스 필요 (아래)
120
+ 4. 배포:
121
+ ```bash
122
+ firebase deploy --only firestore:rules,storage,firestore:indexes
123
+ ```
124
+
125
+ **Rules를 파일에만 넣고 배포 안 하면** 글쓰기에서 `permission-denied` — 가장 흔한 설치 실수.
126
+
127
+ #### 카테고리 목록용 인덱스 예시
128
+
129
+ `firestore.indexes.json` (컬렉션 ID는 prefix에 맞게):
130
+
131
+ ```json
132
+ {
133
+ "indexes": [
134
+ {
135
+ "collectionGroup": "boardPosts",
136
+ "queryScope": "COLLECTION",
137
+ "fields": [
138
+ { "fieldPath": "category", "order": "ASCENDING" },
139
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
140
+ ]
141
+ }
142
+ ]
143
+ }
144
+ ```
145
+
146
+ > ⚠️ npm 패키지 tarball에도 `docs/` 가 포함된다(0.4.7+). 구버전이면 GitHub의 `docs/`를 보면 된다.
78
147
 
79
148
  ## 사용법
80
149
 
81
- 라우팅은 호스트 소유. 컴포넌트는 모듈 auto-import (`MemiBoardList` …).
150
+ 라우팅·미들웨어는 **호스트 소유**. 컴포넌트는 모듈 auto-import (`MemiBoardList` 등).
151
+ `import { MemiBoardEditor } from 'memi-board'` 는 **안 된다** — core 엔트리는 composable/types 만 export.
82
152
 
83
153
  ```vue
84
154
  <!-- app/pages/board/index.vue -->
@@ -90,7 +160,15 @@ const { isSignedIn } = useMemiBoardAuth()
90
160
  <template>
91
161
  <div>
92
162
  <UButton v-if="isSignedIn" to="/board/new" label="새 글쓰기" />
93
- <MemiBoardList link-base="/board" />
163
+ <!-- 단순: 상세 /board/{id} -->
164
+ <MemiBoardList post-link-base="/board" />
165
+ <!-- 카테고리 path: get-post-link 권장 -->
166
+ <!--
167
+ <MemiBoardList
168
+ :category="route.params.category as string"
169
+ :get-post-link="(p) => `/board/${p.category}/${p.id}`"
170
+ />
171
+ -->
94
172
  <MemiBoardSignIn v-if="!isSignedIn" />
95
173
  </div>
96
174
  </template>
@@ -112,10 +190,8 @@ const router = useRouter()
112
190
  </template>
113
191
  ```
114
192
 
115
- 글쓰기(`/board/new`)·수정 라우트 가드도 호스트가 직접 (`vuefire` `getCurrentUser`):
116
-
117
193
  ```ts
118
- // app/middleware/board-auth.ts
194
+ // app/middleware/board-auth.ts — 글쓰기 가드 (호스트)
119
195
  import { getCurrentUser } from 'vuefire'
120
196
 
121
197
  export default defineNuxtRouteMiddleware(async () => {
@@ -127,100 +203,172 @@ export default defineNuxtRouteMiddleware(async () => {
127
203
  ```vue
128
204
  <!-- app/pages/board/new.vue -->
129
205
  <script setup lang="ts">
130
- import { MemiBoardEditor } from 'memi-board'
131
206
  definePageMeta({ middleware: 'board-auth' })
132
207
  const router = useRouter()
208
+ // MemiBoardEditor 는 auto-import (import from 'memi-board' 불필요)
133
209
  </script>
134
210
 
135
211
  <template>
136
- <MemiBoardEditor @saved="(id) => router.push(`/board/${id}`)" @cancel="router.push('/board')" />
212
+ <MemiBoardEditor
213
+ @saved="(id) => router.push(`/board/${id}`)"
214
+ @cancel="router.push('/board')"
215
+ />
216
+ <!-- path 로 카테고리 고정 시: fixed-category="free" -->
137
217
  </template>
138
218
  ```
139
219
 
140
- `playground/app/pages/board/`에 목록·상세·글쓰기·수정·버전히스토리 5개 페이지 전체 예시가 있다 — 그대로 복사해서 시작해도 된다.
220
+ 전체 페이지 예시는 `playground/app/pages/board/` 참고.
141
221
 
142
- 제공하는 컴포넌트: `MemiBoardList`, `MemiBoardDetail`, `MemiBoardEditor`, `MemiBoardCommentList`, `MemiBoardCommentForm`, `MemiBoardAttachments`, `MemiBoardSignIn`, `MemiBoardVersionHistory`.
143
- 제공하는 composable: `useMemiBoardAuth`, `useMemiBoardPosts`, `useMemiBoardComments`, `useMemiBoardStorage`, `useMemiBoardModeration`, `useMemiBoardSettings`.
222
+ ### 제공하는 API
144
223
 
145
- ### 카테고리
224
+ **컴포넌트 (auto-import):**
225
+ `MemiBoardList`, `MemiBoardDetail`, `MemiBoardEditor`, `MemiBoardCommentList`, `MemiBoardCommentForm`, `MemiBoardAttachments`, `MemiBoardSignIn`, `MemiBoardVersionHistory`
226
+
227
+ **List props:** `pageSize`, `category`, `postLinkBase`, `getPostLink(post)`, `linkBase`(deprecated)
228
+
229
+ **Editor props:** `postId`(수정), `fixedCategory`(path 고정 시 선택 UI 숨김)
230
+ 본문은 Nuxt UI **`UEditor`**(TipTap, `content-type="markdown"`). 호스트 `@nuxt/ui` 4.x 에 Editor 포함 필요.
146
231
 
147
- `useMemiBoardSettings()`가 `{prefix}Settings/config` 문서에서 카테고리 목록(`categories: { id, label }[]`)을 읽어온다. `MemiBoardEditor`는 이 목록으로 카테고리 선택 UI를 보여주고, `MemiBoardList`/`MemiBoardDetail`은 글의 `category` 필드를 뱃지로 표시한다.
232
+ **에디터 이미지:** 툴바 / 붙여넣기 / 드롭 → Firebase Storage
233
+ `{prefix}/posts/{postId}/images/*` + `.../images/thumbnails/*` (원본+썸네일).
234
+ 본문 markdown 에는 원본 URL. 수정 중 버려진 파일은 호스트 스케줄러로 정리 (`extractEditorImageUrls` 로 본문 참조 비교).
148
235
 
149
- 설정 문서가 아직 없으면 기본 카테고리(자유/공지/질문)로 계속 동작한다 — 관리자(`{prefix}Users/{uid}.role == 'admin'`)가 게시판에 처음 들어오는 순간 그 기본값으로 문서가 자동 생성되고, 이후엔 Firebase 콘솔에서 `{prefix}Settings/config.categories` 배열을 직접 수정하면 된다(카테고리 관리 UI는 아직 없음).
236
+ **composables:**
237
+ `useMemiBoardAuth`, `useMemiBoardPosts`, `useMemiBoardComments`, `useMemiBoardStorage`, `useMemiBoardModeration`, `useMemiBoardSettings`
238
+
239
+ ### 카테고리
240
+
241
+ - 문서: `{prefix}Settings/config` → `categories: { id, label }[]`
242
+ - 문서 없으면 UI는 기본(자유/공지/질문)으로 동작
243
+ - **로그인 사용자**가 글쓰기 폼에서 카테고리 추가 가능 (`ensureSettings`로 문서 시드)
244
+ - 관리자 전용 설정 UI는 없음 — 콘솔에서 배열 수정 가능
150
245
 
151
246
  ### 로그인 공급자
152
247
 
153
- `configureMemiBoard({ auth: { providers: [...] } })`의 기본값은 `['google', 'apple']`이고, `'emailPassword'`도 배열에 추가할 수 있다.
248
+ `auth.providers` 기본 `['google', 'apple']`, `'emailPassword'` 추가 가능.
154
249
 
155
- - **Google**: Firebase 콘솔 → Authentication → Sign-in method에서 Google 활성화만 하면 된다.
156
- - **Apple**: Firebase 콘솔 활성화 외에 Apple Developer 쪽 Services ID·키 발급이 추가로 필요하다 — 순서대로: [docs/firebase-setup.md](docs/firebase-setup.md#apple). 이 설정 없이 버튼을 누르면 에러가 난다.
250
+ - **Google:** 콘솔 Sign-in method 활성화
251
+ - **Apple:** Services ID·키 등 추가 설정 → [docs/firebase-setup.md#apple](docs/firebase-setup.md#apple)
157
252
 
158
- ### 버전 히스토리 페이지
253
+ ### 버전 히스토리
159
254
 
160
- `<MemiBoardVersionHistory />` 컴포넌트를 원하는 페이지(예: `app/pages/board/version-history.vue`)에 배치하면, 게시판을 설치한 사이트의 방문자도 "이 게시판에 어떤 기능이 있는지/뭐가 달라졌는지" 바로 볼 수 있다.
255
+ `<MemiBoardVersionHistory />` 를 페이지에 두면 패키지 기능 변경을 방문자에게 보여줄 수 있다.
161
256
 
162
- ## Security Model — 반드시 읽어야 하는 부분
257
+ ## Security Model
163
258
 
164
- 이 패키지는 서버가 없다. **모든 권한 검증은 Firestore/Storage Security Rules가 전부다.** 아래 두 파일을 프로젝트에 병합하는 것만으로는 부족하다 — **실제로 Firebase에 배포해야** 글쓰기·댓글·첨부파일이 동작한다. 로컬 `.rules` 파일만 고쳐두고 배포를 잊으면 글쓰기 버튼을 눌렀을 때 `permission-denied`가 난다(가장 흔한 설치 실수).
259
+ 서버가 없으므로 **Rules가 전부**다.
165
260
 
166
- 1. [`docs/firestore.rules.example`](docs/firestore.rules.example) — 호스트 프로젝트의 `firestore.rules`에 병합
167
- 2. [`docs/storage.rules.example`](docs/storage.rules.example) — 호스트 프로젝트의 `storage.rules`에 병합
168
- 3. **배포까지 실행**:
169
- ```bash
170
- firebase deploy --only firestore:rules,storage
171
- ```
172
- (호스트 프로젝트에 이미 `firestore.rules`/`storage.rules`가 배포 파이프라인에 연결돼 있다면, 평소 쓰던 배포 명령에 이 파일들이 포함돼 있는지 확인한다. Firebase CLI를 처음 연결하는 프로젝트라면 [docs/firebase-setup.md](docs/firebase-setup.md#4-호스트-프로젝트를-firebase-cli에-연결)부터 본다.)
261
+ 권한 요약:
173
262
 
174
- 권한 모델:
175
- - 글/댓글 작성 → 로그인 필요
176
- - 수정/삭제 → 작성자 본인 또는 `role: 'admin'`
177
- - 관리자 승격은 이 패키지가 절대 하지 않는다. Firebase 콘솔에서 `{collectionPrefix}Users/{uid}` 문서의 `role` 필드를 직접 `'admin'`으로 바꿔야 한다.
263
+ | 동작 | 조건 |
264
+ |------|------|
265
+ | 글/댓글 읽기 | 공개 |
266
+ | 글/댓글 작성 | 로그인 + `moderationStatus == 'approved'` (클라가 항상 approved 로 씀 — 검열 통과 후에만 write) |
267
+ | 글 수정·삭제 | 작성자 또는 `{prefix}Users/{uid}.role == 'admin'` |
268
+ | 댓글 삭제 | 댓글 작성자 · 글 작성자 · admin |
269
+ | 관리자 승격 | **패키지가 하지 않음** — 콘솔에서 `role: 'admin'` 수동 설정 |
178
270
 
179
- **알려진 한계**: AI 검열은 클라이언트에서만 실행된다. 악의적 사용자가 Firestore SDK로 직접 우회 쓰기하면 검열을 피할 수 있다 — 서버가 없는 아키텍처의 근본적인 한계이며, 이 패키지는 이를 해결하려 하지 않는다. 악용 위험이 큰 프로젝트라면 별도로 Cloud Functions 검증 트리거를 추가하는 것을 권장한다(이 패키지의 범위 밖).
271
+ **알려진 한계:** AI 검열은 클라이언트 전용. SDK로 직접 write 하면 우회 가능. 중요 서비스면 Cloud Functions 검증을 호스트가 추가(패키지 범위 밖).
180
272
 
181
- ## Firebase AI Logic (AI 검열)
273
+ ## Firebase AI Logic (검열)
182
274
 
183
- `configureMemiBoard({ moderation: { enabled: true, ... } })`(기본값)면 글/댓글 제출 전에 로컬 비속어 필터 → Gemini 검열을 순서대로 실행하고, 걸리면 제출 자체를 막는다(`moderation.onError`로 검열 서비스 장애 시 허용/차단 정책을 정한다, 기본은 `'allow'`).
275
+ 흐름: **로컬 금칙어 → Gemini 구조화 JSON** → 걸리면 저장 안 함.
184
276
 
185
- Firebase AI Logic을 쓰려면 Firebase 콘솔에서 AI Logic(Gemini Developer API)을 활성화해야 한다 — **무료(Spark) 요금제에서는 제한되거나 안 될 수 있어 Blaze(종량제)가 필요할 수 있다.** 남용 방지를 위해 App Check도 함께 설정하는 것을 권장한다 — App Check는 memi-board가 아니라 호스트의 `vuefire` 설정(`appCheck` 옵션)에서 켠다. **App Check는 옵션을 켠다고 자동으로 강제되지 않는다** — Firebase 콘솔 → App Check에서 Firestore/Storage 각각 "Enforce"를 따로 켜야 실제로 거부가 시작된다. 설정 단계는 [docs/firebase-setup.md](docs/firebase-setup.md#8-app-check-설정-선택-권장)에 자세히 있다.
277
+ | 옵션 | 기본 | 설명 |
278
+ |------|------|------|
279
+ | `enabled` | `true` | 끄면 로컬만 / 또는 전부 통과에 가깝게 |
280
+ | `model` | `gemini-3.5-flash-lite` | Firebase AI Logic 지원 모델명 |
281
+ | `onError` | `'allow'` | AI 실패 시 `'block'` 이면 저장 거부 |
282
+ | `localBlocklist` | `[]` | 기본 목록에 문자열 추가 |
283
+ | `useLimitedUseAppCheckTokens` | `true` | `false`면 일반 App Check 세션 토큰 |
284
+ | `blockBanThreshold` | `3` | 콘텐츠 차단(local/ai) 누적 이상이면 글·댓글 제한. `0` = off |
285
+ | `blockBanDecayMs` | `86400000` | 경고 1회 차감 간격(기본 24h) |
186
286
 
187
- ## 문제 해결
287
+ 콘텐츠 차단이 누적되면 `{prefix}Users/{uid}` 에 `moderationBlockCount` / `moderationBlockAt` 이 기록된다.
288
+ 3회면 약 하루 동안 작성 불가(24h마다 1 차감). Rules 예시에서 본인은 **+1만** 가능하고 감소·리셋은 admin.
188
289
 
189
- ### `[VueFire] useCurrentUser() called before the VueFireAuth module was added to the VueFire plugin`
290
+ 콘솔에서 **AI Logic(Gemini Developer API)** 활성화. 신규 프로젝트는 구형 `gemini-2.5-flash` 가 404 날 수 있음 → 3.x Flash / Flash-Lite 사용.
190
291
 
191
- `nuxt-vuefire`/`vuefire`/`firebase`를 프로젝트에 직접 설치하지 않았을 때, 또는 호스트의 `vuefire.auth.enabled`가 꺼져 있을 때 나는 에러다(위 [설치](#설치) 참고). pnpm 기준으로 `pnpm why vuefire`를 실행했을 때 `vuefire`가 두 개 이상의 서로 다른 버전/인스턴스로 나오면 그게 원인이다 — 하나로 합쳐질 때까지 lockfile을 정리하거나(`pnpm dedupe`), 세 패키지 버전을 프로젝트와 memi-board의 `peerDependencies` 범위에 맞게 맞춘다.
292
+ ### App Check (AI Enforce 시 사실상 필수)
192
293
 
193
- ### 설치는 됐는데 카테고리·설정이 하나도 안 보인다
294
+ `firebaseml` / AI Logic 이 **Enforce** 되어 있으면 App Check 토큰 없이 `generateContent` 가 실패한다.
194
295
 
195
- 관리자(`role: 'admin'`)가 게시판에 한 번도 들어온 적이 없으면 `{prefix}Settings/config` 문서가 아직 자동 생성되지 않은 상태다 — 코드 내장 기본값(자유/공지/질문)으로 정상 동작 중인 것이니 에러가 아니다. 관리자 계정으로 게시판에 한 번 들어가면 생긴다.
296
+ 1. 콘솔 App Check → 웹 앱에 **reCAPTCHA v3** 등록 (사이트 키·시크릿)
297
+ 2. 호스트에서 **`initializeAppCheck`** (memi-board 밖). 예:
196
298
 
197
- ## 로컬 개발 (이 리포 자체를 수정할 때)
299
+ ```ts
300
+ // app/plugins/app-check.client.ts
301
+ import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check'
302
+ import { useFirebaseApp } from 'vuefire'
198
303
 
199
- ```bash
200
- pnpm install
201
- pnpm build # src/ → dist/ (한 번 빌드해 둬야 playground가 'memi-board'를 resolve할 수 있다)
202
- pnpm dev:prepare
203
- pnpm dev # playground 실행, http://localhost:3000
304
+ export default defineNuxtPlugin(() => {
305
+ if (!import.meta.client) return
306
+ // 로컬: self.FIREBASE_APPCHECK_DEBUG_TOKEN = true | '<등록된-uuid>'
307
+ initializeAppCheck(useFirebaseApp(), {
308
+ provider: new ReCaptchaV3Provider('<RECAPTCHA_SITE_KEY>'),
309
+ isTokenAutoRefreshEnabled: true,
310
+ })
311
+ })
204
312
  ```
205
313
 
206
- `src/`를 수정하면서 바로 반영해서 보고 싶으면 다른 터미널에서:
314
+ 3. 로컬: 디버그 토큰을 콘솔에 등록 (또는 고정 UUID)
315
+ 4. `firebaseConfig.appId` 가 App Check에 등록한 **웹 앱과 동일**한지 확인
316
+ 5. (선택) Firestore/Storage 에도 Enforce — 켜면 토큰 없는 요청 거부
317
+
318
+ > `nuxt-vuefire` 의 `vuefire.appCheck` 옵션만으로 AI Logic까지 충분한지는 SDK/버전에 따라 다를 수 있다. 문제가 있으면 위처럼 명시적 `initializeAppCheck` 를 권장한다.
319
+
320
+ ## 다른 프로젝트에 붙일 때 체크리스트
321
+
322
+ - [ ] 동일 스택: Nuxt 4 + `@nuxt/ui` 4 + `nuxt-vuefire` + Firebase
323
+ - [ ] `pnpm add memi-board` 및 peer 직접 설치
324
+ - [ ] `modules`에 `memi-board/nuxt`
325
+ - [ ] `configureMemiBoard` 플러그인 (`collectionPrefix` 충돌 없게)
326
+ - [ ] CSS `@source` …/memi-board/dist/runtime/components
327
+ - [ ] Rules 예시 병합 + **deploy**
328
+ - [ ] 카테고리 필터 사용 시 indexes deploy
329
+ - [ ] 페이지·미들웨어 직접 작성 (또는 playground 복사)
330
+ - [ ] (검열) AI Logic 활성화 + 권장 모델 + App Check
331
+ - [ ] `pnpm why vuefire` 로 이중 인스턴스 없는지 확인
332
+
333
+ 같은 Firebase 프로젝트에 호스트 앱 유저 컬렉션이 있어도 무방 — 게시판 역할은 **`{prefix}Users` 전용**이다.
334
+
335
+ ## 문제 해결
336
+
337
+ ### `useCurrentUser() called before the VueFireAuth module...`
338
+
339
+ `vuefire.auth.enabled` 미설정, 또는 `vuefire`/`firebase` 이중 설치. `pnpm why vuefire` → 하나로. 모듈이 `vite.resolve.dedupe`에 vue/vuefire/firebase를 넣지만, **호스트 peer 설치**가 우선이다.
340
+
341
+ ### 카테고리 필터 쿼리 에러 / 인덱스 링크
342
+
343
+ 콘솔이 안내하는 복합 인덱스 생성, 또는 위 `firestore.indexes.json` 배포.
344
+
345
+ ### AI 검열 실패 / 글 안 올라감 (`onError: 'block'`)
346
+
347
+ App Check 토큰, `appId` 일치, 모델명(3.x), AI Logic 활성화, 네트워크. 브라우저 콘솔의 `moderation AI failed` warn 확인.
348
+
349
+ ### monorepo `link:` / `file:` 로 개발할 때
350
+
351
+ 패키지 쪽 `node_modules`의 vuefire가 잡히면 이중 인스턴스. 호스트에서 `vite.resolve.dedupe` + 필요 시 alias/optimizeDeps. 배포 전에는 **npm 버전**으로 설치하는 것을 권장.
352
+
353
+ ## 로컬 개발 (이 리포)
207
354
 
208
355
  ```bash
209
- pnpm build:watch # vite build --watch — src/ 변경 시마다 dist/를 다시 빌드
356
+ pnpm install
357
+ pnpm build
358
+ pnpm dev:prepare
359
+ pnpm dev # playground
360
+ pnpm build:watch # src 변경 시 dist 갱신
210
361
  ```
211
362
 
212
- Firebase 없이 빠르게 테스트하려면 [Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite)를 쓴다:
363
+ 에뮬레이터:
213
364
 
214
365
  ```bash
215
366
  firebase emulators:start --project demo-memi-board
216
367
  NUXT_PUBLIC_USE_EMULATORS=1 pnpm dev
217
368
  ```
218
369
 
219
- ## 버전 정보
220
-
221
- - 사이트에 설치된 상태에서 보기: `<MemiBoardVersionHistory />` 컴포넌트를 원하는 페이지에 배치
222
- - 코드 수준의 자세한 변경 내역: [CHANGELOG.md](CHANGELOG.md)
223
-
224
- ## 라이선스
370
+ ## 버전 · 라이선스
225
371
 
226
- MIT
372
+ - UI: `<MemiBoardVersionHistory />`
373
+ - 상세: [CHANGELOG.md](CHANGELOG.md)
374
+ - **MIT**
@@ -7,6 +7,12 @@ export interface UseMemiBoardAuthReturn {
7
7
  isSignedIn: ComputedRef<boolean>;
8
8
  isAdmin: ComputedRef<boolean>;
9
9
  rolePending: Ref<boolean>;
10
+ /** 유효 검열 차단 누적 (lazy decay 반영) */
11
+ moderationBlockCount: ComputedRef<number>;
12
+ /** 글·댓글 작성 제한 중 */
13
+ isWriteRestricted: ComputedRef<boolean>;
14
+ restrictedUntilLabel: ComputedRef<string | null>;
15
+ restrictedMessage: ComputedRef<string | null>;
10
16
  signInWithGoogle: () => ReturnType<typeof signInWithPopup>;
11
17
  signInWithApple: () => ReturnType<typeof signInWithPopup>;
12
18
  signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>;
@@ -15,10 +21,16 @@ export interface UseMemiBoardAuthReturn {
15
21
  canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean;
16
22
  canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean;
17
23
  canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean;
24
+ /** 콘텐츠 검열 차단 1회 기록 후 메시지 조각 반환 */
25
+ recordContentModerationBlock: () => Promise<{
26
+ effectiveCount: number;
27
+ restricted: boolean;
28
+ messageSuffix: string;
29
+ }>;
30
+ refreshBoardUser: () => Promise<void>;
18
31
  }
19
32
  /**
20
- * 인증 상태 + 게시판 역할(role) 관리.
21
- * role은 {prefix}Users/{uid} 문서에서만 읽고, 모듈은 절대 'admin'을 쓰지 않는다
22
- * (관리자 승격은 호스트 프로젝트 소유자가 Firebase 콘솔에서 직접 설정).
33
+ * 인증 상태 + 게시판 역할(role) + 검열 차단 누적.
34
+ * role·경고 카운트는 globalThis 공유 (여러 composable 호출이 같은 상태).
23
35
  */
24
36
  export declare function useMemiBoardAuth(): UseMemiBoardAuthReturn;
@@ -1,7 +1,8 @@
1
1
  import { ModerationResult } from '../types';
2
2
  /**
3
- * 글쓰기 전 블로킹 검열. 로컬 비속어 필터 → Firebase AI Logic(Gemini).
4
- * App Check limited-use 토큰 사용.
3
+ * 글쓰기 전 블로킹 검열.
4
+ * 이용 제한 확인 → 로컬 비속어 → Firebase AI Logic(Gemini).
5
+ * 콘텐츠 차단(local/ai) 시 boardUsers 경고 누적.
5
6
  */
6
7
  export declare function useMemiBoardModeration(): {
7
8
  checkText: (text: string) => Promise<ModerationResult>;
@@ -1,8 +1,12 @@
1
- import { Attachment } from '../types';
1
+ import { Attachment, EditorImageEntry } from '../types';
2
+ /** 에디터 이미지 최대 크기 (바이트) */
3
+ export declare const EDITOR_IMAGE_MAX_BYTES: number;
2
4
  export declare function useMemiBoardStorage(): {
3
5
  uploadAttachment: (file: File, postId: string, onProgress?: (ratio: number) => void) => {
4
6
  promise: Promise<Attachment>;
5
7
  cancel: () => void;
6
8
  };
7
9
  deleteAttachment: (attachment: Pick<Attachment, "path">) => Promise<void>;
10
+ uploadEditorImage: (file: File, postId: string) => Promise<EditorImageEntry>;
11
+ deleteEditorImage: (entry: Pick<EditorImageEntry, "originalPath" | "thumbnailPath">) => Promise<void>;
8
12
  };
package/dist/config.d.ts CHANGED
@@ -9,6 +9,13 @@ export interface MemiBoardModerationOptions {
9
9
  moderateImages?: boolean;
10
10
  /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */
11
11
  useLimitedUseAppCheckTokens?: boolean;
12
+ /**
13
+ * 콘텐츠 검열 차단(로컬/AI flagged) 누적 횟수 이상이 되면 글·댓글 작성 제한.
14
+ * 기본 3. 0 이하면 제한 기능 off.
15
+ */
16
+ blockBanThreshold?: number;
17
+ /** 경고 1회 차감 간격(ms). 기본 24시간 */
18
+ blockBanDecayMs?: number;
12
19
  }
13
20
  export interface MemiBoardConfig {
14
21
  collectionPrefix: string;
package/dist/index.d.ts CHANGED
@@ -12,4 +12,9 @@ export { useMemiBoardSettings, DEFAULT_CATEGORIES } from './composables/useMemiB
12
12
  export { versionHistory } from './data/versionHistory';
13
13
  export type { VersionHistoryEntry } from './data/versionHistory';
14
14
  export { formatDate } from './utils/formatDate';
15
- export type { Attachment, ModerationStatus, PostModel, PostDetail, CommentModel, BoardUserRole, BoardCategory, BoardSettingsModel, ModerationResult, } from './types';
15
+ export { DEFAULT_BLOCK_BAN_THRESHOLD, DEFAULT_BLOCK_BAN_DECAY_MS, effectiveModerationBlockCount, isModerationWriteRestricted, moderationWriteRestrictedUntilMs, formatRestrictedUntilLabel, } from './utils/moderation-strike';
16
+ export type { Attachment, EditorImageEntry, ModerationStatus, PostModel, PostDetail, CommentModel, BoardUserRole, BoardCategory, BoardSettingsModel, ModerationResult, } from './types';
17
+ export { compressImage } from './utils/compressImage';
18
+ export { extractEditorImageUrls } from './utils/extractEditorImageUrls';
19
+ export { renderMarkdownToHtml } from './utils/renderMarkdown';
20
+ export { EDITOR_IMAGE_MAX_BYTES } from './composables/useMemiBoardStorage';