diff --git a/.claude/agents/mobile-dev.md b/.claude/agents/mobile-dev.md index 01f92ea..d0796c1 100644 --- a/.claude/agents/mobile-dev.md +++ b/.claude/agents/mobile-dev.md @@ -23,15 +23,16 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill ## 작업 순서 -1. `lib/features/user/` 의 model → repository → provider 수직 슬라이스를 복제한다. -2. 네트워크는 `core/api/dio_client.dart` 의 인터셉터 패턴을 재사용한다. 새 클라이언트를 만들지 않는다. -3. 에러 분기는 `e.error as ApiException` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다. -4. 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` 을 쓴다. +1. 새 기능은 그 모듈 폴더(`lib/features/<모듈>/`)와 그 모듈의 라우트 묶음(`<모듈>_routes.dart`)에 둔다. 새 모듈은 스킬의 "새 모듈 붙이는 법"대로. 모듈끼리 import 하지 않는다. +2. `lib/features/auth/` 의 model → repository → provider 수직 슬라이스를 복제한다(목록 페이지네이션은 `features/user` 예제). +3. 네트워크는 `dioProvider` 의 Dio 를 쓴다(인터셉터 차례 — 스킬 "API 통신"). 새 클라이언트 · 인터셉터 줄을 만들지 않는다. +4. 에러 분기는 `apiErrorCodeOf(e)` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다. +5. 화면은 `docs/design/app/README.md` 와 캔버스 확정본대로 만든다 — 그 전에는 자리 화면(`PlaceholderPage`). 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` · 테마 상수를 쓴다. ## 완료 기준 (실행하고 결과를 보고한다) ```bash -cd flutter && flutter analyze && flutter test +cd flutter && flutter analyze && flutter test && flutter build apk --debug ``` - `flutter/.env` 가 없으면 analyze 가 asset 경고를 낸다 — `.env.example` 복사 여부를 먼저 확인한다. diff --git a/.claude/rules/coding-common.md b/.claude/rules/coding-common.md index 15648e5..2e248cd 100644 --- a/.claude/rules/coding-common.md +++ b/.claude/rules/coding-common.md @@ -14,7 +14,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙 Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용) - 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/` - 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리 - (반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`) + (반복 사용되는 키 문자열도 상수로: 예 `kRefreshTokenKey`, `REQUEST_ID_HEADER`) - 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양 — backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`) - **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]]) diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 7ef7c51..6f0f15e 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -51,7 +51,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ 규칙: - **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다. -- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`) +- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`, + 앱 새 메일 알림의 `FCM_SERVICE_ACCOUNT_KEY` — 앱 1단계, **선택**: Firebase 서비스 계정 JSON 키를 통째로 base64 한 줄. 비면 알림만 끄고 기동한다(운영 필수 아님 — 기동 warn · + `GET /api/app-config` 의 `pushAvailable: false`). 값이 있으면 모든 환경에서 키 모양을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다, `env.validation.ts` 의 `validateFcmSettings`). + 개발 · 운영 키를 따로 만든다. 매일 백업이 `backend/.env.production` 을 함께 올린다) - **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 — 숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다. (기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다) diff --git a/.claude/rules/error-codes.md b/.claude/rules/error-codes.md index 9f5ecb7..f12f1fd 100644 --- a/.claude/rules/error-codes.md +++ b/.claude/rules/error-codes.md @@ -50,9 +50,9 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다 서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다. - **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다. - 웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절). + 웹 · 앱은 그 전에 갱신을 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절). - 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다 -- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제) +- 앱: `core/api/auth_interceptor.dart` · `token_refresher.dart` — 웹처럼 갱신(`/auth/app/refresh`)을 한 번 해 보고, 갱신까지 거절됐을 때만 승격한다 (리프레시 토큰 삭제 · 로그인 화면 — `docs/mail/api-contract.md` 1절 "앱 로그인"). 그 요청을 보낸 세션이 이미 끝났으면(세션 세대가 다르다) 알리지 않는다 — 사용자가 스스로 로그아웃했으면 승격하지 않고 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝난 뒤면 `AUTH_002` ## 도메인 코드 추가 diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index b4baea0..b02ce59 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -31,6 +31,7 @@ 아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.** - 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더 +- 앱 푸시(앱 1단계 — FCM)의 휴대폰 주소(FID — 지문도 남기지 않는다: 이어 붙여 기기를 좇지 못하게) · 서비스 계정 키 · 개인 키 · 서비스 계정 JWT · Google access token · FCM · Google 의 오류 설명 글(상태 · 오류 코드만 남긴다) - 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보 - 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다) - DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`) diff --git a/.claude/rules/security.md b/.claude/rules/security.md index 6329a93..b14ac9b 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -11,8 +11,8 @@ ## 인증 · 토큰 -- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`) -- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달 +- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 리프레시 토큰만 `flutter_secure_storage`(키: `refresh_token`), 액세스 토큰은 메모리 — 앱 전용 토큰 경로(계약 `docs/mail/api-contract.md` 1절 "앱 로그인"), Android 백업 끔 +- Access Token 만료는 짧게(15분~1시간). Refresh Token 은 웹은 HttpOnly 쿠키로만, 앱은 1절 "앱 로그인"대로(본문으로 받아 보안 저장소에만 — 로그 · 일반 저장소에 두지 않는다) - 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다. 동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게) - 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다. @@ -102,6 +102,16 @@ 같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔, nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰). +### 앱 푸시 (휴대폰 새 메일 알림 — 앱 1단계 · FCM) + +- **글은 "새 메일 N통"뿐이다** — 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 Google 로 보내지 않는다. 메시지는 한 곳(`modules/push/fcm/fcm-message.ts`)에서 종류 · 개수만 받아 만든다(받는 칸이 없다), + 새 메일을 알아채는 쪽도 엔진 연결에서 신호만 받는다. Google 은 기기마다 언제 몇 통이 왔는지는 안다(알려진 한계). +- **휴대폰 주소(FID)는 한 세션에만** — 같은 FID 를 다른 세션이 등록하면 앞 줄을 지운다(같은 휴대폰에서 다른 직원이 로그인해도 앞 직원의 알림이 가지 않게). 직원마다 10개까지. +- **보낼 때 살아 있는 · 제한 없는 앱 세션만 고른다** — 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 운영 도구)은 DB 의 `revoked_at` 으로 바로 빠진다. 등록 경로는 앱 토큰만(웹 쿠키 세션 403 `AUTH_003`). +- **서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나** — Firebase 의 기본 Admin SDK 계정(Firestore · Auth 까지 쓴다)을 쓰지 않는다. 키는 개발 · 운영 따로, + `backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` 에만 둔다(로그 · 오류 · 응답에 싣지 않는다). 새면 그 키를 Google Cloud 콘솔에서 지우고 새 키로 — 그 키로는 우리 앱 사용자에게 꾸민 알림을 보낼 수 있다(메일은 읽지 못한다). +- 바깥 주소는 상수 두 곳(`oauth2.googleapis.com` · `fcm.googleapis.com`)뿐이다 — 키 파일의 `token_uri` 를 따라가지 않고(다르면 기동하지 않는다) 다른 곳으로 보내는 응답(3xx)도 따라가지 않는다. + ## 네트워크 · 인프라 - 보안 헤더(`helmet`) 적용 diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index dc787e4..6b76d52 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -187,6 +187,9 @@ sudo docker compose --env-file .env.production logs -f backend 그 막기는 사설 대역에 기댄다 — `APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET` 은 사설 대역으로 둔다(mail setup 은 앱 네트워크만 검사한다). 서버 자신의 공인 주소로 풀리는 이름은 막지 않는다(공인 주소라서) — 그 요청은 호스트의 80 · 443 에 닿으므로, 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다 (메일 엔진의 443 은 setup 이 403 으로 막고, 중계는 응답이 그림일 때만 돌려준다 — 2026-10-29 리뷰). + **앱 새 메일 알림(앱 1단계 — FCM)** 도 같은 길로 나간다: backend → `oauth2.googleapis.com:443`(서비스 계정 JWT → access token) · `fcm.googleapis.com:443`(보내기). + 막히면 휴대폰 알림만 오지 않는다(로그 warn · error — 다른 기능은 영향 없음). **서버 시계가 맞아야 한다**(NTP — `timedatectl` 의 "System clock synchronized: yes") — JWT 의 `iat` 가 틀리면 Google 이 토큰 교환을 거절한다. + 키(`FCM_SERVICE_ACCOUNT_KEY` — 서비스 계정 JSON 을 통째로 base64 한 줄)는 `backend/.env.{APP_ENV}` 에 둔다(compose 는 바꿀 것이 없다 — `env_file`). 비면 알림만 끄고 뜬다 — 넣었으면 기동 로그 "앱 새 메일 알림: 켬 (Firebase 프로젝트 …)"를 본다. - **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고 사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다). - **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"): @@ -498,7 +501,7 @@ node --env-file=.env.development mail/scripts/setup.mjs | --- | --- | --- | | Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" | | nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) | - | Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 | + | Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다. 앱 알림의 N 은 `Email/query` 의 `after` 가 그 초를 넣지 않는 것에 맞춰 한 초 앞을 넘긴다(`jmap-mail.provider.ts` 의 `inboxUnreadSinceFilter` — 2026-11-08 확인) — 올리면 이것도 다시 본다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 | | Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` | | unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 | | ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) | @@ -508,6 +511,7 @@ node --env-file=.env.development mail/scripts/setup.mjs | imapsync (전환 때 서버 B 호스트에서 — 2026-11-02) | `gilleslamiral/imapsync:2.326@sha256:ece980cb…` | `mail/README.md` 「imapsync」 절의 명령. 저자 공식 이미지 · amd64 만 · 비-root(`nobody`). 인증서를 기본으로 확인하지 않는다(`SSL_verify_mode=0`) — 운영 명령은 `--sslargs1/2 SSL_verify_mode=1` 꼴(README) | | Redis (선택 — 기본 꺼짐) | `redis:7-alpine`(아직 떠 있는 태그 — 기존 드리프트) | 켤 때 판 · digest 로 고정하고 이 표에 줄을 둔다 | | CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 | + | Flutter SDK (CI · 개발 PC — 이미지가 아니다) | `3.41.9` stable (Dart 3.11.5 · 2026-04-30) | `.github/workflows/ci-flutter.yml` 의 `flutter-version`, `flutter/pubspec.yaml` 의 `environment.sdk`(Dart 하한) · `intl`(새 SDK 의 flutter_localizations 가 고정한 판), `flutter/README.md`, [[flutter]] "Flutter 판", 이 문서. 개발 PC 의 Flutter(`C:\src\flutter`)는 다른 프로젝트도 함께 쓴다 — 같은 마이너의 패치 3.41.9 는 2026-11-06 사용자 확인 뒤 올렸고, 마이너(3.47 등)를 올리는 것은 사용자가 정한다(올리면 개발 PC 와 CI 를 함께) | - **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다. 새 태그가 있는지와 digest 는 `docker buildx imagetools inspect <이미지>:<태그>` 로 확인한다 (2026-10-27: Stalwart `v0.16`·`v0.16.27`, nginx `stable-alpine`·`1.30.6-alpine3.24` 가 각각 같은 digest). @@ -591,7 +595,7 @@ node --env-file=.env.development mail/scripts/setup.mjs backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음). - **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다. - 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]]) - - `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** + - `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** (백엔드 고유 시크릿 — `JWT_SECRET`, 앱 알림을 쓰면 `FCM_SERVICE_ACCOUNT_KEY` — 매일 백업에 함께 든다) 복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]). - **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고, `stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다. diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 80f79d0..a1b557e 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -3,7 +3,7 @@ name: flutter description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다. --- -> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. +> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. 앱 로그인의 근거는 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 (앱 전용 토큰 경로)", 화면 기준은 `docs/design/app/README.md`, 개발 실행 · 배포 준비 메모는 `flutter/README.md` 다. > 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용. > 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]] > Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조). @@ -12,25 +12,44 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S ## 템플릿 기본 제공 골격 -> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). +> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). +> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 스토어에 올린 뒤에는 못 바꾼다), 패키지 `arasystem_app`. | 제공 항목 | 위치 | 비고 | | --- | --- | --- | -| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 | -| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 | -| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 | +| 앱 뿌리 | `lib/main.dart` · `lib/app.dart` | main: dotenv → 글꼴 라이선스 → 토큰 보관 준비(첫 실행 지우기) → 기기 정보(판 · User-Agent) → `ProviderScope(overrides)`. app: `MaterialApp.router` · 한국어만(`locale: ko` · `GlobalMaterialLocalizations.delegates`) · 전역 `scaffoldMessengerKey` | +| **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) | +| 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` | +| 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) | +| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 | +| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 | +| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | +| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | +| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | | ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) | -| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 | -| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 | -| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 | -| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 | -| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 | +| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 | +| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 | +| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) | +| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 | +| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | +| 푸시 기기 등록 자리 | `lib/core/push/` — `push_registration.dart` · `push_target.dart` | 모듈 공통. 지금은 `NoopPushRegistrar`(서버 `/api/push-devices` 가 생기면 채운다 — 세션 노티파이어가 로그인 뒤 `register` · 로그아웃 전 `unregister` 를 부른다). 알림 데이터에는 **모듈 이름과 id 만**(`{module, id}`) — `locationForPush` 가 모듈의 `locationOfItem` 으로 경로를 고른다 | +| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | +| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 | +| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) | > **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다): -> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))` -> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/` -> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리 -> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가 +> - **새 모듈 붙이는 법**(웹 [[vue3]] "새 사내 모듈"에 해당): `core/modules/app_modules.dart` 의 항목을 켜고(`isEnabled`) → `features/<모듈>/<모듈>_routes.dart` 에 `ModuleRoutes`(첫 라우트 경로 = `/<모듈>`)를 만들고 → `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다. 탭 · 주소 · 알림 · 설정 절은 저절로 붙는다. 웹 목록도 함께 본다(짝) +> - **모듈끼리 서로 import 하지 않는다.** 함께 쓰는 것(사람 고르기 · 파일 첨부 · 날짜 표기 등)은 `core/` · `shared/` 에 둔다. 세션(`features/auth`)은 모듈이 아니라 모두가 쓴다. 다른 모듈의 설정 화면에 절을 더할 때는 `ModuleRoutes.settingsSections` 로 내놓기만 한다 +> - **주소는 모듈 이름부터**(`/mail/...` · `/settings/...` · 나중 `/calendar/...`). 깊은 링크 · 푸시도 모듈 이름으로 고른다 +> - **상태관리 · 복제할 패턴**: `features/auth` 의 `data/models`(손으로 쓴 모델) → `data/repositories`(`dioProvider` 의 Dio) → `presentation/providers`(타입을 적은 Riverpod 프로바이더 · 노티파이어)를 복제한다. 목록은 `features/user` 의 커서 페이지 예제. import 는 `package:arasystem_app/...` +> - **화면 글**: 디자인 README 「화면 문구 — 사용자에게 말하듯」. 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`), 로그인 단계는 `core/constants/screen_titles.dart` +> - **모델 생성기**: `freezed` · `json_serializable` · `riverpod_generator` 는 아직 넣지 않았다 — build_runner 가 같은 폴더의 `error_lexicon.g.dart`(자동 생성 사전)를 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고 한 번 돌려 사전이 남는지 확인한다 +> - **권장 패키지(선택)**: `cached_network_image`·`fpdart`·`webview_flutter`(받은 메일) 도입 시 `pubspec.yaml` 추가. 새 패키지는 배포 2주가 지난 판만 ([[security]] "의존성") + +### Flutter 판 + +**개발 PC 와 CI 를 같은 판으로 고정한다 — 지금 `3.41.9` stable (Dart 3.11.5).** 함께 고치는 곳: `.github/workflows/ci-flutter.yml` 의 `flutter-version` · `flutter/pubspec.yaml` 의 `environment.sdk` 와 `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 지금 `0.20.2`, 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로) · `flutter/README.md` · [[docker]] 이미지 버전 표의 "Flutter SDK" 줄. CI 는 `flutter pub get --enforce-lockfile` 로 lock 그대로 받는다. +개발 PC 의 Flutter 는 다른 프로젝트도 함께 써서 그 판에 맞췄다 — 3.47 로 올리는 것은 사용자가 정한다(올리면 개발 PC · CI 를 함께, 2주 규칙). --- @@ -47,26 +66,33 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S ``` lib/ ├── main.dart -├── app.dart ← MaterialApp / 라우터 설정 -├── core/ -│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등) -│ ├── theme/ ← 앱 테마 정의 -│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터 -│ ├── storage/ ← flutter_secure_storage 래퍼 (선택) -│ └── utils/ ← 순수 유틸 함수 +├── app.dart ← MaterialApp.router · 한국어 · 테마 · 전역 알림 자리 +├── core/ ← 모듈 공통 (모듈은 core · shared 만 함께 쓴다) +│ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent +│ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판 +│ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes) +│ ├── push/ ← 푸시 기기 등록 자리 · 알림 → 경로 +│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry) +│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시 +│ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴 +│ └── utils/ ← 순수 유틸 함수 (시계 등) ├── features/ -│ └── user/ -│ ├── data/ -│ │ ├── models/ ← JSON 직렬화 모델 -│ │ └── repositories/ ← API 호출 구현체 -│ ├── domain/ -│ │ └── entities/ ← 순수 비즈니스 엔티티 -│ └── presentation/ -│ ├── pages/ ← 화면 단위 위젯 -│ ├── widgets/ ← 재사용 위젯 -│ └── providers/ ← 상태 관리 +│ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다) +│ ├── mail/ ← 모듈 — 주소 /mail/... +│ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다) +│ │ ├── data/ +│ │ │ ├── models/ ← JSON 모델 (손으로 — 위 "모델 생성기") +│ │ │ └── repositories/ ← API 호출 구현체 +│ │ ├── domain/ +│ │ │ └── entities/ ← 순수 비즈니스 엔티티 +│ │ └── presentation/ +│ │ ├── pages/ ← 화면 단위 위젯 +│ │ ├── widgets/ ← 재사용 위젯 +│ │ └── providers/ ← 상태 관리 +│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다) +│ └── user/ ← 예제(참조용) └── shared/ - └── widgets/ ← 앱 공통 위젯 (생성 완료) + └── widgets/ ← 앱 공통 위젯 (자리 화면 등) ``` --- @@ -133,103 +159,109 @@ Expanded(flex: 1, child: RightPanel()), ## 상태 관리 -```dart -// Riverpod 예시 (권장) -@riverpod -Future fetchUser(FetchUserRef ref, int id) async { - final repo = ref.watch(userRepositoryProvider); - return repo.getUser(id); -} +생성기(`riverpod_generator` · `@riverpod`)는 쓰지 않는다 — 프로바이더는 타입을 적어 손으로 만든다(위 "모델 생성기"). + +```dart +// 프로바이더 — 타입을 적는다 (features/user 예제 · features/auth 와 같은 꼴) +final Provider userRepositoryProvider = Provider( + (Ref ref) => UserRepository(ref.watch(dioProvider)), +); + +final FutureProviderFamily userProvider = + FutureProvider.family( + (Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id), + ); + +// 위젯에서 사용 — 실패는 [다시 시도](ref.invalidate)로 다시 한다(저절로 다시 하지 않는다) +class UserTile extends ConsumerWidget { + const UserTile({required this.userId, super.key}); + + final int userId; -// 위젯에서 사용 -class UserWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final userAsync = ref.watch(fetchUserProvider(userId)); - return userAsync.when( - data: (user) => Text(user.name), + final AsyncValue user = ref.watch(userProvider(userId)); + return user.when( + data: (UserModel value) => Text(value.name), loading: () => const CircularProgressIndicator(), - error: (e, _) => const ErrorWidget(), + error: (Object error, StackTrace _) => const SizedBox.shrink(), ); } } ``` -- **Riverpod** 권장 (Provider는 레거시로 간주) +- **Riverpod 3** — 세션처럼 바꾸는 일이 있는 상태는 `AsyncNotifier`/`Notifier`(`sessionProvider` 가 본보기) +- **저절로 다시 하기는 끈다**: Riverpod 3 은 프로바이더의 `retry` 를 주지 않으면 실패한 프로바이더를 기본으로 10번까지 다시 만든다(요청을 다시 보낸다). + 앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 `noAutomaticRetry`(`core/utils/provider_retry.dart`)를 건다 — 다시 하기는 화면의 [다시 시도]가 한다 - `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용 +- 실패를 상태(`AsyncValue`)에 두어도 된다 — 비밀이 든 요청(`ApiOptions.secret` — 로그인 · 코드 · 비밀번호 단계 · logout)의 실패 예외는 본문을 비워 던지고, 인증 인터셉터가 내보내는 모든 실패 예외는 Bearer 를 지운 채 온다 --- + ## API 통신 (Dio + 인터셉터) -실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다. +앱 로그인은 앱 전용 토큰 경로다(계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — `POST /api/auth/app/login` · `/app/refresh` · `/app/logout`, 보호 API 는 `Authorization: Bearer`). +세션 규칙(1회용 회전 · 30초 유예 · 끊은 세션 바로 막기 · 제한된 세션)은 웹과 같고 토큰을 나르는 길만 다르다. + +``` +화면 · 저장소 ── ref.watch(dioProvider) + │ + ▼ 인터셉터 줄 (core/api/dio_client.dart) +ResponseInterceptor {success,data}→data · 실패는 DioException.error = ApiException +AuthInterceptor QueuedInterceptor — Bearer · 미리 갱신 · 401 → TokenRefresher(한 번에 하나) → 한 번 다시 · AUTH_010 → 다시 맞추기(renewed 면 한 번 다시) +ErrorNotifyInterceptor 맨 끝 — silent 가 아니면 ErrorNotifier(스낵바 · 같은 코드 3초) + │ 갱신 · 다시 보내기는 언래핑만 있는 안쪽 Dio 로 (같은 줄을 다시 타지 않게) + ▼ +SessionEventBus ──▶ 세션 노티파이어 (세션 끝 → 로그인 화면 · AUTH_010 → 세션 확인(안쪽 Dio) · 갱신 → RestrictionCheck 로 답 · 갱신 응답의 제한) +``` ```dart -// core/api/dio_client.dart (실제 구조 요약) -class DioClient { - DioClient._internal() { - // flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값 - final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api'; - _dio = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - )); - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) async { - // jwt_token 읽어 Authorization 헤더 첨부 - final token = await _storage.read(key: kJwtTokenKey); - if (token != null) options.headers['Authorization'] = 'Bearer $token'; - handler.next(options); - }, - onResponse: (response, handler) { - // success==true → data 언래핑, false → ApiException 으로 reject - handler.next(response); - }, - onError: (err, handler) async { - // 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조) - // 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다. - handler.next(err.copyWith(error: await _toApiException(err))); - }, - )); - } +// 저장소 — 파싱만 한다. 문구를 만들지 않는다 +final Response> response = await _dio.get>( + '/mail-state', + options: ApiOptions.silent(), // 요청마다 알림 끄기 — Options(extra: {kSilentExtraKey: true}) 와 같다 +); + +// 화면 — code 로 가른다 +try { + await ref.read(sessionProvider.notifier).signIn(email: email, password: password); +} on DioException catch (e) { + if (apiErrorCodeOf(e) == 'AUTH_004') showTopError(); } ``` -> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다. +- 인터셉터를 더할 때는 위 차례를 지킨다 — 알림은 늘 맨 끝(갱신 · 다시 보내기가 끝난 결과만 알린다). +- 세션 노티파이어를 인터셉터가 직접 부르지 않는다(노티파이어가 Dio 를 쓰므로 서로 물린다) — `SessionEventBus` 로 알린다. +- 갱신을 새로 부르는 곳(제한을 푼 직후 등)도 `TokenRefresher`(`AuthRepository.refresh`)를 지난다 — 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이다. --- ## 보안 (토큰 저장) ```dart -// flutter_secure_storage 사용 -const storage = FlutterSecureStorage(); - -// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token') -await storage.write(key: kJwtTokenKey, value: token); - -// 읽기 -final token = await storage.read(key: kJwtTokenKey); - -// 삭제 (로그아웃) -await storage.deleteAll(); +// 토큰은 TokenStore 로만 다룬다 (core/storage/token_store.dart) +await tokenStore.save(tokens); // 리프레시 토큰 → 보안 저장소(kRefreshTokenKey), 액세스 토큰 → 메모리 +final String? refresh = await tokenStore.readRefreshToken(); +await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 없이 — 늦은 401 알림을 가른다) ``` -- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용 -- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]]) -- `SharedPreferences`에 토큰 저장 **금지** +- 리프레시 토큰 · 민감 정보는 `flutter_secure_storage`(`SecureStore`)에만, 액세스 토큰은 메모리에만 둔다 +- 키 문자열을 여기저기 적지 말고 `kRefreshTokenKey` 상수를 쓴다 ([[coding-common]]) +- `SharedPreferences` 에 토큰 저장 **금지** — 첫 실행 표시(참 · 거짓 하나)만 둔다 +- 로그인 때 넣은 비밀번호는 Riverpod 상태에 두지 않는다(`LoginSecret` — 메모리 · 10분) +- 비밀번호 · 토큰은 로그 · 예외 문장에 남기지 않는다 — `LogInterceptor` 를 넣지 않고, `AuthTokens.toString()` 은 값을 가린다 - 추가 보안 규칙은 [[security]] 참조. --- ## 성능 & 최신 트렌드 추가 규칙 -- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델) +- **모델 직렬화**: 지금은 손으로 쓴 불변 모델(`final` 칸 + `fromJson`/`tryParse` — `features/auth/data/models`). `json_serializable` · `freezed` 는 위 "모델 생성기" 주의를 지킨 뒤에 넣는다 - **이미지**: `cached_network_image`로 네트워크 이미지 캐싱 - **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지) - **에러 핸들링**: `Either` 패턴 고려 (`fpdart` 패키지) -- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원) +- **라우팅**: `go_router` (도입 완료 — `core/router/`). 새 화면은 그 모듈의 라우트 묶음에 더한다 - **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리 --- @@ -241,15 +273,17 @@ await storage.deleteAll(); - [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용. - [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지. - [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`. -- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다. -- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다. +- [ ] **API**: `dioProvider` 의 Dio 를 쓴다(새 클라이언트 · 인터셉터 차례를 만들지 않는다). 리포지토리에서 try/catch 로 메시지를 만들지 않는다. 화면이 칸에 보이는 실패는 `silent` 로 끈다. +- [ ] **에러 분기**: `apiErrorCodeOf(e)`(또는 `e.error as ApiException`)의 **`code` 로 분기**한다. 문구는 사전에서만 온다. `AUTH_010` 은 화면이 다루지 않는다(세션이 그 단계로 옮긴다). - [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`. - [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]). -- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지. +- [ ] **토큰**: `TokenStore` 로만(리프레시 = `flutter_secure_storage` 의 `kRefreshTokenKey`, 액세스 = 메모리). `SharedPreferences` 저장 금지. - [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공). -- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료). -- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`. -- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. +- [ ] **모듈**: 새 기능은 그 모듈 폴더(`features/<모듈>/`)와 라우트 묶음 안에. 모듈끼리 import 하지 않는다 — 함께 쓰는 것은 `core/` · `shared/`. 모듈 목록을 바꾸면 웹 `app-modules.ts` 도(짝). +- [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상. +- [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`). +- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다). + Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). 다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다. - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`). diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 32857d2..9e1e3cb 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -24,14 +24,14 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` | | 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · **요청 대상 원문 검사**(`common/middleware/request-target.middleware.ts` + `shared/utils/request-target.ts` — request-id 바로 뒤, 날 `\` · `#` · 제어 문자 · 공백 · 0x7F · 0x80 이상이면 라우팅 전에 400 `VAL_001`. Express 의 parseurl 은 `#` · 0xA0 가 있으면 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꾼다 — 그래서 앞단 nginx 가 정규식으로 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰). Node 24 의 HTTP 파서는 제어 문자 · 날 UTF-8 을 앱 전에 거절한다 — JSON 이 아닌 Node 기본 400) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성). **라우팅은 대소문자를 가린다** — 앱은 `createHttpAdapter()`(Express 인스턴스에 `case sensitive routing` 을 먼저 켠 ExpressAdapter — main.ts · e2e 공용)로 만든다. Nest 의 ExpressAdapter 가 생성자에서 `app.use` 를 불러 Express 5 의 라우터를 그때 만들므로 `configureApp()` 에서 켜면 늦다 — `configureApp()` 은 라우터가 대소문자를 가리지 않으면 기동을 멈춘다(2026-10-30 — `/FILES/` 같은 변형이 앞단 nginx 의 받기 상한을 비켜 갔다) | | **JSON 본문 모양 한도** | `src/shared/utils/json-shape.ts` + `app.setup.ts` 의 `JSON_SHAPE_LIMITS` | 배열 하나 1,000 · 값(객체 · 배열 · 원시) 10,000 · 깊이 32. 읽기 전(UTF-8 바이트를 한 번 훑는다 — `verifyJsonBody`, 재귀 없이 넘는 순간 멈춘다)과 읽은 뒤(`rejectOversizedJsonBody`) 두 번 보고, 넘으면 DTO 변환 · 검사 전에 400 `VAL_001`(본문 파서는 가드보다 앞 — 로그인 전 요청도 400). DTO 의 개수 한도로는 막지 못해 둔 것이다(2026-10-28 리뷰 — 1MB 에 작은 객체 8만 개로 7.8초 · +329MB, 깊은 중첩은 500). 새 계약의 본문이 한도를 넘으면 여기부터 올린다 ([[security]]) | -| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 | +| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 둘 — 웹은 쿠키(`addCookieAuth('access_token')`), 앱은 Bearer(`addBearerAuth` — 앱 로그인이 준 토큰만). 보호 라우트는 `@ApiCookieAuth()` 만 적는다 — `main.ts` 가 그 작업마다 Bearer 를 "또는"으로 붙인다(`shared/utils/openapi-security.ts` 의 `addAlternativeSecurity` — 컨트롤러마다 `@ApiBearerAuth()` 를 달지 않는다) | | 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` | | **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` | -| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` | +| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 검증, 실패는 `AUTH_001`. 토큰은 웹은 쿠키, 앱은 `Authorization: Bearer` — Bearer 가 있으면 그것만 본다(`modules/auth/access-token.ts`). 앱 토큰(표시 `cli: 'app'`)은 Bearer 로만 · 표시 없는 웹 토큰은 쿠키로만 받는다 — 바꿔 쓰면 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts`(`passReqToCallback` — 길과 표시를 견준다, 끊은 세션 · 제한은 두 길이 같다) | | 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` | | 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 | | 공백 · 중복 데코레이터 | `src/common/decorators/trim-string.decorator.ts` · `array-distinct.decorator.ts` | `TrimString()` — 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다(메일함 이름 · 필터의 이름 · 찾을 글 · 전달 주소). `ArrayDistinct(options)` — 배열 항목이 겹치지 않는지 Set 으로 한 번 훑는다(선형 — 원시 값은 값으로, 객체는 같은 객체일 때만. 객체 배열의 id 중복은 서비스가 Set 으로 본다 — 예: 필터 규칙 `findFilterRulesProblem`). class-validator 의 `ArrayUnique` 는 `indexOf` 비교라 항목 수의 제곱이다 — **쓰지 않는다** | -| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 | +| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · **앱 전용 토큰 경로** `app-auth.controller.ts`(앱 1단계 — `/api/auth/app/login`·`refresh`·`logout`, 계약 1절 "앱 로그인": 웹과 같은 `AuthService` 함수를 지나 실패 카운터 · 코드 가드 · 앱 비밀번호 거절 · 막는 계정이 같고, 로그인 요청 수 상한은 웹 로그인과 같은 칸(`auth.constants.ts` 의 `LOGIN_THROTTLE` — 키를 같은 이름으로). 토큰은 응답 본문 `tokens`(끝나는 때는 토큰의 exp · `private, no-store`), 앱 토큰 둘에 `cli: 'app'`, 세션 표 `client`(web · app — 마이그레이션 `AddAppSessionClient`, DB 기본값 web — 칸을 모르는 코드가 넣어도 웹), 갱신은 같은 클라이언트의 리프레시 토큰으로만(다르면 `AUTH_001`, 세션은 끊지 않는다) · **앱 세션의 30초 유예는 그 세션을 끝낸다**(사유 `app-refresh-grace` — 앱은 갱신을 한 번에 하나만 하므로 응답을 잃었거나 토큰이 두 번 쓰였다, 응답은 웹과 같은 200 `tokens: null`). 본문 경계: `refreshToken` 이 없거나 글이 아니면 400 `VAL_001`, 빈 글 · 2,048자(`MAX_REFRESH_TOKEN_LENGTH`) 넘음은 서명을 보기 전에 틀린 토큰(갱신 401 `AUTH_001`). 로그아웃은 본문의 리프레시 토큰 → 없거나 · 빈 글 · 길거나 · 틀리면 Bearer 의 sid — 본문 모양(글이 아님 · 모르는 칸)이 틀릴 때만 400, 그 밖은 200. 앱 표시가 있는데 sid 가 UUID 가 아닌 액세스 토큰은 가드가 `AUTH_001`. 앱 요청의 "지금 세션"은 리프레시 쿠키가 없어 액세스 토큰의 sid — `AuthService.requestSessionId`, 비밀번호 바꾸기 · 로그인된 기기가 쓴다) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 | | 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` · q 맞추기 `recipient-match.ts`(8단계 — **frontend `shared/utils/recipient-match.ts` 와 짝**, 같은 표본 표를 두 쪽 테스트에 둔다 — 한쪽을 고치면 둘 다. 이름은 코드 포인트로 한 번 훑는다 — 길이에 비례), 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun` — `jmap/bulk-run.ts`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 이미 받은 메일에 필터 적용 `POST /api/mail-filter-runs`(7단계 — `mail-filter.controller.ts`, 본문 `dto/mail-filter-run.dto.ts`. 저장한 필터가 아니라 본문의 규칙으로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음). 판정 `mail-filter-match.ts` 는 **`jmap/sieve-script.ts` 의 `FIELD_TESTS` 와 짝**이다 — 엔진 검색은 낱말 단위라 쓰지 않고 Sieve 의 판정을 그대로 옮겼다(한쪽을 바꾸면 다른 쪽도). 견줄 글 `jmap/filter-run-texts.ts`(원래 머리글 `header:From:all` 등 — `:asRaw` 는 붙이지 않는다(엔진이 응답의 이름에서 뗀다), 풀기 `jmap/mime-text.ts` 의 `decodeHeaderText` — 접힌 줄은 띄어쓰기 하나, 인코딩된 글은 홀로 선 것만), HTML 글자 `html/html-text.ts`(엔진의 `body :text` 와 같게 — 닫는 `p` · `br` 만 줄을 끊고 `style` · `script` · `head` · `template` 은 글째 빼며 그 밖의 태그는 빈자리 없이 뺀다. 속성이 붙은 태그는 엔진처럼 알아보지 않는다 — 2026-10-29 인증 발송 네 묶음으로 맞춤, 규칙은 계약 2절), 어댑터 `jmap/jmap-filter-run.ts`(머리글 500통 · 본문 100통 차례, 본문 값은 부분마다 256KiB · 한 통 1MiB — 바깥 메일 한 통이 메모리를 크게 쓰지 않게), 이어 할 자리 `jmap/filter-run-cursor.ts`(2판 — 위 경계 · anchor · 그 받은 시각 · 규칙 지문. 모든 조회에 `before` 를 걸어 시작한 뒤 온 메일은 보지 않고, 빠진 anchor 는 그 초부터 다시 읽는다). 한 요청의 시간은 메일함 처리와 같은 `BulkRun`, 같은 직원은 한 번에 하나(`mail.service.ts` 의 `filterRuns` — 진행 중이면 `REQ_001`)) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) | | 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") | | 사내 목록(로그인 기록) | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록한다 — 8단계부터는 엔진의 사내 목록(아래 주소록)을 **읽지 못할 때만** 받는 사람 추천의 "사내"를 대신한다. 직원 역할에는 엔진의 계정 목록(Principal) 권한이 없다(일부러 막아 두었다) | @@ -41,9 +41,11 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" | | 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 | | 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 | -| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 리프레시 쿠키로 지금 세션을 안다) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`. 로그인 기록 `login-events.*`(표 `login_events` — 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` | +| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 지금 세션은 `AuthService.requestSessionId`: 웹은 리프레시 쿠키 · 앱은 액세스 토큰의 sid) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`(앱 이름 `ARA SYSTEM 앱` 은 세션의 `client` · 로그인 기록의 `method` 가 app 일 때만 `describeAppUserAgent` 로 — 웹 세션이 앱 꼴 UA 를 보내면 브라우저 null. 운영체제는 앱 UA `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 첫 괄호 칸 — 짝: flutter `kUserAgentProduct`). 세션 목록 항목에 `client`(web · app). 로그인 기록 `login-events.*`(표 `login_events` — 방식 webmail · app · imap · smtp, 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` | | 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM `, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it -backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. **메일 이전**(2026-11-02 — 전환의 imapsync): 「입사」의 이전용 비밀번호는 엔진 비밀번호 만료를 30일 뒤로 건다(그때까지 IMAP 정상 — 임시 비밀번호의 지난 만료는 IMAP 이 끊긴다), 이전 중 = 만료가 미래(31일 안)이고 실행 기록(`ops_audit_log`)의 마지막 판정 기록이 이전용(`hire-migration` · `migration-password` — 이전용 명령은 failure 도 센다) — 판정은 `modules/ops/migration-state.ts` 하나(엔진 만료만으로는 기본 만료의 찌꺼기와 가를 수 없다), 「메일 이전」(`modules/ops/migration-commands.ts`)의 이전 마치기가 직원용 임시 비밀번호로 바꾼다. 이전용 비밀번호는 실행 한 번 안에서 같은 값 — `migration-password.ts` 의 `MigrationPasswordSession`(싱글턴 · 메모리에만 · `OpsCli.run()` 의 finally 에서 지움), 약함 거절의 다시 만들기는 `temp-password.ts` 의 `applyFreshPassword`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열한 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 이전 마치기: 앱 비밀번호 → 세션 → 로그인 기록 → 임시 비밀번호 + 만료 → 세션 한 번 더(알림 없음) / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) | | 메일 검색 색인 (14단계 1차 — 색인 바탕 · 2차 — 검색 · 강조 · 3차 — 대화) | `modules/mail-search` · `modules/mail/jmap/jmap-index-source.ts` · `shared/utils/search-text.ts` · `subject-base.ts` | 표 둘(마이그레이션 `AddMailSearchIndex`): `mail_search_messages`(직원 주소 + 엔진 메일 id 마다 한 줄 — 원문(NFC) · 접기 글 `fold_*` · 글자 두 개 묶음 `grams` · 메일함 · 답장 관계(GIN 둘 — 직원 주소(lower 식) · 묶음 · 메일함을 한 색인에 담은 찾기 GIN `IDX_mail_search_messages_user_grams_mailboxes` 와 refs GIN. 마이그레이션이 손으로 만들고 엔티티에는 `synchronize: false` 로 이름만)) · `mail_search_states`(직원마다 한 줄 — building · ready, 엔진 Email state, 채우기 자리, 멈춘 까닭, 실패 수). 포트 `MAIL_INDEX_SOURCE`(`mail-index-source.ts` — 엔진 교체는 `mail-search.module.ts` 의 useClass 만): 어댑터는 textBody 의 글자 부분만 읽는다(첨부 안의 글은 읽지 않는다) — 본문 값은 부분 256KiB · 한 통 1MiB, 한 요청의 메일 전체 크기 합 32MiB · 16MiB 넘는 메일은 혼자. 접기 `foldText`(NFC 뒤 코드 포인트 하나씩 — 전각 → 반각 · 소문자, 원문과 위치가 같다) · 묶음 `gramsOf` · 맞은 곳 `matchRanges` · 말머리 떼기 `subjectBase` — **짝: frontend `shared/utils/search-match.ts`**, 같은 표본 표 `backend/test/search-text-samples.ts`(한쪽을 고치면 둘 다). 상태 줄은 로그인(`AuthService`) · 비밀번호 바꾸기 · `GET /api/search-index` 가 만들고 멈춤을 푼다(로그인한 적 없는 계정은 색인하지 않는다). 맞추기 `mail-search-sync.service.ts`(Email/changes — 실시간 state 소식 300ms 모음 · 5분 cron · 검색 바로 전 1.5초, 직원마다 하나(`MailSearchRuns`) · 서버 전체 4(`ConcurrencyGate`), cannotCalculate → 전체 대조), 채우기 `mail-search-build.service.ts`(밤 창 KST 22:00~02:45 · 05:00~07:30 — 1분 cron, 최신부터 50통씩 anchor, 다 읽으면 새 차례의 처음에 따라잡기 → 전체 대조 → ready). **시간 끝은 실패가 아니다** — `IndexDeadlineError`(이어 하기 — 실패 수 · 경고 없음). 색인 쓰기는 상태 줄을 `FOR UPDATE` 로 잠근 한 트랜잭션 — 퇴사 마무리(`ops-data.service.ts`)는 상태 줄 → 색인 줄 차례로 지운다. 맞추고 채우는 직원은 사내 도메인(`MAIL_DOMAIN`) 주소뿐이다(같은 DB 의 e2e 줄은 건드리지 않는다). cron 은 `timeZone: 'Asia/Seoul'`. 묶음 크기 · 시간 한도는 `mail-search.constants.ts` 한 곳(잰 뒤 바꾼다). 로그는 코드 · 사유 · 가린 주소만(메일 글 · 제목 · 엔진 설명 문장 없음), 조건 검색 쿼리(`from` · `to` · `subject` · `body`)는 `maskUrlForLog` 가 길이만, 개발의 DB 쿼리 로그도 끈다(`database.config.ts`). **2차(검색 · 강조)**: `GET /api/messages` 의 조건 `from` · `to`(참조 포함) · `subject` · `body`(검색어와 같은 검사 — `dto/search-query-fields.ts` 의 `SearchTextField`) · `after` · `before`(UTC) · `hasAttachment=true`. 목록 · 검색은 `modules/mail/message-list.service.ts` — 색인이 준비됐으면(ready · 지금 판 `INDEX_FORMAT` — 판이 다르면 서버가 뜰 때 building 으로 되돌려 밤에 다시 채운다) 색인, 아니면 엔진(조건은 `jmap/search-terms.ts` 의 `searchFieldConditions`), 쪽을 넘기는 중에는 커서의 방식대로(`mail-search/search-cursor.ts` — 색인 커서 2판 `{v:2,m:'i'}`, 엔진 커서는 1판). 첫 쪽의 색인 찾기가 DB 장애면 엔진으로 찾는다. 색인 찾기: 계획 `search-plan.ts`(낱말 · 칸 · 한 글자 낱말 `SINGLE_CHAR_TERMS_SKIP_BODY` · 두 글자 낱말뿐이면 묶음만으로 `exactByGrams`) → SQL `search-sql.ts` → `mail-search-query.service.ts`. **후보는 찾기 GIN 하나에서만 거른다** — `IDX_mail_search_messages_user_grams_mailboxes`(마이그레이션 `SearchIndexUserGramsMailboxes`, 2026-11-06 실사용 시험)가 직원 · 묶음 · 메일함을 한 번에 맞춰 맞은 줄만 읽는다. 주소는 `lower("user_email")` 식(그대로면 btree 로 그 직원 줄을 다 읽고 묶음 배열(TOAST)을 풀었다 — 260~700ms → 10ms), 메일함은 `"mailbox_ids" @> $n` 그대로(`= ANY` 면 그 직원의 묶음 줄을 모두 읽는다). **메일함만 담은 GIN 을 다시 만들지 않는다** — 메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체인데, 계획기가 그것만으로 메일함 줄을 다 읽고 묶음 배열을 줄마다(다른 직원 줄까지) 풀었다(1만 통 메일함 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1초 — 고친 뒤 15ms · 1ms). e2e 가 EXPLAIN(그 색인 하나 · 색인 조건에 메일함)과 GIN 목록을 본다. 확인(strpos — 접은 본문을 풀어 한 줄 0.15~0.3ms)은 후보 id 의 줄만, 후보가 많으면 100줄부터 4배씩 쪽이 찰 때까지 · 한 요청 3,000줄까지(넘으면 이어 찾기 커서 — 쪽이 limit 보다 적어도 hasNext), total 은 첫 쪽에서 1,000까지(넘거나 다 세지 못하면 null). 상수는 `mail-search.constants.ts`. 강조: 목록 `search-highlight.ts`(제목 · 본문에서 맞은 곳 둘레의 미리 보기 — 색인 방식에서만. 미리 보기는 인용 줄(`>`) 밖의 맞은 곳 먼저 → 인용 안에서만 맞고 검색어 낱말이 모두 색인의 다른 칸(접은 칸 넷 — `highlightTextsSql`)에도 있으면 인용 밖 첫 글 → 아니면 `>` 를 뗀 인용 조각. 본문 강조는 두 글자 이상 낱말만 — `search-plan.ts` 의 `bodyHighlightOf`, 읽기 화면 `highlightTermsOf` 도 같은 계획에서 꺼낸다), 읽기 화면 `modules/mail/message-highlight.ts` + **정화한 뒤** HTML 의 글 조각에 `` 를 넣는 `html/html-mark.ts`(태그 · 속성 · 주석 · style 안은 그대로, 길이에 비례 · 1,000개까지). 위치는 NFC 글의 UTF-16 [시작, 끝) — 강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다. **3차(대화)**: 대화 키 — 색인 쓰기(`mail-search-index.repository.ts` 의 `linkThreads`)가 넣은 메일 M 의 refs 가 가리키는 메일 · M 을 가리키는 메일 가운데 말머리를 뗀 제목(`subject_base`)이 같은 것의 키를 가장 작은 키로 합친다(쌍 읽기 한 쿼리 + UPDATE 한 문장 · 합치기 `thread-keys.ts`, Message-ID 가 없으면 혼자, 넣기는 이미 있는 줄의 대화 키를 덮지 않는다). 같은 대화 `GET /api/messages/{id}/conversation` · 묶은 목록 `GET /api/conversations?mailboxId=` — `modules/mail/conversation.controller.ts` · `conversation.service.ts`(색인이 준비됐으면 색인 · 아니면 엔진의 스레드 — 응답의 `basis`, 커서가 방식을 잇는다 `mail-search/conversation-cursor.ts` — `m` 이 색인 방식이면 **입구에서 decode**: 꾸민 커서는 맞추기 · 엔진 · DB 전에 400). 색인 쿼리 `mail-search/conversation-sql.ts` · `mail-conversation-query.service.ts` — **늘 그 직원 주소의 btree 로 먼저 좁힌다**(메일함은 `$n = ANY("mailbox_ids")` 꼴 — 메일함 posting 은 회사 전체라 그 직원의 줄에서 거른다. 메일함 칸을 담은 GIN 은 검색의 찾기 GIN 하나뿐이고 대화 쿼리는 쓰지 않는다), 같은 대화는 그 메일의 키와 쪽을 한 문장(LEFT JOIN LATERAL — 색인에 없음과 빈 쪽을 가른다). 묶은 목록 줄: messageIds 최신부터 `CONVERSATION_MESSAGE_IDS_MAX`(500)까지 · 참여자는 **첫 보낸 사람 칸**(`from_email` · `from_name` — 마이그레이션 `AddMailSearchSender`, 색인 판 4. 보낸 사람 글을 다시 해석하지 않는다 — 이름 안의 `<주소>` 는 주소가 아니다)에서 주소마다 한 번 · 10명까지 DB 가 고른다(대화마다 LATERAL `DISTINCT ON`), `pendingSendIds`(messageIds 가운데 붙잡힌 메일 — 기다리는 제출은 쪽마다 한 번: 색인은 `getSummariesWithHeld`, 엔진은 목록 요청 안). 10명 흉내(10만 줄)에서 큰 메일함 첫 쪽 15~80ms · 자세히 · 참여자 1~2ms. 엔진 쪽은 `jmap/jmap-conversations.ts`(Thread/get · collapseThreads) — **거르기는 값싼 속성 · 요약 속성은 내보낼 메일만**, 상한 · 시간은 `mail-search.constants.ts` 의 `CONVERSATION_ENGINE_*`(같은 대화 한 요청 1,000통 — 그 이하 스레드만 total, 넘으면 커서 자리부터 · total null / 묶은 목록 스레드마다 최신 500 · 한 요청 2,000(`shareScan`) — 상한에 걸린 줄은 읽지 못한 메일을 거르지 않고 통 수에 더하고 나머지는 읽은 최신 쪽으로 / 한 요청 15초 — 훑는 호출은 마지막 요약 읽기 몫 3초를 뺀 제한 시간, 마지막 읽기만 남은 시간을 다, 남은 3초면 더 훑지 않고 시간에 끊긴 훑기는 읽은 데까지로 답한다. 참여자 이름은 색인과 같은 `participantName`(100 코드 포인트)). 엔진의 Thread/get emailIds 는 받은 시각 오래된 차례다(2026-11-06 개발 엔진 200 스레드 확인). 휴지통 · 스팸메일함 역할과 내부 발송 키워드는 `jmap/mail-scope.ts` 한 곳, 보이기 경로의 기다리는 제출 상한 경고는 `pending-send.ts` 의 `warnIfHeldQueryFull`. 설정 `groupByConversation`(`modules/mail-user-settings` — 처음 끔, 서버는 화면이 부르면 묶을 뿐 설정을 보지 않는다) | +| 앱 새 메일 알림 (앱 1단계 — FCM) | `modules/push` · `modules/mail/new-mail-push.service.ts` · `MailEventsService.hold` · `common/guards/app-client.guard.ts` | 계약 1절 "앱 새 메일 알림 · 앱 설정". **등록** `PUT`·`DELETE /api/push-registrations/current` · `DELETE …/current/unseen`(`push-registrations.controller.ts` — `@UseGuards(JwtAuthGuard, AppClientGuard)` + `@ApiBearerAuth()`: 앱 토큰만, 웹 쿠키 세션은 403 `AUTH_003`, 제한된 세션 403 `AUTH_010`). 표 `push_registrations`(마이그레이션 `AddPushRegistrations` — 세션(sid)마다 한 줄 · FID 유일 · 세션 표와 외래 키 없음). PUT 은 한 트랜잭션(`push-registrations.repository.ts` — 세션이 살아 있는 · 제한 없는 앱 세션인지 → 같은 FID 의 다른 줄 지우기(같은 FID 는 `pg_advisory_xact_lock('PSH1', hashtext(fid))` 로 차례) → upsert(새 줄만 `seen_at` = 지금) → 직원마다 10줄). 보낼 대상 · 붙잡을 직원은 늘 세션 표와 함께 고른다(살아 있는 · 제한 없는 앱 세션 — 끊은 세션은 `revoked_at` 으로 바로 빠진다), 끝난 세션의 줄은 5분 정리 `push-cleanup.service.ts`(그 휴대폰을 직원이 건드리지 않고 끝난 세션 — 저절로 끝남 · 다른 곳의 비밀번호 바꾸기 · 앱 갱신 유예 · 토큰 재사용 감지 — 의 휴대폰에만 한 번 session-ended, 끊은 까닭 표는 `push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(`Record` — 까닭을 더하면 거기서 정해야 컴파일된다) — `DELETE … RETURNING`). 등록이 바뀐 소식은 `PushRegistrationsService.onChanged`(메일 쪽이 듣는다 — 푸시 모듈은 메일을 모른다, 사내 도메인만 `MailEngineModule` 의 `MAIL_SETTINGS` 에서). **보내기** 포트 `PUSH_SENDER`(`push-sender.port.ts` — 알림은 종류 · 개수뿐) · 구현 `configured-push-sender.ts`(env `FCM_SERVICE_ACCOUNT_KEY` — 없으면 보내지 않고 기동은 한다) · `fcm/`: 키 읽기 `fcm-credentials.ts`(env 검사와 같은 함수 — 까닭에 값을 싣지 않는다), OAuth `fcm-auth.ts`(RS256 JWT → `oauth2.googleapis.com/token`, 만료 5분 전까지 기억 · 함께 부르면 한 번), 보내기 `fcm-sender.ts`(`message.fid` — 실패 표는 계약, 시도 3번 · 20초 · 서버 전체 4 · 429 는 `Retry-After` 가 있을 때만 그때 한 번 · 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` — 그 세션의 줄이 아직 그 FID 인가(`isDeliverable`) · 로그는 종류마다 한 시간에 한 번 · 서버 종료로 끊긴 것은 로그 없이), 메시지 `fcm-message.ts`(순수 함수 한 곳 — 글은 `push-text.ts`), HTTP `fcm-transport.ts`(전역 fetch · 두 주소 고정 · 응답 64KB — 시험만 `forTesting`). 새 의존성 없음. **새 메일** `NewMailPushService`(메일 모듈): 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡고(`MailEventsService.hold` — SSE 와 같은 연결 · 같은 판정, 신호만 받는다 · 연결 상한에 세지 않는다, 함께 여는 수 4, 실패하면 1 · 5 · 15 · 60분 — 계정 문제는 60분부터), 새 메일 신호 → 같은 직원 30초에 한 번(첫 알림 바로 · 끝에 한 번) → 기기마다 N(`MailProvider.countInboxUnreadSince` — 받은편지함 · 안 읽음 · 초안 아님 · `seen_at` 뒤, 한 요청 8개씩) → N ≥ 1 이면 보낸다 · 받지 않는 기기는 FID 가 그대로일 때만 지운다. 붙잡을 때마다 따라잡기(10분 안 · `seen_at` · `last_pushed_at` 의 **다음 초**부터 — 받은 시각은 초, N 은 `seen_at` 의 초부터 같은 초를 넣는다. 엔진의 `after` 는 그 초를 넣지 않아(0.16.27 — 2026-11-08 확인) 어댑터가 한 초 앞을 넘긴다. 붙잡은 뒤 신호가 먼저 오면 따라잡기는 건너뛰고, 따라잡기 뒤 첫 신호 보내기는 새로 받은 메일이 있는 기기에만 — 붙잡은 직후의 메일 하나에 한 번). 붙잡기가 끝나면(`ended` — 계정 문제 60분 · 그 밖 1분 뒤 다시, 서버 종료가 아닌데 허브가 닫혀도 알린다) 간격 끝 차례도 지운다, 등록이 바뀐 소식은 기다림을 풀고 바로 붙잡아 본다. 같은 직원의 보내기는 `KeyedQueue` 로 차례 | +| 앱 설정 (앱 1단계) | `modules/app-config` | `GET /api/app-config`(로그인 없음 · 기본 요청 수 한도 · `Cache-Control: no-store`) → `{ minSupportedVersion: { android, ios: null }, pushAvailable }`. 최소 판은 코드 상수 `app-config.constants.ts` — 옛 앱을 막을 API 변경과 같은 커밋에서 올린다. `pushAvailable` = `PUSH_SENDER.available` | | Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) | | 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) | | env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) | @@ -54,13 +56,14 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` | | 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) | | 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]). 요청이 끝난 뒤에도 이어지는 일(실시간 알림의 엔진 연결 등)은 `runOutsideRequestContext()` 로 연다 — 처음 요청의 추적 ID 를 물려받지 않게 | -| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) | +| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`, 함께 도는 일의 수 `concurrency-gate.ts` · 열쇠마다 한 번에 하나 `keyed-queue.ts` — 검색 색인 · 앱 알림이 함께 쓴다) | > **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현): > - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에 > `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요). +> 가드는 웹 쿠키 · 앱 Bearer 를 다 받는다 — 문서의 Bearer 는 `main.ts` 가 붙이므로 `@ApiBearerAuth()` 는 달지 않는다. "이 요청의 세션"이 필요하면 `AuthService.requestSessionId(user, 리프레시 쿠키)` 를 쓴다(앱은 쿠키가 없다 — sid). > 13단계부터 그 클래스에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010 — 문서)도 단다. 제한된 세션(새 비밀번호 · 2단계 켜기 전)도 불러야 하는 핸들러만 `@AllowRestrictedSession()` — 계약 6절 목록뿐이고, 달지 않은 보호 라우트는 403 `AUTH_010` 이다. -> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다. +> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다. 앱 토큰만 받는 경로(앱 알림 등록 등)는 `@UseGuards(JwtAuthGuard, AppClientGuard)`(`common/guards/app-client.guard.ts` — 웹 쿠키 세션은 403 `AUTH_003`) + `@ApiBearerAuth()` 만. > 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다 > - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다 > - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체). @@ -194,10 +197,13 @@ const config = new DocumentBuilder() .setTitle('API 문서') .setDescription('프로젝트 API 명세서') .setVersion('1.0') - .addCookieAuth(ACCESS_TOKEN_COOKIE) // 인증은 HttpOnly 쿠키뿐 — 보호 라우트에 @ApiCookieAuth() (Bearer 는 등록하지 않는다) + .addCookieAuth(ACCESS_TOKEN_COOKIE, { type: 'apiKey' }, 'cookie') // 웹 — HttpOnly 쿠키 + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'bearer') // 앱 — 앱 로그인이 준 토큰만 .build(); const document = SwaggerModule.createDocument(app, config); +// 보호 라우트는 @ApiCookieAuth() 만 적는다 — 가드가 앱의 Bearer 도 받으므로 문서에 "또는"으로 한 번에 붙인다 +addAlternativeSecurity(document, 'cookie', 'bearer'); SwaggerModule.setup('api-docs', app, document); ``` @@ -216,14 +222,16 @@ SwaggerModule.setup('api-docs', app, document); ## 인증 (JWT) -> **구현 위치.** 쿠키 기반 JWT 가 구현되어 있다 — `modules/auth`(로그인·`me`·`refresh`·`logout`, 계약 `docs/mail/api-contract.md` 1절), +> **구현 위치.** 쿠키 기반 JWT 가 구현되어 있다 — `modules/auth`(로그인·`me`·`refresh`·`logout`, 계약 `docs/mail/api-contract.md` 1절). +> 모바일 앱은 같은 세션 규칙을 토큰 본문 · Bearer 로 쓴다 — `modules/auth/app-auth.controller.ts`(`/api/auth/app/*`, 계약 1절 "앱 로그인" — 위 "직원 인증" 행), > 가드 `common/guards/jwt-auth.guard.ts`, 전략 `modules/auth/jwt.strategy.ts`, `@CurrentUser()` `common/decorators/current-user.decorator.ts`. > 서명 키·만료(`JWT_*`)는 `modules/auth/auth.settings.ts` 한 곳에서 읽는다. 직원 자격 확인은 메일 엔진 포트(`MAIL_PROVIDER`)로 한다. > 리프레시 세션은 DB(`auth_sessions`)에 둔다 — 토큰은 해시만 저장, 1회용 회전, 이미 쓴 토큰이 오면 세션 폐기, 절대 30일·미사용 14일. -> 액세스 토큰은 세션을 조회하지 않는다 (끊어도 만료까지 유효 — 그래서 `JWT_ACCESS_EXPIRES_IN` 은 env 검증이 5분~1시간으로 막는다). +> 액세스 토큰은 서명으로 검증하고 대개 세션 표를 읽지 않는다. 12단계부터 끊은 세션은 토큰의 `sid` 와 서버가 기억한 끊은 세션(`revoked-sessions.ts` — 1시간)으로 바로 막고(앱의 Bearer 도), +> 13단계부터 제한(`rst`)이 실린 토큰만 요청마다 세션 표의 지금 제한을 읽는다. `JWT_ACCESS_EXPIRES_IN` 은 env 검증이 5분~1시간으로 막는다(끊은 세션 기억이 1시간이고, `sid` 가 없는 12단계 전 토큰은 만료까지 쓰인다). - Access Token: 만료 시간 짧게 (15분 ~ 1시간) -- Refresh Token: HttpOnly Secure 쿠키로 전달 +- Refresh Token: HttpOnly Secure 쿠키로 전달 (웹 — 앱은 응답 본문으로 받아 보안 저장소에 두고 본문으로 보낸다) - `@nestjs/jwt` + `@nestjs/passport` 사용 - Guard를 통한 라우트 보호 - 토큰 저장·전송 보안 규칙은 [[security]] 참조. @@ -256,6 +264,9 @@ SwaggerModule.setup('api-docs', app, document); > 그대로면 인스턴스마다 따로 세고 기억해 같은 코드를 다른 인스턴스에서 다시 쓸 수 있다 → 공유 저장소(`SET NX PX` · 카운터). 끊은 세션 기억(`revoked-sessions.ts` — 1시간, 기동 때 DB 에서 채움) — > 13단계부터 15초마다 DB 에서 채우므로 인스턴스 사이도 15초 안에 맞는다(운영 도구가 끊은 세션도 같은 길). 2단계 필수 정책 · 문의 연락처의 10초 기억(`system-settings`)도 인스턴스마다 따로지만 10초 안에 맞는다. 검사가 겹치면 위험 파일 알림 메일도 겹칠 수 있다(조건부 UPDATE 로 줄였다). 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다. > 검색 색인(14단계)의 직원마다 차례(`mail-search-runs.ts` 의 `MailSearchRuns`) · 맞추기의 소식 모음 · 기다리는 맞추기 · 5분 차례 겹침 막기(`mail-search-sync.service.ts` 의 debounces · queued · syncingAll) · 서버 전체 맞추기 수(`ConcurrencyGate` — 4) · 1분 채우기 겹침 막기(`mail-search-build.service.ts` 의 running) — 그대로면 인스턴스마다 따로 돌아, **두 인스턴스의 1분 차례가 같은 직원을 함께 채우고**(쓰기는 상태 줄 잠금으로 차례가 되지만 같은 묶음을 두 번 읽는다) 같은 직원을 함께 맞추며, 엔진에 가는 맞추기 수가 인스턴스 수만큼 는다 → 직원마다 잠금(`SET NX PX` · `pg_advisory_lock`) · 공유 셈. 표 쓰기 자체는 DB 라 그대로 맞다. + > 앱 새 메일 알림(앱 1단계)의 직원마다 엔진 연결 붙잡기 · 30초 간격 · 보내기 차례(`modules/mail/new-mail-push.service.ts` 의 held · pacing · sends) — 인스턴스마다 붙잡으면 같은 새 메일을 인스턴스 수만큼 보낸다 + > (N 은 엔진에서 세므로 수는 같고 알림만 겹친다) → 한 인스턴스만 붙잡게(`pg_try_advisory_lock` 을 쥔 인스턴스가 모두 맡는다 — 세션 단위 잠금이라 그 연결을 붙잡는다, 그 인스턴스가 죽으면 다른 인스턴스가 잇는다) 또는 직원마다 잠금. + > FCM 보내기의 동시 수(`fcm-sender.ts` 의 4)도 인스턴스마다다. 등록 · `seen_at` · `last_pushed_at` 은 DB 라 그대로 맞다. 세션 끝 알림은 지운 인스턴스만 보낸다(`DELETE … RETURNING`). FCM OAuth 토큰 기억은 인스턴스마다 하나여도 무해하다. > 대용량 첨부(11단계)의 같은 조각 게이트 · 조각 동시 수(`file-storage.service.ts` 의 `ChunkGate` — 그대로면 두 인스턴스가 같은 조각을 함께 쓴다 → 잠금), 검사 차례(`file-scan.service.ts` — 행 잠금이 없어 > 두 인스턴스가 같은 파일을 두 번 검사한다 → `FOR UPDATE SKIP LOCKED`), 정리 cron 둘(인스턴스마다 돈다), 파일 바이트(로컬 볼륨 → 같은 저장 포트 `FILE_BLOB_STORE` 의 공유 저장 어댑터(S3 호환)) — > 만들기 잠금(`pg_advisory_xact_lock`) · 받은 수 셈(조건부 UPDATE)은 DB 라 그대로 맞다: @@ -391,7 +402,7 @@ dc exec backend npm uninstall <패키지> - [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용. - [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]). - [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체. -- [ ] **보호 라우트**: `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` + `@CurrentUser()` — 가드 없이 `@CurrentUser()` 를 쓰면 AUTH_001 로 막힌다 ([[security]]). +- [ ] **보호 라우트**: `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` + `@CurrentUser()` — 가드 없이 `@CurrentUser()` 를 쓰면 AUTH_001 로 막힌다 ([[security]]). 앱의 Bearer 는 가드가 받고 문서는 `main.ts` 가 붙인다 — 지금 세션은 `requestSessionId`(리프레시 쿠키만 보지 않는다). - [ ] **전역 설정 변경 시**: `src/app.setup.ts` 만 고친다 — main.ts 와 e2e 가 같은 함수를 쓴다. - [ ] **메일 엔진 코드**: JMAP 타입·호출은 `modules/mail/jmap/` 밖으로 내보내지 않는다. 받은 메일 HTML 은 `html/sanitize-mail-html.ts` 를 거친 뒤에만 내보낸다. 보내는 서식 본문(`bodyHtml`)은 `html/sanitize-outgoing-html.ts` 를 거친 뒤에만 엔진에 넘긴다 — 편집기가 새 서식을 쓰게 되면 이 허용 목록과 @@ -429,6 +440,8 @@ dc exec backend npm uninstall <패키지> **엔진 Basic 의 402 는 장애가 아니다**(12단계) — 비밀번호는 맞고 그 직원이 2단계 인증(OTP)을 켰다는 뜻이다. 웹메일 로그인은 코드 단계로, 비밀번호 바꾸기는 대리 접근 + `otpAuth.otpCode` 로 간다(지금까지 `MAIL_001` 로 다뤄 OTP 를 켠 직원이 들어오지 못했다). 2단계 · 앱 비밀번호 · 웹훅 설정을 고칠 때는 계약 5절의 엔진 사실(엔진은 등록 때 코드를 보지 않는다 · 같은 코드를 다시 받는다 · 앱 비밀번호 기본 권한은 앱 비밀번호를 또 만든다)을 함께 본다. **발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인). + **앱 푸시(FCM)로 나가는 글에는 메일 내용 · 주소를 싣지 않는다**(앱 1단계) — 메시지는 `modules/push/fcm/fcm-message.ts` 한 곳에서 종류 · 개수만으로 만든다(받는 칸이 없다). + 새 메일을 알리는 쪽은 엔진 연결을 붙잡아 **신호만** 받는다(`MailEventsService.hold` — 보낸 사람 · 제목을 넘기지 않는다). FID · 서비스 계정 키 · access token · FCM 오류 글은 로그에 남기지 않는다. **대용량 첨부의 표시**(11단계 — `data-ara-large-files` · `` · `data-ara-quote-start`)는 서버만 넣는다 — 두 정화기가 사용자 HTML 의 `data-ara-*` · 주석을 지우는지 정화를 고칠 때 함께 본다(지우지 못하면 남이 꾸민 덩이가 첨부처럼 보인다). 보내기에 실패해도 링크는 "확실한 실패"(`SendNotStartedException`)에서만 거둔다 — 결과를 모르면 남긴다(메일이 나갔을 수 있다). - [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드). diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 015aad5..a71c1cf 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -67,7 +67,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) | | 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) | | 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `
` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `