Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 84 additions & 16 deletions apps/web/src/_pages/privacy/privacy-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function PrivacyPage() {
<div className="container max-w-3xl">
<h1 className="text-3xl font-bold">개인정보처리방침</h1>
<p className="mt-4 text-sm text-muted-foreground">
최종 수정일: 2026년 2월 1일
최종 수정일: 2026년 2월 16일
</p>

<div className="mt-10 space-y-8 text-sm leading-relaxed text-muted-foreground">
Expand Down Expand Up @@ -71,18 +71,55 @@ export function PrivacyPage() {

<section>
<h2 className="text-lg font-semibold text-foreground">
5. 개인정보의 처리 위탁
5. 개인정보의 처리 위탁 및 국외 이전
</h2>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
AI 분석: Anthropic (Claude API) / Google (Gemini API)
- 계약서 텍스트 분석 처리
</li>
<li>결제 처리: 토스페이먼츠 - 결제 및 환불 처리</li>
<li>
데이터 저장: Supabase - 데이터베이스 및 파일 저장
</li>
</ul>
<p className="mt-2">
서비스 제공을 위해 아래와 같이 개인정보 처리를 위탁하고
있으며, 일부 수탁사는 국외에 소재합니다.
</p>
<div className="mt-3 overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b">
<th className="py-2 pr-3 text-left font-medium text-foreground">수탁사</th>
<th className="py-2 pr-3 text-left font-medium text-foreground">소재국</th>
<th className="py-2 pr-3 text-left font-medium text-foreground">이전 항목</th>
<th className="py-2 pr-3 text-left font-medium text-foreground">목적</th>
<th className="py-2 text-left font-medium text-foreground">보호 조치</th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="py-2 pr-3">Anthropic (Claude API)</td>
<td className="py-2 pr-3">미국</td>
<td className="py-2 pr-3">계약서 텍스트</td>
<td className="py-2 pr-3">AI 분석</td>
<td className="py-2">API 전송 시 TLS 암호화, 분석 후 미보관</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-3">Google (Gemini API)</td>
<td className="py-2 pr-3">미국</td>
<td className="py-2 pr-3">계약서 텍스트</td>
<td className="py-2 pr-3">AI 분석 (대체)</td>
<td className="py-2">API 전송 시 TLS 암호화, 분석 후 미보관</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-3">Supabase (AWS)</td>
<td className="py-2 pr-3">대한민국 (ap-northeast-2)</td>
<td className="py-2 pr-3">모든 서비스 데이터</td>
<td className="py-2 pr-3">데이터베이스, 파일 저장, 인증</td>
<td className="py-2">AES-256 암호화, RLS 접근 통제</td>
</tr>
<tr>
<td className="py-2 pr-3">토스페이먼츠</td>
<td className="py-2 pr-3">대한민국</td>
<td className="py-2 pr-3">결제 정보 (금액, 주문번호)</td>
<td className="py-2 pr-3">결제 및 환불 처리</td>
<td className="py-2">PCI DSS 인증, HMAC 서명 검증</td>
</tr>
</tbody>
</table>
</div>
</section>

<section>
Expand All @@ -109,13 +146,44 @@ export function PrivacyPage() {

<section>
<h2 className="text-lg font-semibold text-foreground">
8. 문의처
8. 개인정보보호책임자
</h2>
<p className="mt-2">
개인정보 관련 문의사항이 있으시면 아래 연락처로 문의해
주시기 바랍니다.
개인정보보호법 제31조에 따라 개인정보보호책임자를 다음과
같이 지정합니다.
</p>
<p className="mt-2">이메일: support@contract-guardian.kr</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>직책: 대표자 (개인정보보호책임자 겸임)</li>
<li>이메일: support@contract-guardian.kr</li>
</ul>
<p className="mt-2">
개인정보 열람, 수정, 삭제 요청 및 기타 문의사항은 위
이메일로 연락해 주시기 바랍니다.
</p>
</section>

<section>
<h2 className="text-lg font-semibold text-foreground">
9. 개인정보의 파기 절차 및 방법
</h2>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
업로드된 계약서 및 분석 결과: 분석 완료 후 90일 경과
시 자동 삭제 (데이터베이스 기록 및 저장소 파일 일괄
삭제)
</li>
<li>
회원 탈퇴: 탈퇴 즉시 계정 정보, 분석 기록, 저장
파일을 복구 불가능한 방법으로 파기
</li>
<li>
결제 기록: 전자상거래법에 따라 5년 보관 후 파기
</li>
<li>
접근 로그: 개인정보보호법 시행령에 따라 최소 6개월
보관 후 파기
</li>
</ul>
</section>
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/entities/analysis/api/get-analysis-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireAuth, isAuthError } from "@/shared/lib/auth";
import { createAdminClient } from "@/shared/api/supabase/admin";
import { checkRateLimit } from "@/shared/lib/rate-limit";
import { notFound, rateLimited, internalError, apiError } from "@/shared/lib/api-errors";
import { logAudit } from "@/shared/lib/audit-log";

const CONTENT_TYPES: Record<string, string> = {
pdf: "application/pdf",
Expand Down Expand Up @@ -47,6 +48,13 @@ export async function handleGetAnalysisFile(
return apiError("FILE_ERROR", "파일을 불러올 수 없습니다.", 500);
}

await logAudit({
userId: user.id,
action: "file.download",
resourceType: "analysis",
resourceId: id,
});

const contentType = CONTENT_TYPES[analysis.file_type] || "application/octet-stream";

return new NextResponse(fileData, {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/features/auth/api/delete-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireAuth, isAuthError } from "@/shared/lib/auth";
import { checkRateLimit } from "@/shared/lib/rate-limit";
import { rateLimited, internalError, apiError } from "@/shared/lib/api-errors";
import { createAdminClient } from "@/shared/api/supabase/admin";
import { logAudit } from "@/shared/lib/audit-log";

export async function handleDeleteAccount() {
// Auth check
Expand Down Expand Up @@ -53,6 +54,13 @@ export async function handleDeleteAccount() {
return apiError("DELETE_FAILED", "계정 삭제에 실패했습니다.", 500);
}

await logAudit({
userId: user.id,
action: "account.delete",
resourceType: "account",
resourceId: user.id,
});

return NextResponse.json({ success: true });
} catch (error) {
console.error("Account deletion error:", error);
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/features/payment/api/confirm-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { PaymentConfirmSchema } from "@cg/shared";
import { notFound, dbError, apiError, rateLimited } from "@/shared/lib/api-errors";
import { checkRateLimit } from "@/shared/lib/rate-limit";
import { sendPaymentConfirmEmail } from "@/shared/lib/email";
import { logAudit } from "@/shared/lib/audit-log";
import { sanitizeTossResponse } from "../lib/sanitize-toss-response";

export async function handleConfirmPayment(request: NextRequest) {
try {
Expand Down Expand Up @@ -63,7 +65,7 @@ export async function handleConfirmPayment(request: NextRequest) {
payment_key: paymentKey,
status: "done",
method: tossResult.method,
toss_response: tossResult,
toss_response: sanitizeTossResponse({ ...tossResult }),
approved_at: tossResult.approvedAt,
})
.eq("id", payment.id)
Expand Down Expand Up @@ -94,6 +96,13 @@ export async function handleConfirmPayment(request: NextRequest) {
});
}

await logAudit({
userId: user.id,
action: "payment.confirm",
resourceType: "payment",
resourceId: orderId,
});

return NextResponse.json({
success: true,
orderId,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/features/payment/api/webhook-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/shared/api/supabase/admin";
import { env } from "@/shared/lib/env";
import { mapTossStatus } from "../lib/map-toss-status";
import { sanitizeTossResponse } from "../lib/sanitize-toss-response";

function verifyWebhookSignature(
rawBody: string,
Expand Down Expand Up @@ -52,7 +53,7 @@ export async function handleWebhook(request: NextRequest) {
.from("payments")
.update({
status: mapTossStatus(status),
toss_response: data,
toss_response: sanitizeTossResponse(data as Record<string, unknown>),
})
.eq("order_id", orderId)
.in("status", ["ready", "in_progress"]);
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/features/payment/lib/sanitize-toss-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Filter Toss Payments API response to retain only non-PII fields.
* Raw responses may contain card numbers, buyer name, phone, etc.
*/

const ALLOWED_FIELDS = [
"paymentKey",
"orderId",
"orderName",
"status",
"method",
"type",
"approvedAt",
"requestedAt",
"totalAmount",
"suppliedAmount",
"vat",
"currency",
"receiptUrl",
] as const;

export function sanitizeTossResponse(
response: Record<string, unknown>
): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};

for (const key of ALLOWED_FIELDS) {
if (key in response) {
sanitized[key] = response[key];
}
}

return sanitized;
}
8 changes: 8 additions & 0 deletions apps/web/src/features/report/api/report-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireAuth, isAuthError } from "@/shared/lib/auth";
import { generateReportPdf } from "../lib/generate-pdf";
import { checkRateLimit } from "@/shared/lib/rate-limit";
import { notFound, rateLimited, internalError, apiError } from "@/shared/lib/api-errors";
import { logAudit } from "@/shared/lib/audit-log";

export async function handleReportGeneration(
request: NextRequest,
Expand Down Expand Up @@ -37,6 +38,13 @@ export async function handleReportGeneration(
return apiError("NOT_READY", "분석이 완료되지 않았습니다.", 400);
}

await logAudit({
userId: user.id,
action: "report.download",
resourceType: "report",
resourceId: id,
});

// Generate PDF
const pdfBuffer = await generateReportPdf(analysis);

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/features/upload/api/upload-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MAX_FILE_SIZE, SUPPORTED_FORMATS } from "@cg/shared";
import { randomUUID } from "crypto";
import { checkRateLimit } from "@/shared/lib/rate-limit";
import { rateLimited, internalError, dbError, apiError } from "@/shared/lib/api-errors";
import { logAudit } from "@/shared/lib/audit-log";

export async function handleUpload(request: NextRequest) {
try {
Expand All @@ -21,6 +22,17 @@ export async function handleUpload(request: NextRequest) {
return rateLimited();
}

// Verify privacy policy consent exists before processing PII
const { count } = await supabase
.from("consent_logs")
.select("id", { count: "exact", head: true })
.eq("user_id", user.id)
.eq("consent_type", "privacy_policy");

if (!count || count === 0) {
return apiError("CONSENT_REQUIRED", "개인정보처리방침 동의가 필요합니다.", 403);
}

const formData = await request.formData();
const file = formData.get("file") as File | null;

Expand Down Expand Up @@ -94,6 +106,14 @@ export async function handleUpload(request: NextRequest) {
return dbError("분석 기록 생성에 실패했습니다.");
}

await logAudit({
userId: user.id,
action: "file.upload",
resourceType: "analysis",
resourceId: analysisId,
metadata: { fileType: file.type, fileSize: file.size },
});

return NextResponse.json({
analysisId,
filename: file.name,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/shared/lib/api-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export type ApiErrorCode =
| "INVALID_STATUS"
| "DELETE_FAILED"
| "FILE_ERROR"
| "NOT_READY";
| "NOT_READY"
| "CONSENT_REQUIRED";

export function apiError(code: ApiErrorCode, message: string, status: number) {
return NextResponse.json({ code, message }, { status });
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/shared/lib/audit-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { headers } from "next/headers";
import { createAdminClient } from "@/shared/api/supabase/admin";

type AuditAction =
| "file.upload"
| "file.download"
| "analysis.create"
| "analysis.delete"
| "report.download"
| "payment.confirm"
| "account.delete";
Comment on lines +5 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The AuditAction type is missing the 'analysis.auto_delete' action, which is used in the cleanup_expired_analyses SQL function. To maintain consistency between the database and the TypeScript types, please add it to the union type. This will be helpful for any future code that might process audit logs.

Suggested change
| "file.upload"
| "file.download"
| "analysis.create"
| "analysis.delete"
| "report.download"
| "payment.confirm"
| "account.delete";
| "file.upload"
| "file.download"
| "analysis.create"
| "analysis.delete"
| "analysis.auto_delete"
| "report.download"
| "payment.confirm"
| "account.delete";


type ResourceType = "analysis" | "payment" | "report" | "account";

interface AuditLogParams {
userId: string;
action: AuditAction;
resourceType: ResourceType;
resourceId?: string;
metadata?: Record<string, unknown>;
}

export async function logAudit({
userId,
action,
resourceType,
resourceId,
metadata,
}: AuditLogParams): Promise<void> {
try {
const headerStore = await headers();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The headers() function from next/headers is synchronous and should not be awaited. Using await here will cause a TypeError at runtime because it's not a promise. Please remove the await keyword.

Suggested change
const headerStore = await headers();
const headerStore = headers();

const ipAddress =
headerStore.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = headerStore.get("user-agent") ?? null;

const admin = createAdminClient();
await admin.from("audit_logs").insert({
user_id: userId,
action,
resource_type: resourceType,
resource_id: resourceId ?? null,
ip_address: ipAddress,
user_agent: userAgent,
metadata: metadata ?? {},
});
} catch (error) {
// Audit logging should never break the main request
console.error("Audit log failed:", error);
}
}
Loading
Loading