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
@@ -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;
}