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
+21 -4
View File
@@ -4,8 +4,25 @@
"indexes": "firestore.indexes.json"
},
"emulators": {
"firestore": { "port": 8080 },
"ui": { "enabled": false },
"singleProjectMode": true
}
"firestore": {
"port": 8080
},
"ui": {
"enabled": false
},
"singleProjectMode": true,
"auth": {
"port": 9099
},
"functions": {
"port": 5001
}
},
"functions": [
{
"source": "functions",
"codebase": "default",
"runtime": "nodejs22"
}
]
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "fanuzu-functions",
"version": "1.0.0",
"private": true,
"description": "FANUZU Cloud Functions — 2주차 Firestore 설계서 §8 인터페이스 목록의 구현",
"type": "module",
"main": "lib/index.js",
"engines": {
"node": "22"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\""
},
"dependencies": {
"firebase-admin": "13.6.0",
"firebase-functions": "6.5.0"
},
"devDependencies": {
"@types/node": "22.20.0",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,102 @@
import * as functionsV1 from 'firebase-functions/v1';
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
import { callerUid, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
/**
* `fn-auth-oncreate` — 계정 문서의 유일한 생성 주체.
*
* 보안 규칙이 `users` 의 클라이언트 `create` 를 닫아 두었으므로(교차검증 F-06)
* 이 트리거가 만들지 않으면 계정 문서가 존재하지 않는다. 생성 주체를 하나로
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
* 사용자 문서가 남는다.
*
* v2 에는 사용자 생성 배경 트리거가 없어 v1 Auth 트리거를 쓴다.
*/
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
const db = getFirestore();
const batch = db.batch();
batch.set(db.doc(`users/${user.uid}`), {
uid: user.uid,
displayName: user.displayName ?? '팬우주 여행자',
photoUrl: user.photoURL ?? null,
countryCode: 'KR',
locale: 'ko',
fandomId: '',
authProviders: user.providerData.map((p) => p.providerId),
totalContributedPop: 0,
representativeBadgeId: null,
status: 'active',
notificationPrefs: {},
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
activityBalance: 0,
supportBalance: 0,
pendingActivity: 0,
reconcileStatus: 'ok',
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
});
/**
* `fn-consent-record` — 약관 동의 이력(교차검증 F-04).
*
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
*/
export const fnConsentRecord = onCall(async (req) => {
const uid = callerUid(req);
const docType = str(req.data, 'docType');
const docVersion = str(req.data, 'docVersion');
const granted = (req.data as Record<string, unknown>)?.granted === true;
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}`;
await getFirestore().doc(`users/${uid}/consents/${consentId}`).set({
docType,
docVersion,
granted,
ip: req.rawRequest?.ip ?? null,
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
createdAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { consentId };
});
/**
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
*
* 클레임은 다음 토큰 갱신부터 적용되므로 `admins` 문서도 함께 갱신해 Admin 화면이
* 즉시 목록을 보여줄 수 있게 한다.
*/
export const fnAdminSetRole = onCall(async (req) => {
const actorUid = callerUid(req);
if (req.auth?.token?.role !== 'master') {
fail(ERR.permissionDenied, '마스터 권한이 필요합니다.');
}
const targetUid = str(req.data, 'uid');
const role = str(req.data, 'role');
if (!['master', 'ops', 'viewer'].includes(role)) {
fail(ERR.permissionDenied, '알 수 없는 권한 등급입니다.');
}
await getAuth().setCustomUserClaims(targetUid, { role });
await getFirestore().doc(`admins/${targetUid}`).set({
uid: targetUid,
role,
grantedBy: actorUid,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { uid: targetUid, role };
});
+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 };
});
@@ -0,0 +1,49 @@
import { HttpsError } from 'firebase-functions/v2/https';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { ERR, CatalogError } from './errors.js';
/** 호출자 UID. 익명 호출은 여기서 끊는다 — 설계서 §8 「전부 인증 필요」. */
export function callerUid(req: CallableRequest): string {
const uid = req.auth?.uid;
if (!uid) throw new CatalogError(ERR.authFailed, '로그인이 필요합니다.');
return uid;
}
export type AdminRole = 'master' | 'ops' | 'viewer';
/**
* 운영자 판정 — 커스텀 클레임만 본다.
*
* Firestore 문서 조회로 판정하지 않는 이유는 보안 규칙과 같다: 판정 1회당 읽기
* 비용이 붙고, 우회 표면이 늘어난다.
*/
export function requireRole(req: CallableRequest, allowed: AdminRole[]): AdminRole {
const role = req.auth?.token?.role as AdminRole | undefined;
if (!role || !allowed.includes(role)) {
throw new CatalogError(ERR.permissionDenied, '권한이 없습니다.');
}
return role;
}
/** 문자열 필드를 안전하게 꺼낸다. */
export function str(data: unknown, key: string): string {
const value = (data as Record<string, unknown> | null)?.[key];
if (typeof value !== 'string' || value.length === 0) {
throw new HttpsError('invalid-argument', `${key} 이(가) 필요합니다.`);
}
return value;
}
export function optionalStr(data: unknown, key: string): string | undefined {
const value = (data as Record<string, unknown> | null)?.[key];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function num(data: unknown, key: string): number {
const value = (data as Record<string, unknown> | null)?.[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new HttpsError('invalid-argument', `${key} 이(가) 필요합니다.`);
}
return value;
}
+55
View File
@@ -0,0 +1,55 @@
import { HttpsError } from 'firebase-functions/v2/https';
/**
* 오류 코드 — 2주차 Firestore 설계서 §8 「실패 시 에러 카탈로그의 ID 를 그대로 응답 코드로 쓴다」.
*
* 클라이언트는 이 문자열로 분기하므로 값을 바꾸면 앱이 깨진다. 새 코드가 필요하면
* 카탈로그에 먼저 등재한 뒤 여기에 더한다.
*/
export const ERR = {
permissionDenied: 'E-PERM-01',
authFailed: 'E-AUTH-03',
} as const;
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
/** gRPC 상태로 옮긴다. 앱은 상태가 아니라 `details.code` 로 분기한다. */
type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists'
| 'permission-denied' | 'not-found' | 'resource-exhausted' | 'internal';
const STATUS: Record<ErrorCode, Status> = {
'E-PERM-01': 'permission-denied',
'E-AUTH-03': 'permission-denied',
};
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
export class CatalogError extends HttpsError {
constructor(readonly catalogCode: ErrorCode, message: string) {
super(STATUS[catalogCode], message, { code: catalogCode });
}
}
export const fail = (code: ErrorCode, message: string): never => {
throw new CatalogError(code, message);
};
/**
* 입력이 없으면 그 자리에서 끊는다.
*
* 카탈로그에 없는 실패라 `invalid-argument` 로 둔다 — 사용자가 고칠 수 있는
* 상황이 아니라 클라이언트 구현 오류다.
*/
export function required<T>(value: T | null | undefined, name: string): T {
if (value === null || value === undefined || value === '') {
throw new HttpsError('invalid-argument', `${name} 이(가) 필요합니다.`);
}
return value;
}
/** POP 수량은 양의 정수만 받는다. 소수·음수·비정수는 원장을 오염시킨다. */
export function positiveInt(value: unknown, name: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new HttpsError('invalid-argument', `${name} 은(는) 양의 정수여야 합니다.`);
}
return value;
}
+14
View File
@@ -0,0 +1,14 @@
import { initializeApp } from 'firebase-admin/app';
import { setGlobalOptions } from 'firebase-functions/v2';
// Admin SDK 는 보안 규칙을 우회한다. 그래서 재화를 건드리는 경로가 전부
// 이 패키지 안에만 있어야 하고, 규칙 층은 클라이언트 쓰기를 전면 차단한다.
initializeApp();
// 리전을 서울로 고정한다. 기본값(us-central1)이면 왕복 지연이 그대로 체감된다.
setGlobalOptions({ region: 'asia-northeast3', maxInstances: 20 });
export {
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
} from './account/account.js';
export { fnAdminWrite } from './admin/admin.js';
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { callerUid, requireRole } from '../src/core/callable.js';
import { fnAdminSetRole, fnConsentRecord } from '../src/account/account.js';
import { fnAdminWrite } from '../src/admin/admin.js';
/** 에뮬레이터 없이 도는 인증 · 권한 관문 검증. 데이터에 닿기 전에 끊겨야 한다. */
const req = (auth?: { uid: string; token: Record<string, unknown> }) =>
({ data: {}, auth } as unknown as CallableRequest);
describe('인증 관문', () => {
it('익명 호출은 E-AUTH-03 으로 끊는다', () => {
expect(() => callerUid(req())).toThrow(expect.objectContaining({ details: { code: 'E-AUTH-03' } }));
});
it('로그인한 호출은 UID 를 돌려준다', () => {
expect(callerUid(req({ uid: 'u1', token: {} }))).toBe('u1');
});
});
describe('운영자 관문', () => {
it('허용 목록에 없는 역할은 E-PERM-01 로 끊는다', () => {
expect(() => requireRole(req({ uid: 'v', token: { role: 'viewer' } }), ['master', 'ops']))
.toThrow(expect.objectContaining({ details: { code: 'E-PERM-01' } }));
});
it('허용된 역할은 그대로 돌려준다', () => {
expect(requireRole(req({ uid: 'o', token: { role: 'ops' } }), ['master', 'ops'])).toBe('ops');
});
it('Admin 쓰기 · 권한 부여는 일반 사용자와 조회 운영자를 데이터 접근 전에 거부한다', async () => {
for (const fn of [fnAdminWrite, fnAdminSetRole]) {
for (const auth of [{ uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fn.run(req(auth) as Parameters<typeof fn.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
}
});
it('약관 동의 기록은 익명 호출을 거부한다', async () => {
await expect(fnConsentRecord.run(req() as Parameters<typeof fnConsentRecord.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2023",
"lib": ["ES2023"],
"outDir": "lib",
"rootDir": ".",
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["lib"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
/**
* NodeNext 규약대로 소스가 `./ledger.js` 로 임포트하지만 실제 파일은 `.ts` 다.
* Vite 는 이 치환을 하지 않으므로 여기서 직접 이어 준다.
*/
const tsExtension = {
name: 'resolve-js-to-ts',
resolveId(source: string, importer?: string) {
if (!importer || !source.startsWith('.') || !source.endsWith('.js')) return null;
const candidate = resolve(dirname(importer), source.replace(/\.js$/, '.ts'));
return existsSync(candidate) ? candidate : null;
},
};
export default defineConfig({
plugins: [tsExtension],
test: {
include: ['test/**/*.test.ts'],
// 에뮬레이터 왕복이 있어 기본 5초로는 모자란다.
testTimeout: 30_000,
hookTimeout: 30_000,
// 원장 테스트는 같은 사용자 문서를 건드리므로 순차로 돌린다.
fileParallelism: false,
},
});
+8659
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "fanuzu-firebase",
"version": "1.0.0",
"private": true,
"description": "FANUZU Firebase 산출물 — 보안 규칙 · 인덱스 · Cloud Functions. 에뮬레이터 도구를 여기 한 벌만 둔다.",
"scripts": {
"test": "npm run test:rules && npm run test:functions",
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
"test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\""
},
"devDependencies": {
"firebase-tools": "15.28.2"
}
}
+8056
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"firebase": "12.17.1"
},
"devDependencies": {
"firebase-tools": "^15.28.2",
"vitest": "4.1.10"
}
}