diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 6b76d52..ec1a20d 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -85,7 +85,7 @@ sudo docker compose --env-file .env.production logs -f backend 이 경로만 오류 로그를 `error` 부터 남긴다: 본문을 임시 파일에 받는다는 warn 이 조각마다 남았다), 그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다). JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다. - - 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` · 대용량 조각 · 대용량 받기 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`). + - 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` · 대용량 조각 · 대용량 받기 · 휴대폰 앱 받기 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`). 응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504). - **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다 (외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인). @@ -106,13 +106,19 @@ sudo docker compose --env-file .env.production logs -f backend `nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`. - 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다 (기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다. + **휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 — + 머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고, + 공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다. 머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다. 버퍼링을 끈 받기에서 `proxy_read_timeout` 은 돌지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 `send_timeout`(기본 60s)으로 끊는다 — 느려도 조금씩 받으면 이어진다(2026-10-30 확인). - **동시 연결 상한**(`limit_conn` — 받기가 연결 둘과 백엔드의 파일을 잡으므로): 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 서버 전체는 경로마다 따로 공개 400 · 앱 안 200 (함께 세면 바깥 IP 몇십 개로 직원의 앱 안 받기까지 막힌다 — 2026-10-30 리뷰). + 휴대폰 앱 받기는 앱 안 받기의 그릇(`download_app_ip` 30 · `download_app_total` 200)을 함께 쓴다 — 둘 다 로그인한 직원의 받기라 막으려는 것이 같고, 그릇(받기 600 개 — `worker_connections` 셈)을 늘리지 않으려고. + 판이 나온 날 사무실 휴대폰이 한꺼번에 받다 대용량 받기와 합쳐 주소마다 30 에 닿으면 넘은 받기만 429 다(잠시 뒤 다시 — 처리 중인 요청만 센다). 넘으면 429 `REQ_001`(JSON). 클라이언트 주소는 `X-Forwarded-For` 의 **마지막 칸**(외부 프록시가 이어 붙인 값 — 없으면 접속 주소)이다 — 앞 칸을 꾸며도 바뀌지 않는다. 8080 에 프록시를 건너뛰고 바로 붙는 길이 있으면 주소마다 한도는 피한다(경로마다 서버 전체 한도는 그대로) — 그 길을 막는 것이 ACG · proxy 네트워크다. IPv6 는 /64 로 묶지 않는다. - ⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`) — + ⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`, + 휴대폰 앱 `~* ^/api/app-releases/.+/file` — 끝을 묶지 않는다: 끝의 `/` 같은 꼴도 상한을 타게) — 백엔드 라우팅이 대소문자를 가리지 않던 때 `/FILES/` · `/api/Messages/…` 처럼 글자 하나로 상한과 `X-Accel-Buffering` 넘김을 비켜 갔다(2026-10-30 리뷰). 같은 블록의 prefix 에 `^~` 가 있으면 그 아래 경로는 블록의 정규식을 건너뛰므로 `/api/messages` 는 `^~` 없이 둔다. id · 토큰 칸은 `.+`(nginx 는 `%2F` 를 풀어 location 을 고른다). 포털이 닫는 공개 API 도 `~* ^/api/public-links(?:/|$)` 다. 백엔드도 라우팅이 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정"). @@ -216,7 +222,8 @@ sudo docker compose --env-file .env.production logs -f backend 긴 한글 이름 파일의 받기 응답 머리글(3.5KB)이 4k 를 넘으면 그 받기만 502 다. - 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다. 필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다). -- **업로드 파일은 named volume `_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 — +- **업로드 파일은 named volume `_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다, + 휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 — `sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의 `/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다. 백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다. @@ -333,7 +340,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d `docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를 `compose run`(프로젝트 이름 `ci-bind-`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다. 11단계부터는 nginx 를 두 꼴(공개 호스트를 가를 때 · 아닐 때)로 `nginx -t` 하고 렌더링 결과(server 이름 · 포털이 닫는지 · 받기 영역 400 · 200)와 틀린 `SHARE_PUBLIC_URL` 의 기동 거부를 본다. - "대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다. + "대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다 + (휴대폰 앱 받기 `…/file` 도 — 앱 안 받기와 그릇을 함께 쓰는지 · 공개 호스트는 404 인지 · 정보 경로 `…/latest` 는 타지 않는지. "nginx 설정 검사"는 `limit_conn download_app_*` 가 두 꼴 모두 둘씩인지 센다). "닫는 경로 시험"(12단계)은 같은 가짜 백엔드로 두 server × 닫는 경로 셋(`/api/internal` · `/api/health` · 공개 API)의 변형이 nginx 가 직접 낸 JSON 인지 본다 — 대소문자 · 겹 `/` · `%2F` · `%69` · `%5C` · `..` · absolute-form · 끝 `/` 는 404, 날 `\` · `#` · 0xA0 는 400, 쿼리의 `\` 는 통과, `/api/internals` 같은 이웃 경로는 백엔드로 넘어간다 (경계 확인은 wget — busybox `nc` 는 표준 입력이 끝나면 닫아 프록시를 거친 응답을 받지 못한다). 정적 검사는 include 와 `if ($request_target_rejected)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다). diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index f3b6e55..32395f1 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -13,7 +13,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S ## 템플릿 기본 제공 골격 > 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). -> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 스토어에 올린 뒤에는 못 바꾼다), 패키지 `arasystem_app`. +> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`. | 제공 항목 | 위치 | 비고 | | --- | --- | --- | @@ -39,8 +39,8 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 테마 | `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` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | | **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 | -| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · `AppStoreLauncher` · 글) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). [Play 스토어 열기]는 `market://details` → 웹 주소, 패키지 이름은 `appPackageNameProvider`(main 이 기기 정보로) | -| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보 · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | +| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 | +| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | | 자리 화면 | `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]]) | @@ -80,6 +80,7 @@ lib/ │ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판 │ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes) │ ├── app_config/ ← 앱 설정(최소 판 · 알림을 보낼 수 있는가) +│ ├── app_download/ ← [새 버전 받기] — 포털의 휴대폰 앱 받기 화면 주소 · 런처 · 글 (업데이트 화면 · 설정이 함께 쓴다) │ ├── push/ ← 새 메일 알림(FCM · 채널 · 서버 등록 맞추기) · 알림 → 경로 │ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry) │ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시 @@ -292,7 +293,7 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser - [ ] **모듈**: 새 기능은 그 모듈 폴더(`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 권한이 있어야 네트워크가 된다). +- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판(release)은 우리 서명 키(`android/key.properties` — 저장소 밖 keystore)와 `google-services.json` 이 있고 `flutter/.env` 의 `API_URL` 이 운영 포털의 https 주소일 때만 만든다 — 하나라도 아니면 Gradle 이 까닭을 알리고 멈춘다(debug 키로 조용히 서명하지 않고, 개발 주소가 든 판이 나가지 않게 — 알리는 글에 주소 값은 싣지 않는다, `flutter/README.md` "사내 배포"). 만들면 release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다는 것도 본다. Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고, Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다). Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 9e1e3cb..0a70ef2 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -42,10 +42,11 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 파일 저장 · 조각 올리기 (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` — 지금 세션은 `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) | +| 전환 준비 (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 가 없다). 명령 열세 개(12 · 13 은 휴대폰 앱 — 아래 "휴대폰 앱 받기" 행)와 차례는 계약 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` | +| 휴대폰 앱 받기 (사내 배포 — 2026-11-08) | `modules/app-releases` · `modules/ops/app-release-commands.ts` · `shared/utils/download-headers.ts` | 계약 1절 "휴대폰 앱 받기" — 스토어 없이 직접 빌드한 APK 를 로그인한 직원에게만. `GET /api/app-releases/android/latest`(`{ version, sizeBytes, releasedAt }` · `private, no-store` — 없으면 404 `RES_001`, 컨트롤러 필터가 404 를 debug 로) · `…/latest/file`(GET · HEAD · Range — `downloadHeaders()` 가 대용량 첨부 받기(`serveFile`)와 같은 머리글을 만든다, 형식만 APK · 이름 `ARA-SYSTEM-<버전>.apk`). 제한된 세션은 403 `AUTH_010`. **최신 = 가장 늦게 올린 판**(`created_at` — 버전 번호가 아니다, 옛 APK 를 다시 올리면 되돌리기). 표 `app_releases`(마이그레이션 `AddAppReleases`), 파일은 업로드 볼륨 `app-releases/.apk`(`app-release-storage.ts` — 서버가 만든 UUID 만 경로로, `.part` 에 쓰고 datasync → rename, 표에 넣지 못하면 지운다, 한 시간 넘은 `.part` 는 다음 올리기가 지운다). 표에 있는데 파일이 없거나 크기가 다르면 404 + error 로그. **올리기는 API 가 없다** — 운영 도구 「휴대폰 앱 올리기」 · 「휴대폰 앱 목록」: `apk-file.ts` 의 `inspectApk`(O_NOFOLLOW · O_NONBLOCK 로 한 번 열어 fstat 일반 파일 · `.apk` · `PK\x03\x04` · 1KB~200MB · SHA-256 — 그 연 파일로 들이고, 들이며 다시 센 SHA-256 이 다르면 `AppReleaseFileChangedError`), 버전 규칙은 앱 알림 등록의 `APP_VERSION_PATTERN` 그대로(`app-release-version.ts`), app-config 의 최소 판보다 낮으면 막고 지금 최신보다 낮거나 같으면 묻는다. 실행 기록 `app-release-upload` | | 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]]) | diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index a71c1cf..476e8fc 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -69,6 +69,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 대용량 첨부 (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` 요소만 `