Files
fanuzu/app/firebase/firestore.rules

1053 lines
59 KiB
Plaintext

rules_version = '2';
// FANUZU — Firestore 보안 규칙 (2주차 설계본 v0.1)
//
// 근거
// - 요구사항 정의서 권한 요구 역할 3종 · 운영자 등급 3단계
// - 별첨 1 기능별 완료 기준표 「전 기능 공통 완료 기준 — 서버검증 원칙」
// - 1주차 정책 설계서 P-02 원장 무결성 · P-06 어뷰징 방어
//
// 3대 원칙
// 1) POP · FPI 집계 · 원장 필드는 클라이언트가 쓸 수 없다.
// 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다.
// → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장.
// 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다.
// 3) 운영자 권한은 커스텀 클레임 role(master|ops|viewer)로만 판정한다.
// Firestore 문서 조회로 판정하지 않는다 — 판정 1회당 읽기 1회 비용이 붙고,
// 규칙 평가 중 문서 읽기는 우회 표면을 늘린다.
//
// 미구현 표기
// - 규칙은 거부만 할 수 있고 로그를 남기지 못한다. rbac-summary 「관리자 권한 없는 접근은
// E-PERM-01로 차단하고 시도 자체를 작업 로그에 남긴다」의 로그 적재는 Admin 콘솔이
// Cloud Functions를 경유하도록 설계해 Functions 층에서 처리한다(설계서 §8 fn-admin-*).
service cloud.firestore {
match /databases/{database}/documents {
// ───────────────────────── 공통 헬퍼 ─────────────────────────
function signedIn() { return request.auth != null; }
// `{questId}_{uid}` 꼴 문서 ID 가 호출자 본인 것인가(뒤가 `_` + uid).
function isOwnSubmissionId(id) {
let tail = '_' + request.auth.uid;
return id.size() > tail.size() && id[id.size() - tail.size():id.size()] == tail;
}
function isSelf(u) { return signedIn() && request.auth.uid == u; }
function role() { return signedIn() ? request.auth.token.get('role', '') : ''; }
function isViewer() { return role() == 'master' || role() == 'ops' || role() == 'viewer'; }
function isOps() { return role() == 'master' || role() == 'ops'; }
function isMaster() { return role() == 'master'; }
// 업데이트가 지정한 필드만 건드렸는지 검사 — 그 외 필드 위조를 차단한다.
function touchesOnly(fields) {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
}
// 닉네임 길이 — 정책 원장 B-52 (2~20자 · 중복 허용). 검증 기준이
// 「20자 성공 · 21자 거부」이므로 길이를 규칙에서 본다. 앱도 같은 두 수를
// `nicknameMinLength`·`nicknameMaxLength` 로 들고 있다.
// 닉네임을 건드리지 않는 갱신(사진·언어 등)은 이 검사를 통과시킨다.
// 예약어 판정용 정규화 — 폭 없는 문자(U+200B~200D · U+FEFF)를 지우고 전각 공백(U+3000)을
// 공백으로 본 뒤 앞뒤 공백을 떼고 소문자로. 앱 `normalizeNickname` 과 같은 계산.
function nicknameKey(n) {
return n.replace('[\\x{200B}-\\x{200D}\\x{FEFF}]', '').replace('\\x{3000}', ' ').trim().lower();
}
function nicknameOk() {
return !('nickname' in request.resource.data.diff(resource.data).affectedKeys())
|| (request.resource.data.nickname is string
&& request.resource.data.nickname.size() >= 2
&& request.resource.data.nickname.size() <= 20
// 운영 사칭 이름 차단 — 대소문자 무시 부분 일치. 앱 `reservedNicknameWords` 와 같은 목록.
&& !nicknameKey(request.resource.data.nickname)
.matches('.*(fanuzu|운영팀|관리자|admin|official).*')
// 닉네임 없는 작성자 표기(`publicNameOf` 「팬」)와 그 번역(Fan · ファン · 粉丝)은
// 정확히 일치하면 막는다. 앱 `reservedFanNames` 와 같은 목록.
&& !(nicknameKey(request.resource.data.nickname) in ['팬', 'fan', 'ファン', '粉丝']));
}
// 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단.
function authorIsSelf() {
return signedIn() && request.resource.data.authorUid == request.auth.uid;
}
// 요청자의 users 문서. 비정규화 복제 필드(표시명·팬덤)의 원본 대조에 쓴다.
// §7.1 원칙 3은 "역할 판정"에 get()을 쓰지 않는다는 뜻이고, 여기는 역할 판정이 아니다.
// 호출 지점은 전부 생성(create) 1건 단위라 목록 조회처럼 읽기가 곱해지지 않는다.
// 같은 평가 안에서 같은 경로의 get()은 1회로 합산된다.
function myUser() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
}
// 다른 사용자에게 보이는 작성자 이름 — 앱 `publicName(nickname)` 과 같은 계산.
// 닉네임이 없거나 이메일 형태면 「팬」. 공급자 실명(displayName)은 쓰지 않는다(M-7).
function publicNameOf(u) {
let n = u.get('nickname', '');
return (n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*')) ? n.trim() : '팬';
}
// 공개 닉네임이 있는가 — publicNameOf 가 「팬」으로 대체하지 않는 경우와 같다.
function hasPublicNickname() {
let n = myUser().get('nickname', '');
return n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*');
}
// 라운지 글 · 댓글 · 인사이드 답변의 작성자 이름이 공개 닉네임과 같은지 — 사칭 차단.
function authorNameIsPublic() {
return request.resource.data.authorName == publicNameOf(myUser());
}
// 팬덤 회원인가. 멤버십 문서(`fandoms/{id}/members/{uid}`)가 있거나, 없던 시절의
// 원천인 대표 팬덤(`users.fandomId`)이 같으면 회원이다. 두 원천을 모두 받아
// 멤버십 도입 전후 어느 쪽에서도 같은 판정이 난다.
function isFandomMember(fandomId) {
return signedIn()
&& (exists(/databases/$(database)/documents/fandoms/$(fandomId)/members/$(request.auth.uid))
|| myUser().fandomId == fandomId);
}
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단
function fandomIsMine() {
return isFandomMember(request.resource.data.fandomId);
}
// 라운지 반응(좋아요 · 댓글)을 남길 수 있는 글인가 — 보이는 글이고, 요청자가
// 그 글의 팬덤 회원이다. 글 작성과 같은 회원 판정을 쓴다.
function canReactTo(postId) {
let post = get(/databases/$(database)/documents/posts/$(postId)).data;
return post.visibility == 'visible' && isFandomMember(post.fandomId);
}
// 라운지 본문 — 빈 글 차단 · 길이 상한(글 5,000 · 댓글 1,000).
function bodyOk(max) {
return request.resource.data.body is string
&& request.resource.data.body.size() > 0
&& request.resource.data.body.size() <= max;
}
// 서버 시각 강제 — 앱은 serverTimestamp 를 보낸다. 시각 위조로 정렬 맨 위를 차지하지 못한다.
function createdNow() {
return request.resource.data.createdAt == request.time;
}
function updatedNow() {
return request.resource.data.updatedAt == request.time;
}
// 라운지 글 사진 — Storage 경로를 담는다(`users/{uid}/lounge/{postId}-{i}`).
// URL 이 아니라 경로라 버킷 이름에 묶이지 않고, 칸마다 정확한 경로만 받으므로
// 남의 파일 · 외부 주소 · 다른 글의 사진을 끼워 넣지 못한다. B-44 「이미지 5 장」.
function loungeImagesOk(postId) {
let urls = request.resource.data.imageUrls;
let base = 'users/' + request.auth.uid + '/lounge/' + postId + '-';
return urls is list && urls.size() <= 5
&& (urls.size() < 1 || urls[0] == base + '0')
&& (urls.size() < 2 || urls[1] == base + '1')
&& (urls.size() < 3 || urls[2] == base + '2')
&& (urls.size() < 4 || urls[3] == base + '3')
&& (urls.size() < 5 || urls[4] == base + '4');
}
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외
function campaignIsPublic() {
return resource.data.status in ['funding', 'success', 'fail', 'report'];
}
// 하위 문서(댓글 · 좋아요) 쓰기에서 부모 캠페인이 공개 상태인가.
function campaignOpenFor(campaignId) {
return get(/databases/$(database)/documents/campaigns/$(campaignId)).data.status
in ['funding', 'success', 'fail', 'report'];
}
// 응원 게시판 댓글 작성 자격 — config/policy.campaignCommentScope(1. 운영 파라미터 참고).
function campaignCommentScope() {
let policy = /databases/$(database)/documents/config/policy;
return exists(policy) ? get(policy).data.get('campaignCommentScope', 'all') : 'all';
}
function campaignCommenter(campaignId) {
let scope = campaignCommentScope();
return scope == 'all'
|| (scope == 'members'
&& isFandomMember(get(/databases/$(database)/documents/campaigns/$(campaignId)).data.fandomId))
|| (scope == 'backers'
&& exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid)));
}
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
// 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
//
// config/policy.campaignCommentScope — 응원 게시판 댓글 작성 자격(발주사 확인 중).
// 'all'(없으면 이 값) 로그인 회원 누구나 · 'members' 캠페인 팬덤 회원 · 'backers' 후원자.
// 명세에 작성 자격이 없어 잠정 'all'. 그 밖의 값은 아무도 쓰지 못한다(오타는 닫힌 쪽으로).
//
// config/features — 앱 기능 스위치. 지금 있는 키는 하나다.
// stationEnabled (bool, 기본 false) STATION 하단 탭 노출.
// 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
// Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.*
// 앱은 로그인 전에도 이 문서를 읽으므로 아래 공개 읽기가 그대로 필요하다.
// 쓰기가 마스터 전용인 것이 이 스위치의 유일한 보호막이다.
//
// 이메일/비밀번호 provider 에 의존하는 규칙은 없다(별첨 B v1.4 로 이메일 가입 제외).
// 판정은 전부 request.auth.uid 와 커스텀 클레임 role 로만 한다.
match /config/{configId} {
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
}
// 주간 공식 Ranking Snapshot — 영구 Archive(별첨 B 「Ranking Snapshot」 B-20). 서버만 만들고 고치지 않는다.
match /rankingSnapshots/{week} {
allow read: if signedIn();
allow write: if false;
}
// 팬덤 이름 색인(`fn-nebula-create` 가 문서 ID = 소문자 이름으로 쓴다). 갤럭시 전체 검색(Figma `353:217832`)이
// 접두 조회한다. 값은 공개 정보(팬덤 ID)뿐이다.
match /fandomNames/{name} {
allow read: if signedIn();
allow write: if false;
}
// 어뷰징 탐지 임계치·판정 로직. 노출되면 우회가 가능해 클라이언트 접근을 전면 차단한다.
// 근거: 정책설계서 P-06 「규칙은 공개하지 않는 것을 제안」
match /configPrivate/{configId} {
allow read, write: if false; // 서버(Functions) 전용
}
// 앱 버전 정책 · 점검 모드 · 최소 지원 버전. 미로그인 스플래시가 읽는다.
// 화면: S-A-SPLASH · S-X-MAINT · S-AD-SYSTEM
match /appConfig/{docId} {
allow read: if true;
allow write: if isMaster(); // rbac-summary 「점검 모드·강제 업데이트」 = 마스터 전용
}
// 다국어 문자열 원격 배포 리소스. 화면: S-M-LANG · S-AD-I18N
match /i18n/{locale} {
allow read: if true;
allow write: if isMaster(); // rbac-summary 「다국어 문자열」 = 마스터 전용
}
// ───────────────────────── 2. 사용자 ─────────────────────────
// 계정·프로필. 누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다.
// 화면: S-A-PROFILE · S-M-HOME · S-M-SETTINGS · S-AD-USER
match /users/{uid} {
allow get: if isSelf(uid) || isViewer();
allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER)
// 클라이언트 생성 경로를 닫는다.
// 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고
// ② 클라이언트가 먼저 만들면 totalContributedPop 등 서버 산출 필드가 빈
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
// 생성 주체를 Auth 트리거 하나로 고정한다.
allow create: if false;
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(정책 원장 B-52 —
// 2~20자 · 중복 허용 · 유니크 인덱스 제거). 목록에 없으면 MY 의 닉네임 변경이
// permission-denied 로 떨어지는데, 원인이 화면처럼 보여 찾기 어렵다.
// `displayName` 은 Auth 트리거가 처음 채우는 필드라(§8.5 fn-auth-oncreate) 함께 남긴다.
//
// `fcmTokens` — 단말 푸시 토큰. 재화가 아니고 본인 문서에만 붙으므로 본인이
// 직접 쓴다. 토큰 등록을 Functions 로 돌리면 로그인마다 호출이 한 번 늘고,
// 토큰은 앱이 재발급받을 때마다 바뀌어 그 호출이 계속 반복된다.
//
// `lastSeenAt` — HUD 「LIVE」 접속자 집계 입력(해석 확인 요청 Q-GM-03). 서버 시각만 받고,
// 직전 기록에서 60초가 지나야 다시 쓸 수 있다(앱 주기 2분 · 쓰기 폭주 차단).
// `fandomIds`(가입 팬덤 비정규화)는 목록에 없으므로 서버만 쓴다.
allow update: if isSelf(uid)
&& touchesOnly(['nickname','displayName','photoUrl','countryCode','locale',
'representativeBadgeId','notificationPrefs',
'fcmTokens','lastSeenAt','updatedAt'])
&& nicknameOk()
&& (!('lastSeenAt' in request.resource.data.diff(resource.data).affectedKeys())
|| (request.resource.data.lastSeenAt == request.time
&& (!('lastSeenAt' in resource.data)
|| request.time > resource.data.lastSeenAt + duration.value(60, 's'))));
// 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상
allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']);
allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13)
}
// POP 지갑 요약(집계 필드). 읽기만 허용하고 쓰기는 전부 Functions.
// 화면: S-M-WALLET · S-H-HOME · S-C-FUND
match /users/{uid}/wallet/{docId} {
allow read: if isSelf(uid) || isViewer(); // S-AD-LEDGER 조회 등급 이상
allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」
}
// POP Lot — 획득 단위별 잔량. 원장 대사와 차감의 계산 단위.
// 화면: S-M-WALLET
match /users/{uid}/popLots/{lotId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 잔량 조작 차단
}
// POP 원장. 지급·차감·환불·보류 무효 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다.
// 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER
match /users/{uid}/popTransactions/{txId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 별첨1 ② 원장 정합성 · 멱등키는 Functions 관리
}
// 기본 보상 한 건당 광고 추가 보상 표식(fn-ad-reward). 앱은 이미 받았는지만 읽는다.
match /users/{uid}/adBonuses/{baseTxId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 활동 로그(걸음·스밍·위치·출석). 어뷰징 판정 입력이라 클라이언트 쓰기 금지.
// 화면: S-H-WALK · S-H-STREAM · S-H-PLACE · S-H-ATTEND · S-AD-ABUSE
// 성장 귀속 행(원장과 같은 트랜잭션) — HOME 주간 정보(A-37) 「이번 주 기여량 · 비율」이 본인 행을 합한다.
match /users/{uid}/growthEvents/{txId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
match /users/{uid}/activityLogs/{logId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 퀘스트 진행·일일 한도 카운터. 한도 우회를 막기 위해 서버 전용.
// 화면: S-H-HOME · S-X-AD(일일 한도)
match /users/{uid}/questProgress/{questId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 보유 배지 이력. 부여는 서버 판정.
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
//
// 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
// **1차 범위에 타인 프로필 화면이 없다**(화면 95개 전수 확인). 주석이 사실과 달랐다.
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
// 랭킹 하나 보여주려고 타인 문서를 통째로 여는 경로를 닫는다.
// 성운 일 1회 기록(`fn-nebula-create` 가 쓴다). GALAXY 「성운 생성」 버튼이 오늘 이미 만들었는지
// 보고 비활성화 · 초과 안내를 띄운다(Figma `540:19592`). 쓰기는 서버만.
match /users/{uid}/nebulaCreates/{day} {
allow read: if isSelf(uid);
allow write: if false;
}
match /users/{uid}/badges/{badgeId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 팬덤별 Passport(Tier · 첫 가입 · 순번). 산출은 서버(`account/passport.ts`), 탈퇴해도 남는다.
// 화면: MY Passport 카드(팬덤 전환) — 9/21 회신 5절 · 10/3 #10
match /users/{uid}/passports/{fandomId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// PASSPORT 역사 기록(Eclipse 성공 참여 등 — FX §5.2). 서버만 쓴다.
match /users/{uid}/passportHistory/{id} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 구독(Focus) 상태. 영수증 검증 결과라 서버 전용.
// 화면: S-M-FOCUS
match /users/{uid}/subscription/{docId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 결제 기록. `fn-iap-verify` 가 쓰고 환불 회수는 `fn-iap-refund-webhook` 가 바꾼다.
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (정책 원장 B-02 · B-72)
// 본인이 할 수 있는 것은 **지급 완료 → 환불 요청** 한 가지 전이뿐이다. 금액·상태의
// 다른 전이(승인·반려·회수)는 Admin 과 서버가 한다.
match /users/{uid}/purchases/{purchaseId} {
allow read: if isSelf(uid) || isViewer();
allow update: if isSelf(uid)
&& resource.data.state == 'granted'
&& request.resource.data.state == 'refund-requested'
&& request.resource.data.refundReason is string
&& request.resource.data.refundReason.size() > 0
&& request.resource.data.refundReason.size() <= 100
&& request.resource.data.refundRequestedAt == request.time
&& touchesOnly(['state', 'refundReason', 'refundRequestedAt']);
allow create, delete: if false;
}
// 알림함. 읽음 처리만 본인이 할 수 있다.
// 화면: S-X-NOTICE
match /users/{uid}/notifications/{notiId} {
allow read: if isSelf(uid);
allow update: if isSelf(uid) && touchesOnly(['read','readAt']);
allow create, delete: if false; // 발송은 Functions(FCM) 경유
}
// 가입한 행성/성운 목록(명세 §2.58 「가입 또는 생성한 행성/성운」). 가입 · 탈퇴 Function 만 쓴다 —
// 클라이언트가 쓰면 가입하지 않은 팬덤이 목록에 끼고 `memberCount` 와 어긋난다.
match /users/{uid}/memberships/{fandomId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 검증 활동 표식(`fpi.ts` markCompletion). MY 「완료 퀘스트」가 본인 표식 수를 센다(count 집계).
// 표식은 Campaign·Quest 개설자격의 근거라 서버만 쓴다. 화면: S-M-HOME
match /users/{uid}/verifiedCompletions/{completionId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다.
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
match /users/{uid}/consents/{consentId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 기록은 fn-consent-record 경유
}
// 차단 목록. 본인만 읽고 쓴다.
// 화면: S-M-BLOCK · S-I-REPORT
match /users/{uid}/blocks/{targetUid} {
allow read, write: if isSelf(uid);
}
// ───────────────────────── 3. 아티스트 · 팬덤 · 행성 ─────────────────────────
// 아티스트 마스터. 온보딩에서 미인증 상태로도 목록을 본다.
// 화면: S-A-ARTIST · S-W-PREREG-NEW
match /artists/{artistId} {
allow read: if true;
allow write: if isOps();
}
// 팬덤. fpiScore·orbit 등 집계 필드가 붙어 있어 클라이언트 쓰기를 전면 금지한다.
// 화면: S-G-MAP · S-G-FPI · S-A-ARTIST
match /fandoms/{fandomId} {
allow read: if true; // 웹 브릿지(미로그인) 요약 노출 — D-16
allow write: if false; // 생성·집계 모두 Functions
}
// 팬덤 멤버십 + 개인 기여 집계(히어로 랭킹 원천).
// 화면: S-M-HERO · S-I-LOUNGE(내 기여도)
match /fandoms/{fandomId}/members/{uid} {
allow read: if signedIn();
allow write: if false; // 기여 POP 집계 = 서버 산출값
}
// 사용자 행성. 성장 단계·누적 기여 POP은 서버 산출이고, 외형 설정만 본인이 바꾼다.
// 화면: S-G-PLANET · S-G-THEME · S-A-PLANET-NEW
match /fandoms/{fandomId}/planets/{planetId} {
allow read: if signedIn(); // 타인 행성 열람 허용(갤럭시 맵 진입)
// 원장 v1.2 C-50 — 행성 외형은 **생성 시 형태(planetType) + 컬러(colorKey)만** 고르고
// 이후 사용자 커스텀은 없다. 생성은 온보딩 Function(fn-planet-create)이 하며,
// themeId · effectLevel 은 서버 산출값이다. 클라이언트 쓰기는 전부 닫는다.
allow create, update, delete: if false;
}
// 갤럭시 맵 상단에 흐르는 최근 사건(이클립스 · 아이템 사용).
// 로그인한 사용자에게만 보인다 — 맵 자체가 로그인 뒤 화면이다.
// 쓰기는 서버(Functions)만 한다. 클라이언트가 사건을 만들면 맵이 조작된다.
// 화면: S-G-GALAXY
match /galaxyEvents/{eventId} {
allow read: if signedIn();
allow write: if false;
}
// 성장 단계 정의(7단계). 마스터 데이터.
// 화면: S-G-STAGE
match /planetStages/{stageNo} {
allow read: if true;
allow write: if isMaster();
}
// FPI 이벤트(집계 입력). 클라이언트는 읽지도 쓰지도 않는다 — 가중치 역산 방지.
// 화면: S-AD-LEDGER(FPI 집계 로그)
match /fandoms/{fandomId}/fpiEvents/{eventId} {
allow read: if isViewer();
allow write: if false;
}
// FPI 사전 집계 스냅샷(축별 순위·등락). 랭킹 화면의 실제 읽기 대상.
// 화면: S-G-FPI · S-G-CONTRIB
match /fandoms/{fandomId}/fpiSnapshots/{periodKey} {
allow read: if signedIn();
allow write: if false;
}
// 이클립스 회차(설계서 v1.2 §2.11 `eclipseRounds` — v0.3.0 `eclipses` 에서 전환).
// 화면: S-G-ECLIPSE · S-AD-ECLIPSE
match /fandoms/{fandomId}/eclipseRounds/{eclipseId} {
allow read: if signedIn();
allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준)
}
// 참여자별 유효 Shake 누적(`participants.validShakes` — v0.3.0 `defenders`). 사용자당
// 상한과 속도 제한의 근거이며, 문서 ID 가 uid 라 중복이 구조적으로 없다(G-04).
// 판정은 서버가 하므로 클라이언트 쓰기를 막는다. 화면: S-G-ECLIPSE(내 참여량)
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/participants/{uid} {
allow read: if signedIn(); // 참여자 목록은 공동 이벤트의 일부다
allow write: if false; // 상한·속도 판정은 fn-eclipse-defend
}
// 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다.
// 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다.
// 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/shards/{shardId} {
allow read: if signedIn(); // 합산은 클라이언트가 해도 무방
allow write: if false;
}
// 방어 로그(이클립스 단위 원장). 원천은 여기고, 화면이 구독하는 것은 아래
// 팬덤 단위 liveEvents 다 — 티커는 활성 이클립스 ID 를 모른 채 떠야 한다.
// 화면: S-G-ECLIPSE(실시간 방어 로그)
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/defenseLogs/{logId} {
allow read: if signedIn();
allow write: if false;
}
// 라이브 소셜 프루프(방어 참여 · 이후 아이템 사용). 홈 라이브 띠와 갤럭시
// 섬광 배너가 이 컬렉션을 구독한다. 닉네임·POP·시각만 담는다.
// 클라이언트가 쓰면 「누가 방어했다」를 위조할 수 있어 Functions 전용이다.
// 화면: S-H-HOME(라이브 띠) · S-G-GALAXY(섬광 배너)
// 근거: 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프 실시간 노출」
match /fandoms/{fandomId}/liveEvents/{eventId} {
allow read: if signedIn();
allow write: if false;
}
// 영웅의 선물 발동 이벤트.
// 화면: S-X-HERODROP
match /fandoms/{fandomId}/heroDrops/{dropId} {
allow read: if signedIn();
allow write: if false;
}
// 수령 내역. 문서 ID = uid 로 중복 수령을 구조적으로 차단한다(E-POP-03).
// 화면: S-X-HERODROP
match /fandoms/{fandomId}/heroDrops/{dropId}/claims/{uid} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 수령은 fn-herodrop-claim 경유
}
// Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID = 스토어 상품 ID.
// 화면: 홈 「아이템 구입」 · Admin `관리 마스터_아이템 관리`(fnAdminWrite 경유)
// 지급량(buyerPop · dropPop)은 `fn-iap-verify` 가 여기서 읽으므로 클라이언트 쓰기를 막는다.
match /heroGiftProducts/{productId} {
allow read: if signedIn();
allow write: if false;
}
// 단결형 아이템 발동 상태.
// 화면: S-M-SHOP · 근거: 별첨 1 ⑥ 「지급 대상·배수·시간창 정확」
match /fandoms/{fandomId}/solidarityBoosts/{boostId} {
allow read: if signedIn();
allow write: if false; // 발동·배수·시간창은 Functions 전용
}
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
// 위치 Quest 의 QR 코드(정책 원장 C-03) — 생성자가 현장에 붙일 QR 을 띄우려고 읽는다. 참여자가 읽으면
// 찍지 않고 값을 보낼 수 있으므로 생성자 · 조회 등급만. `fn-quest-create` 가 발급하고 고치지 않는다.
match /questSecrets/{questId} {
allow read: if signedIn() && (resource.data.authorUid == request.auth.uid || isViewer());
allow write: if false;
}
match /quests/{questId} {
allow read: if signedIn();
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
// 공개 후 수정 이력(C-07). 쓰기는 fn-quest-edit 전용 — append-only.
match /edits/{editId} {
allow read: if signedIn();
allow write: if false;
}
}
// 사용자 생성 Quest 제출 · 검토 · 지급(C-08 · C-17 · G-01 · G-03).
// 제출자 · 그 Quest 의 생성자 · 운영자만 읽는다. 상태 전이와 예약은 fn-quest-submit /
// fn-quest-review 가 원장과 한 트랜잭션에서 하므로 클라이언트 쓰기는 없다.
match /questSubmissions/{submissionId} {
allow read: if signedIn() && (resource.data.uid == request.auth.uid
|| resource.data.questAuthorUid == request.auth.uid
|| isOps());
// 자기 제출 건은 문서 ID(`{questId}_{uid}` — fn-quest-submit)로 **없어도** 조회한다.
// 없는 문서는 resource 가 null 이라 위 조건이 거부되고, 앱의 퀘스트 목록이 UGC 퀘스트
// 하나 때문에 통째로 실패했다(2026-10-04 실기기).
allow get: if signedIn() && isOwnSubmissionId(submissionId);
allow write: if false;
}
// 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL
match /places/{placeId} {
allow read: if signedIn();
allow write: if isOps();
}
// AI Daily Briefing 카드. 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
//
// **게시분만 공개한다.** 예전 규칙은 `if true` 였는데, 그러면 승인 전 Draft 가
// 누구에게나 읽혔다. 별첨 B B-47 의 판정 기준이 「미승인 Draft 노출 0건」이므로
// 규칙 층에서 막는다 — 앱이 걸러 주는 것과 읽을 수 없는 것은 다르다.
// 로그인 없는 웹 브릿지 열람(D-16)은 게시분만 필요하므로 그대로 열려 있다.
//
// 앱 질의가 `status == 'published'` 를 포함하므로 목록 조회도 통과한다.
// 운영자는 검수를 위해 Draft 를 봐야 해서 `isViewer()` 를 함께 둔다.
//
// **쓰기는 함수만 한다.** 예전 규칙의 `isOps()` 는 `fn-briefing-publish` 를
// 우회해 상태를 바꿀 수 있는 길이었고, 그 경로로 바뀐 게시는 `adminActionLogs`
// 에 남지 않는다. 누가 무엇을 승인했는지가 사라지면 B-47 의 승인 게이트가
// 사실상 없는 것과 같다.
match /briefings/{briefingId} {
allow read: if resource.data.status == 'published' || isViewer();
allow write: if false; // 수신·승인은 Functions 전용
}
// 배지 도감 마스터. 화면: S-M-BADGE
match /badges/{badgeId} {
allow read: if true;
allow write: if isMaster();
}
// ───────────────────────── 5. 캠페인 ─────────────────────────
// 캠페인. 목표 달성률·후원 합계는 집계 필드라 클라이언트가 못 쓴다.
// 화면: S-C-LIST · S-C-DETAIL · S-C-NEW · S-AD-CAMPAIGN
//
// ⚠️ 목록 쿼리에는 status 필터가 필수다.
// 아래 read 규칙이 공개 상태만 허용하므로, status 필터가 없는 목록 쿼리는
// draft·review 문서까지 매칭돼 **쿼리 전체가 거부**된다(Firestore 는 list 규칙을
// 반환 후보 전체에 적용한다). 인덱스 문제로 오인하기 쉬운 지점이다.
// 허용되는 목록 쿼리 형태는 설계서 §6.1 IDX-15·16·17·19 참조.
match /campaigns/{campaignId} {
// 예전에는 read: if true 라 심사 중(review)·초안(draft) 캠페인 본문이
// URL만 알면 미로그인 상태로 읽혔다. FR-CMP-01「승인 대기 캠페인은 목록에 노출하지 않는다」(E-CMP-03)를
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
allow read: if campaignIsPublic()
|| isOps()
|| (signedIn() && resource.data.creatorUid == request.auth.uid)
// 회사 귀책 취소(B-58)는 후원자에게 환불 알림이 가고 그 링크가 이 문서를 연다.
// 후원 문서 ID 가 거래 ID 라 규칙이 찾을 수 없어, fn-campaign-fund 가 같은
// 트랜잭션에 쓰는 `backers/{uid}` 표식으로 후원 여부를 본다. 철회(withdrawn)는
// 공개 전·첫 후원 전에만 가능해 후원자가 없으므로 계속 비공개다.
|| (signedIn() && resource.data.status == 'cancelled'
&& exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid)));
// 개설은 `fn-campaign-create` 전용이다. 등록비 차감(B-57)과 자격 판정(B-56),
// 심사 대기(review) 진입이 한 트랜잭션이어야 하고, 개설자는 status 를 쓸 수 없어 직접
// 만든 draft 는 심사로 넘어갈 길이 없었다. 화이트리스트 · 집계 초기값
// 판정은 이제 함수가 필드를 직접 정해 쓰는 것으로 대신한다.
allow create: if false;
// 개설자는 심사 전 초안만 고칠 수 있다.
allow update: if signedIn()
&& resource.data.creatorUid == request.auth.uid
&& resource.data.status == 'draft'
&& touchesOnly(['title','summary','body','coverImageUrl',
'targetPop','campaignType','endsAt','updatedAt']);
// 승인·반려·상태 전이. rbac-summary 「캠페인 승인·반려」 = 운영 이상
allow update: if isOps()
&& touchesOnly(['status','reviewNote','rejectReason','reviewedBy','updatedAt']);
allow delete: if false; // 원장 연결 문서라 삭제 대신 상태 전이
}
// 후원 내역. POP 차감과 1:1이라 전량 서버 경유.
// 화면: S-C-FUND · S-C-MINE · S-C-REFUND
// 캠페인 좋아요. 문서 ID = uid 로 중복을 구조적으로 막고, 개수는 앱이
// 집계 쿼리로 센다 — 캠페인 문서의 화이트리스트를 넓히지 않는다. 공개 캠페인에만 누른다.
match /campaigns/{campaignId}/likes/{uid} {
allow read: if signedIn();
allow create: if isSelf(uid)
&& request.resource.data.keys().hasOnly(['createdAt'])
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
.data.status in ['funding','success','fail','report'];
allow delete: if isSelf(uid);
allow update: if false;
}
// 응원 게시판 댓글. 화면: S-C-DETAIL 응원 게시판 탭 · 시안 `527:21190`.
// 라운지 댓글과 같은 틀 — 표시명은 공개 닉네임만(M-7), 서버 시각, 1,000자.
// 누가 쓰는가: 운영값 `config/policy.campaignCommentScope`(all · members · backers).
// 명세에 제한이 없어 잠정 'all' = 로그인 회원 누구나.
// 공개 캠페인에만 단다. `commentCount` 는 fnCampaignCommentCount 가 다시 센다.
match /campaigns/{campaignId}/comments/{commentId} {
// 숨김 댓글은 운영만 본다. 목록 질의는 `visibility == 'visible'` 로 좁혀야 통과한다.
allow read: if isOps() || (signedIn() && resource.data.visibility == 'visible');
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'authorUid','authorName','body','visibility','createdAt','updatedAt'
])
// 응원 게시판은 「팬」 대체 이름을 받지 않는다 — 공개 닉네임이 있어야 쓴다.
&& hasPublicNickname()
&& authorNameIsPublic()
&& request.resource.data.visibility == 'visible'
&& bodyOk(1000)
&& createdNow() && updatedNow()
&& campaignOpenFor(campaignId)
&& campaignCommenter(campaignId);
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
// 운영이 숨긴 댓글은 작성자가 지워 증거를 없애지 못한다.
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid
&& resource.data.visibility == 'visible';
}
// 응원 게시판 댓글 좋아요(시안 `527:21192`). 문서 ID = uid 로 1인 1회.
// 댓글의 `likeCount` 는 fnCampaignCommentLikeCount 가 다시 센다 — 댓글 문서는
// 운영의 숨김 외에 수정 경로가 없어 위조할 수 없다.
match /campaigns/{campaignId}/comments/{commentId}/likes/{uid} {
allow read: if signedIn();
allow create: if isSelf(uid)
&& request.resource.data.keys().hasOnly(['createdAt'])
&& createdNow()
&& get(/databases/$(database)/documents/campaigns/$(campaignId)/comments/$(commentId))
.data.visibility == 'visible'
&& campaignOpenFor(campaignId)
// 댓글을 쓸 수 없는 사람은 좋아요도 누를 수 없다(같은 작성 자격).
&& campaignCommenter(campaignId);
allow delete: if isSelf(uid);
allow update: if false;
}
// 후원자 표식. 문서 ID = uid. `fn-campaign-fund` 가 후원 문서와 같은 트랜잭션에 쓴다.
match /campaigns/{campaignId}/backers/{uid} {
allow read: if isSelf(uid) || isOps();
allow write: if false;
}
match /campaigns/{campaignId}/donations/{donationId} {
allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL)
allow write: if false; // 별첨1 ⑤ 환불 무결성
}
// 결과 보고. 성공 캠페인 개설자(또는 운영)가 1회 제출하고 이후 운영이 관리한다.
// 화면: S-C-RESULT
match /campaigns/{campaignId}/reports/{reportId} {
allow read: if true;
// 대상은 성공한 캠페인이다(별첨1 ⑤ 「결과보고 게시 확인」).
// 제출은 `fn-campaign-report-submit` 전용이다. 보고 문서와 캠페인 상태 `report`
// 전이·후원자 알림이 한 번에 서야 하는데, 개설자는 캠페인 status 를 쓸 수 없어
// 클라이언트 직접 생성으로는 「보고는 있는데 상태는 success」가 남는다.
allow create: if false;
allow update: if isOps();
allow delete: if false;
}
// ───────────────────────── 6. INSIDE 커뮤니티 ─────────────────────────
// 라운지 게시물. 좋아요 수는 집계 필드라 본인 수정 목록에서 제외한다.
// 화면: S-I-LOUNGE · S-I-POST · S-I-POST-NEW
match /posts/{postId} {
allow read: if signedIn();
// 예전에는 authorUid·likeCount·visibility 3개만 봐서
// ① authorName 을 "FANUZU 운영팀"으로 적어 사칭 ② commentCount 위조
// ③ 소속하지 않은 팬덤 피드에 글 삽입이 가능했다. 화이트리스트 + 원본 대조로 닫는다.
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'fandomId','authorUid','authorName','body','imageUrls',
'likeCount','commentCount','visibility','createdAt','updatedAt'
])
&& authorNameIsPublic()
&& fandomIsMine()
&& request.resource.data.likeCount == 0
&& request.resource.data.commentCount == 0
&& request.resource.data.visibility == 'visible'
&& bodyOk(5000)
&& createdNow() && updatedNow()
// 1 장 용량 · 형식 · 파일 이름은 storage.rules 가 본다.
&& loungeImagesOk(postId);
// 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
// 사진은 작성 후 바꾸지 않는다 — 본문만 고친다.
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
&& touchesOnly(['body','updatedAt'])
&& bodyOk(5000)
&& updatedNow();
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
}
// 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다.
// likeCount 는 fnPostLikeCount 트리거가 하위 문서 수를 다시 세어 적는다 —
// 클라이언트는 글 문서의 수를 못 건드리므로(위 touchesOnly) 위조 경로가 없다.
match /posts/{postId}/likes/{uid} {
allow read: if signedIn();
allow create: if isSelf(uid)
&& request.resource.data.keys().hasOnly(['createdAt'])
&& createdNow()
&& canReactTo(postId);
allow delete: if isSelf(uid); // 취소는 회원 자격과 무관하게 연다
allow update: if false;
}
// 댓글. 화면: S-I-POST
match /posts/{postId}/comments/{commentId} {
allow read: if signedIn();
// 댓글에도 같은 표시명 사칭 경로가 있었다.
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'authorUid','authorName','body','visibility','createdAt','updatedAt'
])
&& authorNameIsPublic()
&& request.resource.data.visibility == 'visible'
&& bodyOk(1000)
&& createdNow() && updatedNow()
&& canReactTo(postId); // 팬덤 회원만 · 보이는 글에만
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
&& touchesOnly(['body','updatedAt'])
&& bodyOk(1000)
&& updatedNow();
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
}
// 현상금 Q&A 질문. 현상금 예치가 POP 차감이라 생성도 Functions 경유다.
// 화면: S-I-KNOW · S-I-QUESTION · S-I-QUESTION-NEW
match /questions/{questionId} {
allow read: if signedIn();
allow write: if false; // fn-question-create(예치) · fn-answer-accept(전송)
}
// 답변. 등록은 재화 이동이 없어 클라이언트 쓰기를 허용하되, 채택 필드는 잠근다.
// 화면: S-I-QUESTION · S-I-ANSWER-NEW
match /questions/{questionId}/answers/{answerId} {
allow read: if signedIn();
// 답변에도 같은 표시명 사칭 경로가 있었다.
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'authorUid','authorName','body','sourceUrls','accepted',
'visibility','createdAt','updatedAt'
])
&& authorNameIsPublic()
&& request.resource.data.accepted == false
// 서버 시각만 받는다 — 과거 시각으로 최신순 정렬을 흔들지 못하게.
&& request.resource.data.createdAt == request.time
// 채택·만료된 질문에는 답변을 달지 않는다(현상금이 이미 정산됨).
&& get(/databases/$(database)/documents/questions/$(questionId)).data.status == 'open';
// 채택된 답변은 본문을 고칠 수 없다 — 채택 후 내용 바꿔치기 차단.
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
&& resource.data.accepted == false
&& touchesOnly(['body','sourceUrls','updatedAt']);
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
allow delete: if false; // 채택 대상이라 삭제 대신 숨김
}
// 신고. 접수는 본인 명의로만, 처리 상태는 운영만 바꾼다.
// 화면: S-I-REPORT · S-AD-REPORT
match /reports/{reportId} {
allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid);
allow list: if isOps();
// 설계서 §2.6 reports 스키마로만 받는다. 문서 ID 를 「post-{글}-{신고자}」로
// 규칙이 묶어 두므로 같은 사람의 재신고는 update 가 되어 아래에서 거부되고,
// 임의 ID 로 같은 글을 여러 번 신고할 수도 없다(B-45 「신고자 수」).
// 지금 앱이 접수하는 대상은 라운지 글 · 캠페인(`campaign-{캠페인}-{신고자}`) ·
// 캠페인 응원 게시판 댓글(`campaignComment-{캠페인}-{댓글}-{신고자}`)이다. 라운지 댓글 · 답변 · 회원
// 신고는 앱 경로가 생길 때 같은 방식으로 ID 를 묶어 연다.
// 사유 코드는 Figma 사본 신고 사유 4항목(`moderation.dart`)의 코드다.
allow create: if signedIn()
&& request.resource.data.keys().hasOnly([
'reporterUid','targetType','targetPath','reasonCode',
'status','createdAt'
])
&& request.resource.data.reporterUid == request.auth.uid
&& request.resource.data.targetPath is string
&& ((request.resource.data.targetType == 'post'
&& request.resource.data.targetPath.matches('posts/[^/]+')
&& reportId == 'post-' + request.resource.data.targetPath.split('/')[1]
+ '-' + request.auth.uid)
|| (request.resource.data.targetType == 'campaign'
&& request.resource.data.targetPath.matches('campaigns/[^/]+')
&& reportId == 'campaign-' + request.resource.data.targetPath.split('/')[1]
+ '-' + request.auth.uid)
|| (request.resource.data.targetType == 'campaignComment'
&& request.resource.data.targetPath.matches('campaigns/[^/]+/comments/[^/]+')
&& reportId == 'campaignComment-' + request.resource.data.targetPath.split('/')[1]
+ '-' + request.resource.data.targetPath.split('/')[3]
+ '-' + request.auth.uid))
&& request.resource.data.reasonCode in
['defamation','inappropriate','spam','privacy']
&& request.resource.data.status == 'pending'
&& createdNow();
allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']);
allow delete: if false;
}
// 문의. 화면: S-M-INQUIRY · S-AD-REPORT
match /inquiries/{inquiryId} {
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
// 내 문의 목록(S-M-INQUIRY · Figma `373:634916`)이 자기 문의를 열거한다.
// 규칙이 문서마다 `authorUid` 를 보므로, 질의가 `authorUid == uid` 로
// 좁혀지지 않으면 **질의 전체가 거부**된다 — 남의 문의는 한 건도 새지 않는다.
allow list: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
// 작성자 수정 — Figma 문의 수정 `554:24330` · 상세의 「수정」 `373:635123`.
// 접수(`pending`) 상태에서만, 제목과 본문만 고칠 수 있다. `status` 를 함께
// 건드리면 거부되므로 스스로 「답변 완료」로 올릴 수 없고, 운영이 상태를
// 올린 뒤에는 이 갈래가 닫힌다.
allow update: if signedIn()
&& resource.data.authorUid == request.auth.uid
&& resource.data.status == 'pending'
&& touchesOnly(['title','body','updatedAt']);
allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']);
allow delete: if false;
}
// ───────────────────────── 7. STATION · 광고 · 공지 ─────────────────────────
// 큐레이션 링크. 화면: S-S-HUB
match /stationLinks/{linkId} {
allow read: if signedIn();
allow write: if isOps();
}
// 사용자 제안 링크. 심사 상태는 운영만 바꾼다. 화면: S-S-SUGGEST
match /stationSuggestions/{suggestionId} {
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
allow list: if isOps();
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
allow update: if isOps() && touchesOnly(['status','reviewNote','updatedAt']);
allow delete: if false;
}
// 광고 보상 로그. 보상 지급 근거라 서버 전용. 화면: S-X-AD · S-AD-ADREV
match /adRewardLogs/{logId} {
allow read: if isViewer(); // rbac-summary 「광고 보상 내역」 = 조회 이상
allow write: if false;
}
// AdMob 서버측 시청 검증(SSV) 기록 — fn-ad-ssv 만 쓴다. 지급 근거라 클라이언트 쓰기 없음.
match /adSsv/{transactionId} {
allow read: if isViewer();
allow write: if false;
}
// 공지·배너. 화면: S-X-NOTICE · S-AD-NOTICE
match /notices/{noticeId} {
allow read: if true;
allow write: if isOps();
}
// 푸시 발송 이력·도달률. 운영자만 보고, 발송 집계는 Functions(FCM)가 쓴다.
// 화면: S-AD-PUSH · rbac-summary 「푸시 발송 관리」 = 운영 이상
match /pushCampaigns/{pushId} {
allow read: if isViewer();
allow write: if false; // 발송·집계는 fn-push-send 경유
}
// ───────────────────────── 8. 사전예약 · 초대 코드 ─────────────────────────
// 사전예약 신청. 미로그인 웹앱에서 접수되고 중복·봇 판정이 붙어 서버 전용으로 둔다.
// 화면: S-W-PREREG · S-W-PREREG-DONE · S-AD-PREREG
// 근거: 정책설계서 P-06 reCAPTCHA v3 · E-PRE-02 중복 차단 · E-PRE-03 보류
match /preRegistrations/{preRegId} {
allow read: if isOps(); // 개인정보(연락처) 포함 — 운영 이상만
allow write: if false; // 접수는 fn-prereg-submit 경유
}
// 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다.
// 문서에는 `{active}` 만 둔다 — 코드 주인(UID)은 서버 전용 `inviteCodeOwners/{code}`
// (기본 거부 구역)에 있어 코드로 사용자를 알아낼 수 없다.
// 화면: S-W-PREREG(코드 입력) · E-PRE-01
match /inviteCodes/{code} {
allow get: if true; // 코드 직접 조회만 허용
allow list: if isOps(); // 전수 열거는 운영 이상(코드 수집 방지)
allow write: if false;
}
// 신규 행성 개설 신청. 화면: S-W-PREREG-NEW · S-AD-PREREG
match /planetApplications/{applicationId} {
allow read: if isOps();
allow write: if false; // 접수·심사 모두 Functions
}
// ───────────────────────── 9. 참여형 번역 ─────────────────────────
// 번역 제출. 보상이 걸려 있어 제출은 Functions 경유(도배 제한·중복 검사).
// 화면: S-M-TRANSLATE · S-AD-TRANSLATE
match /translationEntries/{entryId} {
allow read: if signedIn();
allow write: if false;
}
// 번역 일일 카운터(`{uid}_{dayKey}`). 도배 판정 입력이라 서버 전용.
match /translationDaily/{docId} {
allow read, write: if false;
}
// 마킹. 문서 ID = uid 로 중복 마킹을 차단한다. 쓰기는 fn-translation-mark 만 —
// 본인 번역(E-TRN-01) · 팬덤 멤버 · 가입 24시간(E-TRN-04) 판정이 규칙으로는 안 된다.
// 읽기는 번역 작성자 · 마킹한 본인 · 운영자만 — 수는 번역 문서(validCount · invalidCount)에 있다.
match /translationEntries/{entryId}/markings/{uid} {
allow read: if signedIn()
&& (request.auth.uid == uid || isOps()
|| get(/databases/$(database)/documents/translationEntries/$(entryId)).data.authorUid == request.auth.uid);
allow create: if false;
allow update, delete: if false; // 번복 불가 — 어뷰징 가드
}
// ───────────────────────── 10. 랭킹 사전 집계 ─────────────────────────
// 글로벌·국가·아티스트 랭킹 사전 집계 문서. 랭킹 화면은 이 문서 1건만 읽는다.
// 화면: S-G-FPI · S-I-RANK · S-M-HERO
match /rankings/{rankingId} {
allow read: if signedIn();
allow write: if false;
}
// ───────────────────────── 11. 관리자 ─────────────────────────
// 관리자 계정 목록. 실제 권한 판정은 커스텀 클레임이고 이 문서는 목록·감사용이다.
// 화면: S-AD-ACCOUNT
match /admins/{uid} {
allow read: if isViewer();
allow write: if false; // 클레임 설정과 함께 fn-admin-set-role 경유
}
// 관리자 작업 로그. 전건 기록이 판정 기준이라 수정·삭제를 규칙 층에서 봉인한다.
// 화면: S-AD-LOG · 근거: 별첨1 ⑨ 판정 기준 「관리자 조작 전건이 작업 로그로 추적 가능」
match /adminActionLogs/{logId} {
allow read: if isViewer();
allow write: if false;
}
// C-123 「테스트 지급분」 표시 행(Firestore 설계서 v1.4 §3.4) — 원장을 고치지 않고 덧붙인다. 서버 스크립트만 쓴다.
match /popTransactionMarks/{txId} {
allow read: if isViewer();
allow write: if false;
}
// ─────────────── 12. 컬렉션 그룹 쿼리 ───────────────
//
// Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은
// **단일 컬렉션 쿼리에만** 적용되고 collectionGroup('popTransactions') 에는 적용되지 않는다.
// 재귀 와일드카드 블록이 없으면 §1.4가 지정한 컬렉션 그룹 쿼리가 전부 아래 기본 거부에 걸린다.
// 실측으로 S-AD-LEDGER·S-C-MINE 등 7건이 403이었다.
//
// 원칙: **권한을 넓히지 않는다.** 각 블록의 조건은 위 단일 컬렉션 블록과 동일하게 유지한다.
// 쓰기는 선언하지 않으므로 그대로 거부된다(원장 append-only 유지).
//
// 주의: 규칙은 OR로 평가되므로 이 블록들이 기존 단일 컬렉션 읽기를 좁히지 않는다.
// S-AD-LEDGER 전체 원장 (IDX-08·09). 조회 등급 이상만.
// 본인 내역은 /users/{uid}/popTransactions 단일 컬렉션 경로를 쓰므로 여기서 열지 않는다.
match /{path=**}/popTransactions/{txId} {
allow read: if isViewer();
}
// S-AD-ABUSE 어뷰징 모니터링 (IDX-13). 운영자 전용.
match /{path=**}/activityLogs/{logId} {
allow read: if isViewer();
}
// S-AD-ABUSE 일일 한도 감사 (IDX-14). 운영자 전용.
match /{path=**}/questProgress/{questId} {
allow read: if isViewer();
}
// S-AD-CONTENT 댓글 전수 조회 (IDX-05). 운영자 전용.
match /{path=**}/comments/{commentId} {
allow read: if isViewer();
}
// S-C-MINE·S-C-REFUND 내 후원 내역 (IDX-20).
// 단일 컬렉션 규칙과 동일하게 로그인 사용자 읽기(후원자 리더보드가 이미 공개 범위).
match /{path=**}/donations/{donationId} {
allow read: if signedIn();
}
// S-M-HOME 내 답변 (IDX-26). 단일 컬렉션 규칙과 동일.
match /{path=**}/answers/{answerId} {
allow read: if signedIn();
}
// 환불 배치 donations(IDX-21)는 스케줄 Function 전용이다.
// Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다.
// ───────────────────────── 13. 기본 거부 ─────────────────────────
// 위에서 명시하지 않은 모든 경로는 거부한다.
match /{document=**} {
allow read, write: if false;
}
}
}