Cloud Functions 서버 기반과 앱 서버 연동 계층 — 계정 생성 트리거 · 약관 동의 기록 · 운영자 권한 · Admin 쓰기 관문 · Firestore·Functions 클라이언트 · 오류 코드 전달

This commit is contained in:
Sn-Kinos
2026-09-01 00:20:45 +09:00
parent 2037b6d573
commit 1d462ebe31
21 changed files with 21578 additions and 12 deletions
+60
View File
@@ -0,0 +1,60 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { callerUid, requireRole, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
/**
* `fn-admin-write` — 모든 Admin 쓰기의 단일 관문(§7.5).
*
* Admin 콘솔이 Firestore 를 직접 쓰지 않고 여기를 지나는 이유는 로그 때문이다.
* 보안 규칙은 거부만 할 수 있고 로그를 남기지 못한다. 누가 무엇을 왜 바꿨는지는
* 여기서만 남길 수 있고, rbac-summary 가 요구하는 작업 로그가 그것이다.
*/
/** 원장·지갑은 이 경로로 못 쓴다. 재화는 반드시 원장 함수를 지나야 한다. */
const FORBIDDEN = [
/popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/,
];
export const fnAdminWrite = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const targetPath = str(req.data, 'targetPath');
const reason = str(req.data, 'reason');
const patch = (req.data as Record<string, unknown>)?.patch;
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
fail(ERR.permissionDenied, '변경 내용이 올바르지 않습니다.');
}
if (FORBIDDEN.some((re) => re.test(targetPath))) {
// 관리자라도 잔액을 직접 고칠 수 없다. 별첨1 ② 「원장 정합성」의 마지막 방벽이다.
fail(ERR.permissionDenied, '재화 관련 문서는 원장 함수로만 변경할 수 있습니다.');
}
// 경로 깊이가 짝수여야 문서다. 홀수면 컬렉션이라 통째로 덮어쓸 위험이 있다.
const segments = targetPath.split('/').filter(Boolean);
if (segments.length === 0 || segments.length % 2 !== 0) {
fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
}
const db = getFirestore();
const ref = db.doc(targetPath);
const before = await ref.get();
await ref.set({
...(patch as Record<string, unknown>),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
const log = await db.collection('adminActionLogs').add({
actorUid,
role,
targetPath,
reason,
patch,
existedBefore: before.exists,
createdAt: FieldValue.serverTimestamp(),
});
return { logId: log.id, targetPath };
});