memi-board 0.52.0 → 0.52.2

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.
@@ -1,37 +1,37 @@
1
- # Firebase 프로젝트 준비 (처음 설치하는 경우)
1
+ # Firebase project setup (first-time install)
2
2
 
3
- 이 모듈을 처음 붙이는 프로젝트, 특히 Firebase를 아직 안 써본 프로젝트라면 README의 "설치" 섹션만으로는 부족하다. 여기 순서대로 하면 된다.
3
+ If this is the first time you are adding this module to a project, especially one that has never used Firebase, the README's "Installation" section is not enough. Follow the steps below in order.
4
4
 
5
- [호스트 설치 가이드](host-integration.md) · [국제화·번역 설정](internationalization.md) · [역할 마이그레이션](roles-migration.md)
5
+ [Host installation guide](host-integration.md) · [Internationalization and translation](internationalization.md) · [Role migration](roles-migration.md)
6
6
 
7
- ## 1. 사전 준비
7
+ ## 1. Prerequisites
8
8
 
9
- - Node.js + pnpm (또는 npm/yarn)
10
- - Nuxt **4.0 이상**, `@nuxt/ui` **4.0 이상**
11
- - Firebase CLI: `npm i -g firebase-tools` 후 `firebase login`
9
+ - Node.js + pnpm (or npm/yarn)
10
+ - Nuxt **4.0 or later**, `@nuxt/ui` **4.0 or later**
11
+ - Firebase CLI: `npm i -g firebase-tools`, then `firebase login`
12
12
 
13
- ## 2. Firebase 프로젝트 만들기 (아직 없다면)
13
+ ## 2. Create a Firebase project (if you don't have one)
14
14
 
15
- 1. [Firebase 콘솔](https://console.firebase.google.com)에서 새 프로젝트를 만든다.
16
- 2. **Firestore Database**를 사용 설정한다 (Native mode).
17
- 3. **Storage**를 사용 설정한다.
18
- 4. **Authentication**을 사용 설정하고, Sign-in method에서 쓸 provider(Google/Apple/이메일-비밀번호)를 켠다.
15
+ 1. Create a new project in the [Firebase console](https://console.firebase.google.com).
16
+ 2. Enable **Firestore Database** (Native mode).
17
+ 3. Enable **Storage**.
18
+ 4. Enable **Authentication**, and turn on the providers you need (Google/Apple/Email/Password) under Sign-in method.
19
19
 
20
- 이미 있는 Firebase 프로젝트에 붙이는 거라면 이 단계는 건너뛰고, 위 세 서비스가 켜져 있는지만 확인한다.
20
+ If you are adding the board to an existing Firebase project, skip this step and just confirm that the three services above are enabled.
21
21
 
22
- ## 3. `firebaseConfig` 값 가져오기
22
+ ## 3. Get the `firebaseConfig` values
23
23
 
24
- Firebase 콘솔 → 프로젝트 설정(톱니바퀴) → **일반** 탭 → "내 앱" 섹션 → 웹 앱이 없으면 추가 → "SDK 설정 및 구성"에서 `apiKey`/`authDomain`/`projectId`/`storageBucket`/`messagingSenderId`/`appId` 값을 그대로 복사해 `nuxt.config.ts`의 `vuefire.config`에 붙여넣는다(memi-board가 아니라 호스트의 nuxt-vuefire 설정이다 — README [설치](../README.md#설치) 참고).
24
+ Firebase console → Project settings (gear icon) → **General** tab → "Your apps" section → add a web app if there is none → under "SDK setup and configuration", copy the `apiKey`/`authDomain`/`projectId`/`storageBucket`/`messagingSenderId`/`appId` values as-is into `vuefire.config` in `nuxt.config.ts` (this is the host app's nuxt-vuefire config, not memi-board's — see README [Installation](../README.md#installation)).
25
25
 
26
- ## 4. 호스트 프로젝트를 Firebase CLI에 연결
26
+ ## 4. Connect the host project to the Firebase CLI
27
27
 
28
- 프로젝트 루트에 아직 `firebase.json`/`.firebaserc`가 없다면:
28
+ If the project root does not have `firebase.json`/`.firebaserc` yet:
29
29
 
30
30
  ```bash
31
31
  firebase init firestore storage
32
32
  ```
33
33
 
34
- 또는 수동으로 만든다:
34
+ Or create them manually:
35
35
 
36
36
  ```json
37
37
  // .firebaserc
@@ -43,22 +43,22 @@ firebase init firestore storage
43
43
  {
44
44
  "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" },
45
45
  "storage": {
46
- "bucket": "<vuefire.config.storageBucket 과 동일한 값>",
46
+ "bucket": "<same value as vuefire.config.storageBucket>",
47
47
  "rules": "storage.rules"
48
48
  }
49
49
  }
50
50
  ```
51
51
 
52
- `storage.bucket`은 호스트 `vuefire.config.storageBucket`과 **같아야** 한다. 예: `my-app.appspot.com`.
52
+ `storage.bucket` **must match** the host's `vuefire.config.storageBucket`. Example: `my-app.appspot.com`.
53
53
 
54
- 이미 Firebase CLI를 쓰고 있는 프로젝트라면, `firebase.json`에 `firestore.rules`/`storage.rules` 경로가 있는지만 보지 말고 **버킷 이름**도 확인한다.
54
+ If the project already uses the Firebase CLI, don't just check that `firebase.json` has the `firestore.rules`/`storage.rules` paths — check the **bucket name** too.
55
55
 
56
- 구 프로젝트는 버킷이 두 개일 수 있다.
56
+ Older projects may have two buckets:
57
57
 
58
- - `*.appspot.com` — 예전 웹 앱 SDK snippet 의 `storageBucket`
59
- - `*.firebasestorage.app` — 콘솔/CLI 기본 버킷
58
+ - `*.appspot.com` — the `storageBucket` from the older web app SDK snippet
59
+ - `*.firebasestorage.app` — the console/CLI default bucket
60
60
 
61
- `firebase deploy --only storage`에 `bucket`이 없으면 **기본 버킷**에만 규칙이 올라간다. 앱은 `appspot.com`에 올리는데 규칙은 `firebasestorage.app`에만 있으면, 에디터 이미지에서 `storage/unauthorized`가 난다. 둘 다 쓰면 배열로 같은 규칙을 올린다.
61
+ If `firebase deploy --only storage` has no `bucket`, the rules are deployed only to the **default bucket**. If the app uploads to `appspot.com` but the rules exist only on `firebasestorage.app`, editor images fail with `storage/unauthorized`. If you use both, deploy the same rules as an array:
62
62
 
63
63
  ```json
64
64
  "storage": [
@@ -67,111 +67,111 @@ firebase init firestore storage
67
67
  ]
68
68
  ```
69
69
 
70
- 글 삭제가 `storage/unauthorized` (`access 'memiBoardPosts/{postId}'`) 로 실패하면 버킷이 아니라 **list 경로**다. `listAll` 접두사는 `memiBoardPosts/{postId}/` 이라 `match /memiBoardPosts/{postId} { allow list }` 만으로는 안 된다. `docs/storage.rules.example`의 `{postId}/{allPaths=**}` 블록을 그대로 넣고 `firebase deploy --only storage` 한다.
70
+ If deleting a post fails with `storage/unauthorized` (`access 'memiBoardPosts/{postId}'`), the problem is the **list path**, not the bucket. The `listAll` prefix is `memiBoardPosts/{postId}/`, so `match /memiBoardPosts/{postId} { allow list }` alone is not enough. Copy the `{postId}/{allPaths=**}` block from `docs/storage.rules.example` as-is and run `firebase deploy --only storage`.
71
71
 
72
- ## 5. 게시판 Security Rules 병합 + 배포
72
+ ## 5. Merge and deploy the board Security Rules
73
73
 
74
- README의 [Security Model](../README.md#security-model) 섹션대로 `docs/firestore.rules.example`/`docs/storage.rules.example`을 프로젝트 규칙 파일에 병합한 뒤:
74
+ Merge `docs/firestore.rules.example`/`docs/storage.rules.example` into the project's rules files as described in the README's [Security Model](../README.md#security-model) section, then:
75
75
 
76
76
  ```bash
77
77
  firebase deploy --only firestore:rules,storage
78
78
  ```
79
79
 
80
- **병합만 하고 배포를 안 하면 글쓰기·댓글에서 `permission-denied`가 난다** — 가장 흔한 설치 실수다.
80
+ **If you merge but don't deploy, writing posts and comments fails with `permission-denied`** — this is the most common installation mistake.
81
81
 
82
- ### 0.45.1 규칙 변경과 기존 호스트 전환
82
+ ### 0.45.1 rule changes and migrating existing hosts
83
83
 
84
- 0.46 이상에서는 아래 파일 관리의 전역 역할이 **admin/owner**다. staff/manager는 담당 콘텐츠를 관리하지만 타인 파일의 수정·삭제는 기본 규칙으로 허용하지 않는다. 신규 설치의 최초 역할은 `owner`로 지정하고, 기존 admin·staff 계정은 [역할 마이그레이션](roles-migration.md) 절차로 이전한다.
84
+ From 0.46 onward, the global roles for the file management described below are **admin/owner**. staff/manager manage their assigned content, but the default rules do not let them modify or delete other users' files. For a new install, set the first role to `owner`; migrate existing admin and staff accounts using the [role migration](roles-migration.md) procedure.
85
85
 
86
86
 
87
- 0.45.1은 클라이언트와 Firestore·Storage 규칙을 함께 갱신해야 한다. 사이트 자동 배포는 Firebase 규칙 배포를 대신하지 않는다.
87
+ 0.45.1 requires updating the client and the Firestore and Storage rules together. Automatic site deployment does not replace deploying the Firebase rules.
88
88
 
89
- | 대상 | 변경 내용 |
89
+ | Target | Change |
90
90
  |------|-----------|
91
- | 글 수정 | `boardId`·`authorUid` 변경을 금지하고 `listed` 변경을 공개 범위 전환으로 제한한다. |
92
- | 글·본문 동시 생성 | 본문 쓰기에서 `getAfter()`로 batch 적용 후 부모 글의 소유권을 확인한다. |
93
- | 공개 범위 전환 | 보드 설정의 `visibilityTransition` 동안 새 글·댓글 생성과 일반 글 수정을 잠근다. 담당 관리자의 `listed` 동기화는 허용한다. |
94
- | 삭제·감사 기록 | 콘텐츠 작성자·담당 관리자가 연관 좋아요를 정리할 수 있다. 글을 지워도 신고 기록은 남긴다. |
95
- | 이미지·첨부 업로드 | 기존 글은 작성자 또는 전역 admin·owner만 쓴다. 글 저장 전에는 업로드 파일의 `customMetadata.ownerUid`가 로그인 UID와 같아야 한다. 덮어쓰기는 기존 파일의 `ownerUid`도 확인한다. |
96
- | 파일 삭제 | 글 문서가 있으면 작성자 또는 전역 admin·owner, 없으면 파일에 기록된 `ownerUid` 본인만 삭제한다. |
91
+ | Post updates | Changing `boardId` or `authorUid` is forbidden, and changing `listed` is limited to visibility transitions. |
92
+ | Creating a post and its body together | The body write uses `getAfter()` to check ownership of the parent post after the batch is applied. |
93
+ | Visibility transitions | While the board settings have `visibilityTransition`, creating new posts and comments and regular post edits are locked. Assigned managers may still sync `listed`. |
94
+ | Deletion and audit records | Content authors and assigned managers can clean up related likes. Report records remain even when the post is deleted. |
95
+ | Image and attachment uploads | For existing posts, only the author or a global admin/owner can write. Before the post is saved, the uploaded file's `customMetadata.ownerUid` must equal the signed-in UID. Overwrites also check the existing file's `ownerUid`. |
96
+ | File deletion | If the post document exists, only the author or a global admin/owner can delete; otherwise only the user recorded in the file's `ownerUid`. |
97
97
 
98
- 적용 순서:
98
+ Rollout order:
99
99
 
100
- 1. 호스트에서 `memi-board@^0.45.1` 이상을 설치하고 lockfile을 갱신한다. 직접 만든 업로드 코드도 이미지·썸네일·첨부파일 모두에 `customMetadata: { ownerUid: user.uid }`를 넣는다.
101
- 2. 현재 설치한 버전 예제의 함수와 컬렉션·경로별 규칙 전체를 호스트에 병합한다. 컬렉션 이름을 바꿨다면 함수 내부 Firestore 경로도 맞춘다. 예전의 `!postExists(postId)`만으로 파일 쓰기·삭제를 허용하는 조건은 제거한다. Rules의 겹치는 `match` 중 하나라도 허용하면 접근이 가능하므로, 넓은 `allow write`를 남겨두지 않는다.
102
- 3. 에뮬레이터에서 두 계정으로 새 글·본문 동시 저장, 이미지·첨부 업로드, 타인 파일 덮어쓰기·삭제 거절, 댓글 좋아요 정리, 숨김 전환 재시도를 확인한다.
103
- 4. 새 클라이언트와 규칙 배포를 같은 배포 작업으로 진행하고 앱이 사용하는 모든 버킷에 적용한다. 새 규칙이 적용되면 기존 브라우저의 구버전 클라이언트는 저장 전 업로드에 `ownerUid`가 없어 거절될 수 있으므로 새 버전을 불러온 뒤 재시도한다. 아래 교차 서비스 IAM 설정도 확인한다.
100
+ 1. In the host app, install `memi-board@^0.45.1` or later and update the lockfile. Any custom upload code must also set `customMetadata: { ownerUid: user.uid }` on images, thumbnails, and attachments.
101
+ 2. Merge all functions and per-collection/per-path rules from the example of the version you installed into the host. If you renamed collections, update the Firestore paths inside the functions too. Remove old conditions that allow file writes or deletes based only on `!postExists(postId)`. Access is granted if any overlapping `match` in the rules allows it, so don't leave a broad `allow write` in place.
102
+ 3. In the emulator, with two accounts, verify: saving a new post and body together, image and attachment uploads, rejection of overwriting or deleting another user's files, comment like cleanup, and retrying a hide transition.
103
+ 4. Deploy the new client and the rules in the same deployment, and apply them to every bucket the app uses. Once the new rules are live, old clients in existing browsers may be rejected because their pre-save uploads lack `ownerUid`; reload the new version and retry. Also check the cross-service IAM setup below.
104
104
 
105
- 기존 글의 파일은 `ownerUid`가 없어도 부모 글의 작성자·역할로 판단하므로 메타데이터 일괄 수정이 필요하지 않다. 반면 부모 글 문서가 없고 `ownerUid`도 없는 예전 고아 파일은 클라이언트에서 수정·삭제할 수 없다. 필요한 정리는 신뢰할 수 있는 Admin SDK 또는 콘솔에서 수행한다.
105
+ Files on existing posts are authorized by the parent post's author and roles even without `ownerUid`, so no bulk metadata update is needed. However, old orphaned files that have neither a parent post document nor an `ownerUid` cannot be modified or deleted from the client. Do any necessary cleanup from a trusted Admin SDK or the console.
106
106
 
107
- 공개 범위 전환 중 실패하면 `visibilityTransition`을 임의로 지우거나 반대 범위로 바꾸지 말고, 설정 화면에서 **같은 목표 범위로 다시 저장**해 동기화를 완료한다. 숨김 전환은 콘텐츠의 `listed`를 먼저 내리고 마지막에 `visibility: 'hidden'`을 확정한다. 공개 복귀는 보드 공개를 먼저 허용한 뒤 콘텐츠를 동기화하고 전환 표시를 비운다.
107
+ If a visibility transition fails, don't manually clear `visibilityTransition` or switch it to the opposite visibility; instead, **save again with the same target visibility** in the settings screen to complete the sync. A hide transition first lowers the content's `listed`, then finalizes `visibility: 'hidden'` last. Returning to public first allows the board to be public, then syncs the content, then clears the transition marker.
108
108
 
109
- Storage의 Firestore 조회 제한은 서로 다른 문서 최대 2개이므로 예제는 글 문서와 사용자 역할 문서만 읽는다. 따라서 Storage의 admin·owner는 보드 담당 여부와 무관한 전역 파일 관리 권한을 유지한다. Firestore의 담당 스태프 범위와 혼동하지 않는다. [공식 조회 제한](https://firebase.google.com/docs/storage/security/rules-conditions#enhance_with_firestore)을 참고한다.
109
+ Storage rules can read at most 2 distinct Firestore documents, so the example reads only the post document and the user role document. As a result, admin/owner in Storage keep global file management rights regardless of board assignment. Don't confuse this with the assigned-staff scope in Firestore. See the [official access limits](https://firebase.google.com/docs/storage/security/rules-conditions#enhance_with_firestore).
110
110
 
111
- 예제의 파일 읽기와 목록 조회는 계속 공개다. 숨김 보드·비공개 초안 파일까지 비공개로 저장해야 하는 서비스는 별도의 Storage 읽기 정책을 설계해야 한다.
111
+ File reads and listing in the example remain public. Services that need files of hidden boards or private drafts to be stored privately must design a separate Storage read policy.
112
112
 
113
- ### Storage 규칙의 Firestore 조회 권한 (부모/호스트 프로젝트 필수 설정)
113
+ ### Firestore read access for Storage rules (required host app setup)
114
114
 
115
- Storage 예시 규칙은 이미지·첨부 업로드와 파일 삭제 시 `firestore.exists()` / `firestore.get()`으로 글의 존재 여부, `authorUid`, 사용자 `role`을 조회한다. 따라서 **호스트 Firebase 프로젝트의 Storage 서비스 계정**에 Firestore 조회 역할이 필요하다. 패키지 설치나 Firestore Rules 배포만으로 이 IAM 설정이 완료되지는 않는다.
115
+ On image/attachment uploads and file deletion, the example Storage rules use `firestore.exists()` / `firestore.get()` to look up whether the post exists, its `authorUid`, and the user's `role`. The **Storage service account of the host app's Firebase project** therefore needs a Firestore read role. Installing the package or deploying the Firestore rules does not complete this IAM setup.
116
116
 
117
- 호스트 프로젝트 관리자가 다음을 설정한다:
117
+ An administrator of the host project sets this up as follows:
118
118
 
119
- 1. 호스트 저장소에서 위 배포 명령을 대화형으로 실행한다. Firebase CLI가 교차 서비스 규칙용 IAM 역할 추가를 물으면 승인한다. Firebase 콘솔에서 Storage Rules를 저장할 때 표시되는 연결 권한 안내로도 설정할 수 있다.
120
- 2. Google Cloud Console → **IAM 및 관리자 → IAM**에서 같은 Firebase 프로젝트를 선택하고 **Google 제공 역할 부여 포함(Include Google-provided role grants)**을 켠다.
121
- 3. `service-<PROJECT_NUMBER>@gcp-sa-firebasestorage.iam.gserviceaccount.com` 계정을 찾아 **Firebase Rules Firestore Service Agent** (`roles/firebaserules.firestoreServiceAgent`) 역할이 있는지 확인한다. `<PROJECT_NUMBER>`는 프로젝트 ID가 아니라 Firebase 프로젝트 설정의 숫자 프로젝트 번호다. 역할이 없으면 IAM 변경 권한이 있는 호스트 관리자가 이 서비스 계정에 추가한다.
122
- 4. 권한 반영 후 로그인 상태에서 새 글에 20MiB 미만의 작은 첨부파일을 올려 확인한다. 기존 글에서는 작성자·스태프의 첨부 작업도 확인한다.
119
+ 1. Run the deploy command above interactively from the host repository. When the Firebase CLI asks whether to add the IAM role for cross-service rules, approve it. You can also grant it from the connection permission prompt shown when saving Storage Rules in the Firebase console.
120
+ 2. In the Google Cloud Console → **IAM & Admin → IAM**, select the same Firebase project and turn on **Include Google-provided role grants**.
121
+ 3. Find the `service-<PROJECT_NUMBER>@gcp-sa-firebasestorage.iam.gserviceaccount.com` account and confirm it has the **Firebase Rules Firestore Service Agent** (`roles/firebaserules.firestoreServiceAgent`) role. `<PROJECT_NUMBER>` is the numeric project number from the Firebase Project settings, not the project ID. If the role is missing, a host administrator with permission to change IAM adds it to this service account.
122
+ 4. After the permission takes effect, verify by uploading a small attachment (under 20 MiB) to a new post while signed in. On existing posts, also verify attachment operations by the author and staff.
123
123
 
124
- 이 역할은 프로젝트의 Firestore 문서 조회(`datastore.entities.get`)를 허용한다. 일반 사용자 계정에 부여하는 역할이 아니며, Firestore 쓰기·삭제 권한은 포함하지 않는다. Storage 규칙의 소유권·역할 검사는 그대로 유지한다.
124
+ This role allows reading Firestore documents in the project (`datastore.entities.get`). It is not a role for regular user accounts, and it does not include Firestore write or delete permissions. The ownership and role checks in the Storage rules remain in place.
125
125
 
126
- **CI의 `--non-interactive` 배포 성공만으로 IAM 설정까지 완료됐다고 판단하지 않는다.** 최초 설정은 호스트 관리자가 대화형 배포 또는 IAM 콘솔에서 수행하고, 위 서비스 계정의 역할을 확인한다.
126
+ **Do not assume IAM setup is complete just because a `--non-interactive` deploy in CI succeeded.** The host administrator must do the initial setup via an interactive deploy or the IAM console, and confirm the role on the service account above.
127
127
 
128
- 2MB ZIP처럼 용량 조건을 충족해도 이 권한이 없으면 `memiBoardPosts/{postId}/attachments/...`에서 `storage/unauthorized`가 날 수 있다. 새 글 문서가 아직 없어도 `firestore.exists()` 조회 자체에는 권한이 필요하다. 0.45.1의 본문 이미지 업로드도 같은 Firestore 조회 권한이 필요하다.
128
+ Even when the size limit is met, as with a 2 MB ZIP, uploads to `memiBoardPosts/{postId}/attachments/...` can fail with `storage/unauthorized` without this permission. Even if the new post document doesn't exist yet, the `firestore.exists()` lookup itself requires the permission. Body image uploads in 0.45.1 need the same Firestore read permission.
129
129
 
130
- 공식 근거: [교차 서비스 Storage Rules 권한 설정](https://firebase.google.com/docs/rules/manage-deploy#manage_permissions_for_cross-service_cloud_storage_security_rules), [역할의 포함 권한](https://docs.cloud.google.com/iam/docs/roles-permissions/firebaserules#firebaserules.firestoreServiceAgent).
130
+ Official references: [Managing permissions for cross-service Storage Rules](https://firebase.google.com/docs/rules/manage-deploy#manage_permissions_for_cross-service_cloud_storage_security_rules), [Permissions included in the role](https://docs.cloud.google.com/iam/docs/roles-permissions/firebaserules#firebaserules.firestoreServiceAgent).
131
131
 
132
- ## 6. 로그인 공급자 설정
132
+ ## 6. Sign-in provider setup
133
133
 
134
134
  ### Google
135
135
 
136
- Firebase 콘솔 → Authentication → Sign-in method에서 Google을 활성화하기만 하면 된다. 추가 설정 없음.
136
+ Just enable Google in Firebase console → Authentication → Sign-in method. No further setup.
137
137
 
138
138
  ### Apple
139
139
 
140
- Google보다 손이 더 간다:
140
+ This takes more work than Google:
141
141
 
142
- 1. Firebase 콘솔에서 Apple 로그인을 활성화한다.
143
- 2. [Apple Developer](https://developer.apple.com/account) 계정에서 **Services ID**를 새로 만든다.
144
- 3. 그 Services ID의 "Web Authentication Configuration"에 콜백 도메인을 등록한다 — Domains에 `{authDomain}`(Firebase 프로젝트의 authDomain, 보통 `<project-id>.firebaseapp.com`), Return URL에 `https://{authDomain}/__/auth/handler`.
145
- 4. Apple Developer의 **Keys**에서 "Sign in with Apple" 키를 새로 만든다 (기존에 다른 용도 키가 있어도 별도로 새로 만들어야 한다).
146
- 5. Team ID, 방금 만든 키의 Key ID, 그 키의 개인 키(`.p8` 파일 내용)를 Firebase 콘솔의 Apple 로그인 설정 화면에 입력한다.
142
+ 1. Enable Apple sign-in in the Firebase console.
143
+ 2. Create a new **Services ID** in your [Apple Developer](https://developer.apple.com/account) account.
144
+ 3. Register the callback domain in that Services ID's "Web Authentication Configuration" — under Domains, `{authDomain}` (the Firebase project's authDomain, usually `<project-id>.firebaseapp.com`); under Return URL, `https://{authDomain}/__/auth/handler`.
145
+ 4. Create a new "Sign in with Apple" key under **Keys** in Apple Developer (even if you already have keys for other purposes, you must create a separate new one).
146
+ 5. Enter the Team ID, the Key ID of the key you just created, and its private key (the contents of the `.p8` file) in the Apple sign-in settings in the Firebase console.
147
147
 
148
- 자세한 건 [Firebase 공식 가이드](https://firebase.google.com/docs/auth/web/apple)를 따른다. 이 설정 없이 Apple 로그인 버튼을 누르면 에러가 난다.
148
+ For details, follow the [official Firebase guide](https://firebase.google.com/docs/auth/web/apple). Without this setup, clicking the Apple sign-in button results in an error.
149
149
 
150
- ## 7. Firebase AI Logic(Gemini) 활성화 — AI 검열용
150
+ ## 7. Enable Firebase AI Logic (Gemini) for AI moderation
151
151
 
152
- 1. Firebase 콘솔 → **AI Logic** 에서 활성화하고, **Gemini Developer API** 를 사용하도록 설정한다.
153
- 2. **요금제**: Spark에서도 되는 경우가 있으나 제한·쿼터가 있을 수 있다. 부족하면 Blaze.
154
- 3. 모델: 호스트 `nuxt.config.ts`의 `memiBoard.moderation.model`에 `gemini-3.5-flash-lite` 권장.
155
- `gemini-2.5-flash` 는 **신규 프로젝트에서 "no longer available to new users"** 로 404 날 수 있다.
156
- 4. `moderation.enabled` 기본이 `true` 이므로 활성화 후 글쓰기 시 자동 호출된다. 끄려면 `enabled: false`.
152
+ 1. Enable it in Firebase console → **AI Logic**, and configure it to use the **Gemini Developer API**.
153
+ 2. **Pricing plan**: it may work on Spark, but there may be limits and quotas. If they are insufficient, use Blaze.
154
+ 3. Model: `gemini-3.5-flash-lite` is recommended for `memiBoard.moderation.model` in the host's `nuxt.config.ts`.
155
+ `gemini-2.5-flash` may return a 404 with **"no longer available to new users" on new projects**.
156
+ 4. `moderation.enabled` defaults to `true`, so once enabled it is called automatically when posts are written. To turn it off, set `enabled: false`.
157
157
 
158
- ## 8. App Check 설정 (AI Logic Enforce 시 사실상 필수)
158
+ ## 8. App Check setup (effectively required when AI Logic is enforced)
159
159
 
160
- AI Logic / `firebaseml` 이 **Enforce** 이면 App Check 토큰 없이 검열 API가 실패한다. memi-board는 App Check를 초기화하지 않는다. **호스트 책임**이다. 기본값은 한 번만 쓰는 토큰(`useLimitedUseAppCheckTokens: true`)이라 reCAPTCHA v3 공급자로는 맞지 않는다. 플러그인 예시는 [README App Check 절](../README.md#app-check-ai-enforce-시-사실상-필수)을 따른다.
160
+ If AI Logic / `firebaseml` is set to **Enforce**, the moderation API fails without an App Check token. memi-board does not initialize App Check. **That is the host app's responsibility.** The default uses limited-use tokens (`useLimitedUseAppCheckTokens: true`), which don't fit the reCAPTCHA v3 provider. For a plugin example, follow the [README App Check section](../README.md#app-check-effectively-required-with-ai-enforce).
161
161
 
162
- 1. reCAPTCHA Enterprise **점수형** 웹 키를 만들고, Firebase 콘솔 App Check의 웹 앱에 그 사이트 키를 등록한다. 등록하는 웹 앱의 **appId** 가 호스트 `vuefire.config.appId` 와 같아야 한다. 시크릿 키는 클라이언트에 넣지 않는다.
163
- 2. 브라우저 API 키 제한에 **Firebase App Check API** 를 포함한다. 이미 허용한 API는 유지한다.
164
- 3. SPA는 `app:mounted` 이후에 `enterprise.execute` 로 토큰을 만들어 `exchangeRecaptchaEnterpriseToken` 에 `limited_use: true` 로 교환한다. `ReCaptchaEnterpriseProvider` 는 마운트 전 빈 `document.body` 에 숨은 위젯을 그리다 `appCheck/recaptcha-error` 가 난다.
165
- 4. **로컬:** `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true`(콘솔에 UUID 출력 후 App Check → 디버그 토큰 등록) 또는 등록된 고정 UUID.
166
- 5. **프로덕션:** 디버그 토큰 사용 금지. reCAPTCHA 도메인에 실제 호스트 도메인 등록.
167
- 6. 적용은 **AI Logic만** 켠다. Cloud Storage와 Cloud Firestore는 모니터링으로 둔다. 한 번용 토큰은 AI Logic만 받는다. Storage나 Firestore에 적용하면 이미지 업로드와 글 쓰기가 거절된다.
162
+ 1. Create a reCAPTCHA Enterprise **score-based** web key and register that site key for the web app in App Check in the Firebase console. The **appId** of the registered web app must match the host's `vuefire.config.appId`. Never put the secret key in the client.
163
+ 2. Include the **Firebase App Check API** in the browser API key restrictions. Keep the APIs that are already allowed.
164
+ 3. In an SPA, after `app:mounted`, create a token with `enterprise.execute` and exchange it via `exchangeRecaptchaEnterpriseToken` with `limited_use: true`. `ReCaptchaEnterpriseProvider` renders a hidden widget into an empty `document.body` before mounting and fails with `appCheck/recaptcha-error`.
165
+ 4. **Local:** `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true` (prints a UUID to the console; register it under App Check → debug tokens) or a registered fixed UUID.
166
+ 5. **Production:** never use debug tokens. Register the real host domain in the reCAPTCHA domains.
167
+ 6. Enforce **only AI Logic**. Leave Cloud Storage and Cloud Firestore on monitoring. Only AI Logic accepts limited-use tokens. If you enforce Storage or Firestore, image uploads and post writes are rejected.
168
168
 
169
- ## 9. Firestore 복합 인덱스
169
+ ## 9. Firestore composite indexes
170
170
 
171
- `memiBoardPosts`/`memiBoardComments`/`memiBoardLikes` 는 모두 최상위 flat 컬렉션이고, 보드/글 단위 스코핑은 `boardId`/`postId` 필드에 대한 `where` 동등 필터로 한다. [docs/firestore.indexes.json.example](firestore.indexes.json.example) 를 호스트 `firestore.indexes.json` 에 병합 후:
171
+ `memiBoardPosts`/`memiBoardComments`/`memiBoardLikes` are all top-level flat collections, and board/post scoping is done with `where` equality filters on the `boardId`/`postId` fields. Merge [docs/firestore.indexes.json.example](firestore.indexes.json.example) into the host's `firestore.indexes.json`, then:
172
172
 
173
173
  ```bash
174
174
  firebase deploy --only firestore:indexes
175
175
  ```
176
176
 
177
- 콘솔 에러에 인덱스 생성 링크가 뜨면 그걸 따라도 된다 — 위 예시 파일은 패키지가 실제로 실행하는 쿼리 기준으로 미리 정리해 둔 것이다. 컬렉션 이름을 커스터마이즈했다면(`memiBoard.postsCollection` 등) `collectionGroup` 값도 맞춰 바꾼다.
177
+ If a console error shows an index creation link, you can follow it too — the example file above is prepared in advance based on the queries the package actually runs. If you customized collection names (`memiBoard.postsCollection`, etc.), update the `collectionGroup` values to match.
@@ -1,17 +1,17 @@
1
1
  // memi-board 0.23+ Firestore Rules (flat schema)
2
2
  //
3
3
  // memiBoardUsers/{uid}
4
- // memiBoardSettings/{boardId} (구 memiBoards/{id} stub + settings/config 통합)
5
- // memiBoardPosts/{postId}(+ body/main) boardId 필드로 보드 스코핑
6
- // memiBoardComments/{commentId} postId + boardId 필드로 스코핑
7
- // memiBoardLikes/{likeId} 문서ID = `${postId}_${uid}` 또는 `${commentId}_${uid}`
4
+ // memiBoardSettings/{boardId} (merges the old memiBoards/{id} stub + settings/config)
5
+ // memiBoardPosts/{postId}(+ body/main) scoped to a board by the boardId field
6
+ // memiBoardComments/{commentId} scoped by the postId + boardId fields
7
+ // memiBoardLikes/{likeId} doc ID = `${postId}_${uid}` or `${commentId}_${uid}`
8
8
  //
9
- // 카테고리 없음 — boardId 가 곧 예전 category id.
10
- // posts/comments/likes 는 모두 최상위 flat 컬렉션이다. boardId(및 postId)가 이제
11
- // 경로가 아니라 문서 필드이므로, list/collectionGroup 쿼리에 대한 권한 검사가
12
- // 아래처럼 "쿼리의 등호(==) 필터가 그 필드를 상수로 고정해 주는가"에 의존한다.
13
- // (구버전의 collectionGroup carve-out은 더 이상 필요 없음 — memiBoardPosts가
14
- // 진짜 flat 컬렉션이라 작성자 교차조회도 일반 list 쿼리로 동작한다.)
9
+ // No categories — boardId is the old category id.
10
+ // posts/comments/likes are all top-level flat collections. Since boardId (and postId) are now
11
+ // document fields rather than paths, permission checks on list/collectionGroup queries
12
+ // depend, as below, on "whether the query's equality (==) filter pins that field to a constant".
13
+ // (The old collectionGroup carve-out is no longer needed — memiBoardPosts is
14
+ // a true flat collection, so cross-author lookups also work as regular list queries.)
15
15
 
16
16
  rules_version = '2';
17
17
  service cloud.firestore {
@@ -20,9 +20,9 @@ service cloud.firestore {
20
20
  function isSignedIn() {
21
21
  return request.auth != null;
22
22
  }
23
- // 호스트가 다른 기능(예: 손님 대기열)에서 익명 로그인을 켜둔 경우에도
24
- // 게시판 콘텐츠 생성(글/댓글/좋아요)만은 기본적으로 막는다 — 읽기·isSignedIn()
25
- // 전반은 그대로 두고, 아래 3개 create 규칙에서만 이 함수를 쓴다.
23
+ // Even if the host app enables anonymous sign-in for another feature (e.g. a guest queue),
24
+ // block creating board content (posts/comments/likes) by default — reads and isSignedIn()
25
+ // in general are left as-is; this function is used only in the 3 create rules below.
26
26
  function isBoardWriter() {
27
27
  return isSignedIn() && request.auth.token.firebase.sign_in_provider != 'anonymous';
28
28
  }
@@ -48,8 +48,8 @@ service cloud.firestore {
48
48
  function commentPath(commentId) {
49
49
  return /databases/$(database)/documents/memiBoardComments/$(commentId);
50
50
  }
51
- // 댓글 이미지 1장. 없거나 빈 목록도 허용한다. 경로는 그 댓글 폴더만.
52
- // 게시물 컬렉션 이름을 바꾼 호스트는 memiBoardPosts 접두사도 맞춘다.
51
+ // One comment image. A missing or empty list is also allowed. The path must be that comment's folder.
52
+ // Host apps that renamed the posts collection must also update the memiBoardPosts prefix.
53
53
  function commentImageEntryValid(img, postId, commentId) {
54
54
  return img.keys().hasOnly(['name', 'url', 'path', 'size', 'type'])
55
55
  && img.name is string
@@ -82,7 +82,7 @@ service cloud.firestore {
82
82
  && likeId == request.resource.data.postId + '_' + request.auth.uid;
83
83
  }
84
84
  function isValidCommentLike(likeId) {
85
- // 예전 댓글은 boardId 가 없을 수 있다. 스푸핑 방지는 댓글의 postId + 부모 글 boardId 로 한다.
85
+ // Older comments may lack boardId. Spoofing is prevented via the comment's postId + the parent post's boardId.
86
86
  return request.resource.data.keys().hasOnly(['uid', 'postId', 'boardId', 'createdAt', 'commentId', 'target'])
87
87
  && request.resource.data.target == 'comment'
88
88
  && request.resource.data.commentId is string
@@ -115,7 +115,7 @@ service cloud.firestore {
115
115
  return isBoardStaff() && isAllowedStaffForBoard(boardId);
116
116
  }
117
117
  // A visibility transition freezes new content until every listed flag is synchronized.
118
- // 공지(pinned·pinnedAt)는 매니저 전용 갱신으로만 바뀐다 — 작성자의 일반 수정으로는 못 바꾼다.
118
+ // Notices (pinned, pinnedAt) change only via manager-only updates — an author's regular edit cannot change them.
119
119
  function postPinUnchanged() {
120
120
  return request.resource.data.get('pinned', false) == resource.data.get('pinned', false)
121
121
  && request.resource.data.get('pinnedAt', null) == resource.data.get('pinnedAt', null);
@@ -183,8 +183,8 @@ service cloud.firestore {
183
183
  && request.resource.data.viewCount is int
184
184
  && request.resource.data.viewCount == resource.data.get('viewCount', 0) + 1;
185
185
  }
186
- // 답글 생성/삭제 batch에서는 대상이 최상위(root) 댓글이라 작성자가 아닌 사람도
187
- // replyCount만 ±1 갱신할 수 있어야 한다 — 아니면 답글 작성 자체가 배치 전체로 막힌다.
186
+ // In a reply create/delete batch the target is a root comment, so users other than its author
187
+ // must be able to update only replyCount by ±1 — otherwise the whole batch, and thus writing a reply, is blocked.
188
188
  function isReplyCountUpdate() {
189
189
  return isSignedIn()
190
190
  && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['replyCount'])
@@ -193,30 +193,30 @@ service cloud.firestore {
193
193
  && (request.resource.data.replyCount == resource.data.get('replyCount', 0) + 1
194
194
  || request.resource.data.replyCount == resource.data.get('replyCount', 0) - 1);
195
195
  }
196
- // 프로필 이름 변경 시 나를 답글 대상으로 가리키는 다른 사용자의 댓글에
197
- // 복제된 replyToName만 동기화할 수 있다. 작성자/본문 등 다른 필드는 불변이다.
196
+ // When a profile name changes, only the copied replyToName in other users' comments that
197
+ // point to me as the reply target may be synced. Author, body, and other fields are immutable.
198
198
  function isReplyTargetProfileUpdate() {
199
199
  return isSignedIn()
200
200
  && resource.data.replyToUid == request.auth.uid
201
201
  && request.resource.data.replyToUid == resource.data.replyToUid
202
202
  && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['replyToName']);
203
203
  }
204
- // data.boardId 는 이제 문서 필드다. list 쿼리에서 이 함수가 안전하게 평가되려면:
205
- // - isBoardAdmin()/isBoardStaff() 만으로 true 가 되는 경우는 항상 OK
206
- // (request.auth.uid 기준 get() 이라 문서 내용과 무관하게 항상 평가 가능)
207
- // - (isBoardStaff() && canWriteBoard(data.boardId)) 분기에 기대려면 쿼리에
208
- // where('boardId','==', b) 동등 필터가 있어야 한다 — 그래야 Firestore가
209
- // potential result set 전체에 대해 data.boardId == b 임을 정적으로 증명하고
210
- // get(boardSettingsPath(b))를 상수 조회로 안전하게 실행할 수 있다.
204
+ // data.boardId is now a document field. For this function to evaluate safely in list queries:
205
+ // - cases where isBoardAdmin()/isBoardStaff() alone yields true are always OK
206
+ // (get() is keyed on request.auth.uid, so it can always be evaluated regardless of document contents)
207
+ // - relying on the (isBoardStaff() && canWriteBoard(data.boardId)) branch requires the query
208
+ // to have a where('boardId','==', b) equality filter — that lets Firestore
209
+ // statically prove data.boardId == b for the entire potential result set and
210
+ // safely run get(boardSettingsPath(b)) as a constant lookup.
211
211
  function canReadPost(data) {
212
212
  return (data.get('listed', true) == true && data.isPublished == true)
213
213
  || (isSignedIn() && request.auth.uid == data.authorUid)
214
214
  || isBoardAdmin()
215
215
  || isBoardManager(data.boardId);
216
216
  }
217
- // 공개 피드·작성자 댓글 목록은 listed==true 동등 필터로 숨김 보드 댓글을 뺀다.
218
- // 글 상세(postId == 상수)에서는 부모 글을 읽을 수 있으면 숨김 댓글도 볼 수 있다
219
- // (작성자가 담당 스태프 메모를 읽는 경우).
217
+ // Public feeds and author comment lists exclude hidden-board comments with a listed==true equality filter.
218
+ // On a post detail (postId == constant), anyone who can read the parent post can also see hidden comments
219
+ // (e.g. an author reading an assigned staff memo).
220
220
  function canReadComment(data) {
221
221
  return data.listed == true
222
222
  || (isSignedIn() && request.auth.uid == data.authorUid)
@@ -231,7 +231,7 @@ service cloud.firestore {
231
231
  }
232
232
 
233
233
  match /memiBoardUsers/{uid} {
234
- // 이메일 말고는 다 공개 프로필 정보다 — 이메일은 private 서브컬렉션에 따로 둔다.
234
+ // Everything except email is public profile info — email is kept separately in a private subcollection.
235
235
  allow read: if true;
236
236
  allow create: if isSignedIn()
237
237
  && request.auth.uid == uid
@@ -252,7 +252,7 @@ service cloud.firestore {
252
252
  && request.resource.data.moderationBlockCount
253
253
  == resource.data.get('moderationBlockCount', 0) + 1
254
254
  )
255
- // 0.47.0 이하: 호출 전에 확정 횟수를 1 올린다.
255
+ // 0.47.0 and earlier: increment the confirmed count by 1 before the call.
256
256
  || (
257
257
  request.resource.data.diff(resource.data).affectedKeys()
258
258
  .hasOnly(['aiWritingCount', 'aiWritingWindowAt', 'updatedAt'])
@@ -273,8 +273,8 @@ service cloud.firestore {
273
273
  )
274
274
  )
275
275
  )
276
- // 0.47.1+: 호출 전에 예약만 잡고, 성공하면 확정한다. 실패하면 예약만 내린다.
277
- // 확정 횟수는 1씩만 오르고, 클라이언트가 내리지는 못한다.
276
+ // 0.47.1+: only reserve before the call, and confirm on success. On failure, only release the reservation.
277
+ // The confirmed count only increases by 1 at a time and cannot be decreased by the client.
278
278
  || (
279
279
  request.resource.data.diff(resource.data).affectedKeys()
280
280
  .hasOnly(['aiWritingPending', 'updatedAt'])
@@ -312,23 +312,23 @@ service cloud.firestore {
312
312
  )
313
313
  ));
314
314
 
315
- // 다른 작성자에 대해 나만 보는 개인 메모. 문서 ID가 targetUid라 대상당 하나로
316
- // 고정된다. 관리자도 못 읽는다 — 모더레이션용이 아니라 순전히 본인 참고용이다.
315
+ // A private memo about another author, visible only to me. The doc ID is targetUid, so there is
316
+ // one per target. Even admins can't read it — it is purely for personal reference, not moderation.
317
317
  match /memos/{targetUid} {
318
318
  allow read, write: if isSignedIn() && request.auth.uid == uid;
319
319
  }
320
320
 
321
- // 이메일만 담는 비공개 서브컬렉션. 본인·관리자만 읽고 쓴다.
321
+ // Private subcollection holding only the email. Only the user and admins can read and write.
322
322
  match /private/{docId} {
323
323
  allow read, write: if isBoardAdmin() || (isSignedIn() && request.auth.uid == uid);
324
324
  }
325
325
  }
326
326
 
327
- // 구 memiBoards/{boardId} stub + memiBoards/{boardId}/settings/config 를 하나로 합친
328
- // 캐노니컬 보드 문서. 목록 렌더링과 권한 검사가 모두 이 문서 하나만 읽는다.
329
- // create/update 를 분리한 이유: update 쪽 staff 분기가 resource.data(기존 값)를
330
- // 참조하는데, create 시점엔 resource 가 없어 하나로 묶으면 그 분기가 에러로
331
- // 평가된다 — 구버전에서도 보드 생성 자체는 admin 전용이었으므로 분리가 자연스럽다.
327
+ // Canonical board document that merges the old memiBoards/{boardId} stub + memiBoards/{boardId}/settings/config.
328
+ // List rendering and permission checks both read only this one document.
329
+ // Why create/update are separate: the staff branch on update references resource.data (existing values),
330
+ // but resource doesn't exist at create time, so combining them would make that branch evaluate
331
+ // to an error — board creation was admin-only in older versions too, so separating them is natural.
332
332
  match /memiBoardSettings/{boardId} {
333
333
  allow read: if true;
334
334
  allow create: if isBoardAdmin();
@@ -381,17 +381,17 @@ service cloud.firestore {
381
381
  }
382
382
 
383
383
  match /memiBoardComments/{commentId} {
384
- // 공개 피드·작성자 댓글 목록은 where('listed','==',true) 로 숨김 보드 댓글을 걸러낸다.
385
- // 글 상세는 postId 동등 필터라 부모 글 권한(canReadPost)으로 숨김 댓글도 읽을 수 있다.
384
+ // Public feeds and author comment lists filter out hidden-board comments with where('listed','==',true).
385
+ // Post details use a postId equality filter, so hidden comments can be read via parent post permission (canReadPost).
386
386
  allow read: if isBoardAdmin() || canReadComment(resource.data);
387
387
 
388
388
  allow create: if isBoardWriter()
389
389
  && request.resource.data.postId is string
390
390
  && request.resource.data.boardId is string
391
- // boardId 는 클라이언트가 채우는 비정규화 필드라 스푸핑이 가능하다 — 실제
392
- // 글의 boardId 와 다른 값을 채워 commentWriteRole 이 느슨한 다른 보드인 척
393
- // canWriteBoardComment() 를 우회하는 것을 막기 위해 부모 글의 실제 boardId 와
394
- // 일치하는지 확인한다(비용: 댓글 생성마다 get() 1회 추가).
391
+ // boardId is a denormalized field filled by the client and can be spoofed — to prevent
392
+ // filling in a value different from the post's actual boardId to pose as another board with a looser
393
+ // commentWriteRole and bypass canWriteBoardComment(), check that it matches the parent post's actual
394
+ // boardId (cost: one extra get() per comment creation).
395
395
  && get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId
396
396
  && canWriteBoardComment(request.resource.data.boardId)
397
397
  && boardWritesOpen(request.resource.data.boardId)
@@ -422,14 +422,14 @@ service cloud.firestore {
422
422
  );
423
423
  }
424
424
 
425
- // 좋아요 토글 전용 flat 컬렉션. 문서ID 관례 = `${targetId}_{uid}`.
426
- // 본문: target 없음(레거시) + 필드 4개. 댓글: target=='comment' + commentId.
425
+ // Flat collection for like toggles only. Doc ID convention = `${targetId}_{uid}`.
426
+ // Body: no target (legacy) + 4 fields. Comments: target=='comment' + commentId.
427
427
  match /memiBoardLikes/{likeId} {
428
- // "내가 좋아요한 글/댓글" 쿼리(where uid==auth.uid)를 위해 자기 것은 항상 읽을 수
429
- // 있다. 그 외에는 구버전과 동일하게, 해당 글을 읽을 수 있는 사람이면 (uid를
430
- // 정확히 알고 있는 한) 개별 get() 으로 좋아요 여부를 확인할 수 있다.
431
- // resource == null: 아직 없는 좋아요 문서를 트랜잭션에서 get 할 때.
432
- // 없으면 resource.data 접근이 실패해 일반 사용자의 첫 좋아요가 permission-denied 가 된다.
428
+ // Your own likes are always readable for the "posts/comments I liked" query (where uid==auth.uid).
429
+ // Otherwise, as in older versions, anyone who can read the post can check whether a like exists
430
+ // via an individual get() (as long as they know the exact uid).
431
+ // resource == null: when getting a like document that doesn't exist yet in a transaction.
432
+ // Without this, accessing resource.data fails and a regular user's first like becomes permission-denied.
433
433
  allow read: if isBoardAdmin() || (isSignedIn() && resource == null)
434
434
  || (isSignedIn() && request.auth.uid == resource.data.uid)
435
435
  || (resource != null && canReadPost(get(postPath(resource.data.postId)).data))
@@ -444,10 +444,10 @@ service cloud.firestore {
444
444
 
445
445
  allow delete: if isBoardAdmin() || (isSignedIn()
446
446
  && (request.auth.uid == resource.data.uid || canDeleteLike(resource.data)));
447
- // update 는 허용하지 않음 — 토글은 항상 create-or-delete.
447
+ // update is not allowed — toggling is always create-or-delete.
448
448
  }
449
449
 
450
- // 글 신고. 문서ID = `${postId}_{uid}`. 내용은 본인·스태프만 읽는다.
450
+ // Post reports. Doc ID = `${postId}_{uid}`. Contents are readable only by the reporter and staff.
451
451
  match /memiBoardReports/{reportId} {
452
452
  allow read: if (isSignedIn() && resource == null)
453
453
  || (isSignedIn() && request.auth.uid == resource.data.uid)