Cloud Functions 서버 기반과 앱 서버 연동 계층 — 계정 생성 트리거 · 약관 동의 기록 · 운영자 권한 · Admin 쓰기 관문 · Firestore·Functions 클라이언트 · 오류 코드 전달
This commit is contained in:
Generated
+4021
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user