feat: 앱 기반과 앱 서버 — 앱 전용 로그인 · 새 메일 알림(FCM) · 앱 설정 #20

Merged
ttipo merged 20 commits from feat/app-push-server into main 2026-11-08 15:47:30 +09:00
Owner

무엇을 · 왜

모바일 앱(Flutter — 안드로이드 먼저, 첫 판 = 로그인 · 메일 · 새 메일 알림)을 위한 서버와 앱 기반이다. 11-05 사용자 결정(웹 · 운영과 나란히 앱 개발)에 따라 웹과 같은 세션 규칙을 앱 토큰 경로로 쓴다.

  • 앱 전용 로그인(계약 1절 "앱 로그인") — POST /api/auth/app/login · refresh · logout + 보호 API 는 Authorization: Bearer. 웹과 같은 AuthService(실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 거절 · 막는 계정)를 지나고, 토큰은 응답 본문(no-store), 세션 표에 client(web · app — 마이그레이션 AddAppSessionClient, DB 기본값 web). 앱 세션의 30초 유예는 그 세션을 끝낸다.
  • 웹 설정 › 로그인된 기기 — 앱 세션 · 앱 로그인 기록이 함께 보인다("웹메일 2곳 · ARA SYSTEM 앱 1곳", 휴대폰 아이콘).
  • 앱 새 메일 알림(FCM v1 — FID)(계약 1절 "앱 새 메일 알림 · 앱 설정") — 등록 PUT · DELETE /api/push-registrations/current · DELETE …/current/unseen(앱 토큰만), 표 push_registrations(마이그레이션 AddPushRegistrations). 새 메일 신호 → 같은 직원 30초에 한 번 → "새 메일 N통"만(내용 · 주소 · 보낸 사람 없음), 저절로 끝난 세션 · 다른 곳 비밀번호 바꿈 등에는 "알림이 멈췄습니다" 한 번. 새 의존성 없음(node crypto RS256 JWT · fetch). 키 FCM_SERVICE_ACCOUNT_KEY(backend env — 선택, 없으면 알림만 끄고 뜬다 · 기동 로그로 확인).
  • GET /api/app-config(로그인 없음) — 앱 최소 판 · pushAvailable.
  • 앱 기반(Flutter) — 이름 · 식별자 ARA SYSTEM · kr.it.arasystem.app, Flutter 3.41.9 고정(CI 와 같게), 로그인 · 토큰 갱신 · 모듈 라우터 · 인터셉터.
  • 14단계 · 문구 다듬기를 합친 판이다(f0ef782 · 이번 합침). 이번 합침의 충돌 넷은 모두 로그인된 기기 글이었다 — 뜻은 앱 쪽("브라우저" → "기기"), 문장은 다듬은 글("이 목록에 없습니다")로 풀었다.

확인 방법

  • 실기기(11-08 — 갤럭시 S23 · Android 16 · 개발 서버 + 개발 Firebase): 앱 로그인 · 알림 등록(FID 22자), 앱이 뒤에 있을 때 · 닫혀 있을 때 배달된 그 초에 보내 1초 안에 표시, 같은 tag 바꿔 쓰기("새 메일 2통")와 소리 · 런처 배지, 앱을 보는 중에는 그리지 않음, 누르면 받은편지함, 로그아웃 → FCM 풀기 · 5분 정리가 줄을 지우고 세션 끝 알림 없음. 결과는 계약 1절 "실기기 · 실제 키로 확인할 것".
  • 이번 합침 뒤 개발 컨테이너에서: backend tsc · lint 0 · 단위 3,701 · e2e 333 · 프론트 type-check · lint 0 · 단위 2,774.
  • CI 다섯 잡(+ Flutter)이 이 PR 머리를 시험한다.
  • 운영에 올릴 때(따로): 운영용 서비스 계정 키(알림 전용 · 역할 "Firebase Cloud Messaging API Admin" 하나)를 backend/.env.production 에, backend → oauth2.googleapis.com · fcm.googleapis.com 443 · 서버 시계(NTP) — docker 스킬 "백엔드의 바깥 연결".

영향 범위

  • backend
  • frontend (로그인된 기기)
  • flutter (앱 기반)
  • docker / 인프라 (Flutter 판 고정 · CI)
  • 규칙 문서(.claude/)

공유 계약 — 에러 코드 · 응답 포맷은 그대로다(앱 토큰만 받는 경로는 웹 쿠키 세션에 403 AUTH_003, 제한된 세션 403 AUTH_010 — 있던 코드).
더한 것: 앱 로그인 경로 셋, 알림 등록 경로 셋, GET /api/app-config, 로그인된 기기 · 로그인 기록 응답의 client(web · app) · 방식 app.
웹은 바뀌는 동작이 없고 화면만 앱 세션을 함께 보인다. 앱 화면은 다음 PR.

체크리스트

  • 린트/분석 통과 (Warning 0) · 변경 영역 테스트 통과
  • 주석 한국어, 매직 넘버·하드코딩 문자열 없음
  • 사용자 노출 실패는 모두 에러 코드와 함께 처리 (BusinessException / error.code 분기)
  • 에러 코드를 바꿨다면 npm run sync:error-codes 실행 후 생성 파일까지 커밋 (바꾸지 않음)
  • 민감정보 미커밋 (*.example 값은 placeholder — FCM 키 · google-services.json 은 커밋하지 않음)
  • 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음 (FID · 서비스 계정 키 · access token · FCM 오류 글도)

🤖 Generated with Claude Code

## 무엇을 · 왜 모바일 앱(Flutter — 안드로이드 먼저, 첫 판 = 로그인 · 메일 · 새 메일 알림)을 위한 **서버와 앱 기반**이다. 11-05 사용자 결정(웹 · 운영과 나란히 앱 개발)에 따라 웹과 같은 세션 규칙을 앱 토큰 경로로 쓴다. - **앱 전용 로그인**(계약 1절 "앱 로그인") — `POST /api/auth/app/login` · `refresh` · `logout` + 보호 API 는 `Authorization: Bearer`. 웹과 같은 `AuthService`(실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 거절 · 막는 계정)를 지나고, 토큰은 응답 본문(no-store), 세션 표에 `client`(web · app — 마이그레이션 `AddAppSessionClient`, DB 기본값 web). 앱 세션의 30초 유예는 그 세션을 끝낸다. - **웹 설정 › 로그인된 기기** — 앱 세션 · 앱 로그인 기록이 함께 보인다("웹메일 2곳 · ARA SYSTEM 앱 1곳", 휴대폰 아이콘). - **앱 새 메일 알림(FCM v1 — FID)**(계약 1절 "앱 새 메일 알림 · 앱 설정") — 등록 `PUT` · `DELETE /api/push-registrations/current` · `DELETE …/current/unseen`(앱 토큰만), 표 `push_registrations`(마이그레이션 `AddPushRegistrations`). 새 메일 신호 → 같은 직원 30초에 한 번 → **"새 메일 N통"만**(내용 · 주소 · 보낸 사람 없음), 저절로 끝난 세션 · 다른 곳 비밀번호 바꿈 등에는 "알림이 멈췄습니다" 한 번. **새 의존성 없음**(node crypto RS256 JWT · fetch). 키 `FCM_SERVICE_ACCOUNT_KEY`(backend env — 선택, 없으면 알림만 끄고 뜬다 · 기동 로그로 확인). - **`GET /api/app-config`**(로그인 없음) — 앱 최소 판 · `pushAvailable`. - **앱 기반(Flutter)** — 이름 · 식별자 ARA SYSTEM · `kr.it.arasystem.app`, Flutter 3.41.9 고정(CI 와 같게), 로그인 · 토큰 갱신 · 모듈 라우터 · 인터셉터. - 14단계 · 문구 다듬기를 합친 판이다(f0ef782 · 이번 합침). 이번 합침의 충돌 넷은 모두 로그인된 기기 글이었다 — 뜻은 앱 쪽("브라우저" → "기기"), 문장은 다듬은 글("이 목록에 없습니다")로 풀었다. ## 확인 방법 - **실기기(11-08 — 갤럭시 S23 · Android 16 · 개발 서버 + 개발 Firebase)**: 앱 로그인 · 알림 등록(FID 22자), 앱이 뒤에 있을 때 · 닫혀 있을 때 배달된 그 초에 보내 1초 안에 표시, 같은 tag 바꿔 쓰기("새 메일 2통")와 소리 · 런처 배지, 앱을 보는 중에는 그리지 않음, 누르면 받은편지함, 로그아웃 → FCM 풀기 · 5분 정리가 줄을 지우고 세션 끝 알림 없음. 결과는 계약 1절 "실기기 · 실제 키로 확인할 것". - 이번 합침 뒤 개발 컨테이너에서: backend tsc · lint 0 · 단위 3,701 · e2e 333 · 프론트 type-check · lint 0 · 단위 2,774. - CI 다섯 잡(+ Flutter)이 이 PR 머리를 시험한다. - **운영에 올릴 때**(따로): 운영용 서비스 계정 키(알림 전용 · 역할 "Firebase Cloud Messaging API Admin" 하나)를 `backend/.env.production` 에, backend → `oauth2.googleapis.com` · `fcm.googleapis.com` 443 · 서버 시계(NTP) — docker 스킬 "백엔드의 바깥 연결". ## 영향 범위 - [x] backend - [x] frontend (로그인된 기기) - [x] flutter (앱 기반) - [x] docker / 인프라 (Flutter 판 고정 · CI) - [x] 규칙 문서(.claude/) > **공유 계약** — 에러 코드 · 응답 포맷은 그대로다(앱 토큰만 받는 경로는 웹 쿠키 세션에 403 `AUTH_003`, 제한된 세션 403 `AUTH_010` — 있던 코드). > 더한 것: 앱 로그인 경로 셋, 알림 등록 경로 셋, `GET /api/app-config`, 로그인된 기기 · 로그인 기록 응답의 `client`(web · app) · 방식 `app`. > 웹은 바뀌는 동작이 없고 화면만 앱 세션을 함께 보인다. 앱 화면은 다음 PR. ## 체크리스트 - [x] 린트/분석 통과 (Warning 0) · 변경 영역 테스트 통과 - [x] 주석 한국어, 매직 넘버·하드코딩 문자열 없음 - [x] 사용자 노출 실패는 모두 에러 코드와 함께 처리 (`BusinessException` / `error.code` 분기) - [x] 에러 코드를 바꿨다면 `npm run sync:error-codes` 실행 후 생성 파일까지 커밋 (바꾸지 않음) - [x] 민감정보 미커밋 (`*.example` 값은 placeholder — FCM 키 · google-services.json 은 커밋하지 않음) - [x] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음 (FID · 서비스 계정 키 · access token · FCM 오류 글도) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ttipo added 47 commits 2026-11-08 12:45:57 +09:00
14단계 자체 검색 색인의 1차다. 검색 API 를 색인으로 바꾸는 일과 대화 키 잇기는 다음 차례다.

- 마이그레이션 AddMailSearchIndex 하나: 표 mail_search_messages · mail_search_states 와
  mail_settings.group_by_conversation 열(3차의 대화로 묶어 보기 설정 — 같은 배포에서 한 번에 만든다)
- 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 순수 함수와 표본 표(나중에 frontend 와 짝)
- 엔진 포트 MAIL_INDEX_SOURCE + JMAP 어댑터: Email/changes 맞추기(실시간 · 5분 · 검색 전,
  서버 전체 동시 4), 밤 창(KST 22:00~02:45 · 05:00~07:30) 지난 메일 채우기, 전체 대조
- GET /api/search-index, 로그인 · 새 비밀번호 때 상태 줄, 퇴사 마무리에 표 둘
- 로그 가림에 조건 검색 이름 넷, 개발 환경 DB 쿼리 로그 끔(메일 글이 DB 에 든다)

리뷰 반영: 시간 끝은 실패가 아니라 이어 하기, 본문 읽기 묶음의 메일 크기 상한(32MiB ·
16MiB 넘으면 혼자), 겹친 id 빼기, 사내 도메인 주소만 색인(개발 서버 cron 이 e2e 줄을 건드리지 않게).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 2절에 "검색 색인 (GET /api/search-index)" — 색인에 드는 것 · 만드는 때 · 밤 채우기 ·
맞추기 · 조회지만 상태 줄을 만드는 동작(멱등)을 적었다. 로그 규칙에 개발 환경도 DB 쿼리
매개변수를 남기지 않는다는 줄, nestjs 스킬에 mail-search 모듈 줄과 Redis 로 옮길 상태를 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1만 통 측정에서 색인 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이었고 캐시가 비면
3~8배 느렸다(사용자 결정 2026-11-05). 8GB 서버의 한도 합은 6.65GB · 백업 때 7.65GB 다.
복원 연습 때는 8.15GB 로 8GB 를 넘는다 — 사실만 적고 다룰 방법은 따로 정한다.
mail/README 에 운영에 올리는 차례(db 만 먼저 up · 한도 · shared_buffers 확인)를 적었다.
docker 스킬의 연습 주기를 기준 문서(README — 한 달에 한 번)에 맞췄다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 2차다. 색인이 준비된 직원은 서버의 자체 색인으로, 채우는 중인 직원은 지금처럼
엔진으로 찾는다(방식은 첫 쪽에서 정하고 커서가 잇는다).

- 조건 from · to(참조 포함) · subject · body · after · before · hasAttachment — q 와 합친 낱말 8개까지
- 부분 일치(대소문자 · 전각 · NFC), 구절은 줄바꿈 · NBSP 로 갈린 글에도, 한 글자 낱말뿐이면
  제목 · 사람 · 첨부 이름에서만(2026-11-05 사용자 확정)
- 찾기: GIN(lower(user_email), grams) 후보 → 기본 키로 확인, 한 요청 확인 3,000줄 상한 +
  이어 찾기 커서, total 은 첫 쪽에서 1,000 까지(못 세면 null) — 1만 통에서 가장 무거운 검색 0.8초
- 목록 항목 searchHighlight(제목 · 맞은 말 둘레 미리 보기), 읽기 화면은 정화한 HTML 의 글
  조각에 <mark> · 제목 · 첨부 이름 · 글 본문 위치
- 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의
  색인을 지우고 밤에 다시 채운다

리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션,
다른 직원 줄 격리 e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
쿼리 일곱 · 합친 낱말 8개 · 찾는 규칙(부분 일치 · 구절 · 한 글자) · 목록과 읽기 화면의
searchHighlight · hasNext 인데 빈 쪽 · total null 이면 화면이 통 수를 숨기는 것 · 강조 응답은
보이기만(인용 · 인쇄에 쓰지 않음)을 적고, 3단계의 알려진 한계는 색인을 채우는 동안에만
남는다고 고쳤다. nestjs 스킬의 mail-search 줄도 2차에 맞췄다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 3차다. 같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 보이고, 목록을 대화로
묶어 볼 수 있게 한다. 색인이 준비된 직원은 우리 대화 키로, 채우는 중인 직원은 엔진의
스레드로 묶는다(응답의 basis — 첫 쪽에서 정하고 커서가 잇는다).

- 대화 키: 색인 쓰기 때 넣은 메일의 부모 · 자식(References · In-Reply-To) 가운데 말머리
  (회신 · 답장 · 전달 포함)를 뗀 제목이 같은 것을 가장 작은 키로 합친다. 늦게 온 원본도
  잇고, 다시 넣어도 키는 풀리지 않는다. 색인 판 4
- GET /api/messages/{id}/conversation — 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과
  발송 중인 내부 초안은 빼고 지금 메일은 늘 넣는다
- GET /api/conversations?mailboxId= — 그 메일함의 대화 한 줄씩(참여자 10명 · 통 수 ·
  messageIds 최신 500 · pendingSendIds). 직원 주소 btree 로 먼저 좁힌다(10명 흉내 큰 메일함
  첫 쪽 81ms)
- 설정 groupByConversation(처음 끔) — 서버는 저장만 하고 묶을지는 화면이 정한다
- 마이그레이션 AddMailSearchSender — 참여자는 따로 둔 첫 보낸 사람 칸에서만 읽는다

리뷰 반영: 엔진 방식에 개수 · 시간 상한(같은 대화 한 요청 1,000통 · 묶은 목록 스레드마다
500 · 한 요청 2,000 · 15초, 요약 속성은 내보낼 메일만), Mailbox/get 장애는 503,
꾸민 커서는 엔진 · DB 보다 먼저 400, 같은 대화 한 문장, 이름 안의 <주소>로 참여자를
꾸미지 못하게, 로그 추적 ID. 검색 표본 표 머리 주석(frontend 로 옮겼다)도 함께 고친다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "대화 — 14단계" 절: 기준(basis — 색인 · 엔진), 대화에 드는 메일, 같은 대화 ·
  대화로 묶어 보기의 쿼리 · 응답(ConversationSummary — messageIds 최신 500 ·
  pendingSendIds), 엔진 방식의 상한과 어림이 되는 줄, 빈 쪽에도 커서가 오는 때, 알려진 한계
- 보내기 · 읽기 설정에 groupByConversation(처음 false — 서버는 저장만)
- API 표에 검색 조건 · 강조 쿼리, 같은 대화 · 묶은 목록 · 검색 색인 경로를 더한다
- nestjs 스킬의 메일 검색 색인 줄에 3차(대화 키 · 보낸 사람 칸 · 엔진 상한)를 적는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- [조건] 패널(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부)과 결과 머리의 조건 줄 —
  조건도 화면 주소에 싣는다(since · until 은 브라우저 시간대 날짜 · 양 끝 포함)
- 결과 줄 세 줄(분할 보기)과 맞은 말 노랑 강조 — HighlightedText 가 글 조각으로 그린다(v-html 없음),
  읽기 화면은 서버가 넣은 <mark> · 제목 · 첨부 이름 강조. 강조가 든 응답은 보이기만 한다
  (답장 · 전달 · 인쇄 · 예약은 강조 없이 받는다)
- "검색 준비 중" 줄 · 한 글자 안내 · 빈 쪽이면 이어 찾기(한 번에 5번까지) + [더 찾기],
  total 이 null 이면 통 수를 숨긴다
- search-match.ts — backend search-query · search-text · subject-base 와 짝(같은 표본 표)

리뷰 반영: 이어 받는 동안 [다음] · [메일 더 보기] 기다림, 새로 고치는 동안 이어 받지 않음,
손으로 고친 주소도 패널과 같은 검사(8낱말 · 날짜 범위 · 거꾸로 된 기간), 패널 컴포넌트 나눔,
시험 정리(unmount · 보이지 않는 글자 이스케이프).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 계약 2절 "검색 — 14단계"에 클라이언트 줄: 화면 주소(조건 이름 · since · until · attachment),
  손으로 고친 주소의 검사, 읽기 화면에 넘기는 다섯 칸, 이어 찾기 5번, 준비 상태를 모를 때,
  보낸 사람이 쓴 <mark> 의 한계
- 디자인 README "14단계 구현하며 정한 보완 — 검색": [조건] 32 밀도 예외 재사용, 패널 · 조건 줄 ·
  결과 없음 · [더 찾기] · 낱말이 너무 많은 주소의 글, 목록 보기는 행 38 에 제목만 강조,
  결과 줄의 별 · 고르기 칸(3단계 여러 통 처리), 강조 색 토큰
- vue3 스킬에 14단계 검색 줄과 밀도 예외 재사용을 더한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 본문 · 첨부 아래 "같은 대화 N통" — 오래된 차례의 줄(48), 지금 보는 메일 줄은 누를 수 없고,
  다른 줄은 그 자리에서 펼쳐 본문(MailBodyFrame — 강조 쿼리 없이)과 [답장] · [이 메일만 열기]
- 100통씩 받고 쪽이 모자라면 5번까지 이어 묻기 + [더 보기], 통 수를 모르면 수 없이
- 실패는 칸 안 안내 + [다시 시도](알림 없음), 이 경로가 없는 옛 서버(404)면 칸이 없다
- 다시 받기는 메일함 통 수가 바뀔 때 · 쓰기 화면에서 돌아올 때 · 붙잡힌 메일이 풀릴 때 —
  읽음 표시만 바뀐 소식으로는 받지 않고, 끝난 때부터 10초 간격
- 펼친 안 읽은 메일은 읽음으로(읽기 화면과 같은 길), 펼친 본문 안 Ctrl+P 는 그 메일 인쇄

리뷰 반영: 누른 단추가 사라질 때 초점 옮기기, 읽음 소식으로 요청이 두 배가 되던 것,
받는 중 온 소식 · 시계 뒤로 · [더 보기] 뒤 붙잡힌 메일, 받아 둔 본문 10통까지,
본문 안내 줄 · 지워진 메일, 목록 보기에서는 [이 메일만 열기]가 기록을 바꿔 끼운다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 계약 2절 "대화 — 14단계"에 읽기 화면 칸의 클라이언트 줄: 100통 · 이어 묻기 5번 · [더 보기],
  통 수, 알림 없는 실패와 404 면 칸 없음, 다시 받는 때와 10초 간격, 펼친 줄의 읽음
- 디자인 README "14단계 구현하며 정한 보완 — 같은 대화": 크기, 보드와 다르게 한 것
  (기존 바탕 토큰 · h3 · 본문 14px · 펼친 줄의 안내), 보드에 없던 것, 갈 곳 · 초점 · 다시 받기
- vue3 스킬에 같은 대화 줄 · 스토어 구성 · 밀도 예외 재사용을 더한다
- docker 스킬의 요청 수 한도 안내에 메일을 열 때마다 부르는 같은 대화 경로를 더한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 사용자가 정한 앱 전용 토큰 경로다(웹의 쿠키를 앱에 쓰지 않는다). 서버는 아직 없다 —
앱은 이 절대로 만들고 가짜 서버로 시험하며, 서버는 다음 차례에 이 절대로 만든다.

- POST /api/auth/app/login · refresh · logout — 토큰은 본문으로, Bearer 는 앱 표시(cli: app)
  토큰만 · 쿠키는 웹 토큰만. 세션 규칙 · 실패 카운터 · 2단계 코드 가드 · 제한된 세션은 웹과 같다
- 유예 응답(tokens: null)은 세션 끝 — 액세스 토큰이 남았으면 Bearer 로 조용히 logout,
  logout 본문의 refreshToken 은 선택
- 앱이 할 일: 토큰 보관 · 갱신 한 번에 하나 · AUTH_010 다시 확인 · 늦은 응답 버리기
- 5절(계정 보안): 앱 세션도 같은 목록에, 앱은 sid 로 지금 세션을 안다, 로그인 기록 method app,
  기기 이름 "ARA SYSTEM 앱"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
캔버스에서 2026-11-05 확정한 앱 1단계 보드 다섯(로그인 네 단계 · 메일 목록 · 읽기 · 쓰기 ·
앱 틀과 설정 — 안드로이드 먼저)과 앱 디자인 README 다. 앱 틀은 웹의 전역 앱 레일을 아래 탭 막대로
옮긴 것이고, 파일 저장소 · 캘린더도 같은 모듈 틀에 들어간다. canvas.json 은 원래 꼴(보드마다 한 줄)에
보드 다섯 · 차례 · 노트 하나만 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
템플릿의 tma_app · com.example.tma_app 을 앱 이름 "ARA SYSTEM", 식별자 kr.it.arasystem.app
(스토어에 올린 뒤에는 바꿀 수 없다 — 2026-11-05 사용자 확정), 패키지 이름 arasystem_app 으로 바꾼다.
안드로이드 namespace · applicationId · MainActivity 자리, iOS · macOS 번들 id, 리눅스 · 윈도 · 웹의
이름을 함께 고치고, 템플릿의 카운터 위젯 시험은 지운다(앱 기반 커밋이 앱 시험을 둔다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 1단계의 바탕이다(계약 1절 "앱 로그인" — 서버는 다음 차례, 지금은 가짜 서버로 시험한다).

- 토큰: 리프레시 토큰은 보안 저장소에만, 액세스 토큰은 메모리에만. 첫 실행 표시가 없으면 남은 토큰을
  지우고, 안드로이드 백업을 끈다. 보안 저장소 일은 차례로 한다
- 인터셉터: 401 이면 갱신 한 번(동시 401 묶음) 뒤 다시 보내기, AUTH_010 은 me 를 다시 받아 그 단계로,
  미리 갱신 30초 전 · 만료는 응답 Date 로, 세션 세대가 바뀐 뒤의 응답은 조용히 버린다,
  비밀이 든 요청의 실패 예외는 본문 · Bearer 를 지운다
- 세션: 로그인 · 코드 · 새 비밀번호 · 2단계 켜기 단계의 상태와 늦은 결과 버리기(버린 로그인의 토큰은
  조용히 logout), 로그아웃은 메모리부터 비운다, 유예 응답이면 세션 끝 + 조용한 logout
- 모듈 틀: go_router StatefulShellRoute 와 아래 탭 막대 — 모듈 목록은 웹 app-modules.ts 와 짝,
  첫 판은 [메일 · 설정]. 모듈끼리 import 하지 않는다
- Riverpod 자동 다시 하기를 끈다, Pretendard 1.3.9(OFL), 확정 글자 크기, 시험 126건
- 판은 Flutter 3.41.9(Dart 3.11.5) — pubspec 의 sdk 하한과 lock 을 함께

리뷰 세 번 반영(AUTH_010 다시 보내기 · 늦은 응답 · 저장 실패 · 로그아웃 차례 · 비밀이 실린 객체 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
같은 마이너의 최신 패치 3.41.9(Dart 3.11.5 · 2026-04-30)로 개발 PC 와 CI 를 함께 올린다
(2026-11-06 사용자 확인 — 개발 PC 의 Flutter 는 다른 프로젝트도 함께 쓴다). CI 는
`flutter pub get --enforce-lockfile` 로 lock 과 어긋나면 실패한다. docker 스킬의 이미지 버전 표에
Flutter SDK 줄과 함께 고치는 곳을 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- flutter 스킬: 기본 제공 골격을 이번 앱 기반으로(인증 인터셉터 · 토큰 보관 · 세션 노티파이어 ·
  모듈 틀), 새 모듈 붙이는 법, 비밀이 든 요청의 실패 예외, 자동 다시 하기 끔, Flutter 판
- 보안 · 에러 코드 · 코딩 규칙: 앱은 리프레시 토큰을 본문으로 받아 보안 저장소에(kRefreshTokenKey),
  세션 만료 승격은 갱신 뒤 · 스스로 로그아웃한 뒤의 401 은 승격하지 않는다(웹과 같다)
- mobile-dev 역할: 작업 순서 · 완료 기준(apk 빌드 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 1절 "앱 로그인"이다(웹의 쿠키를 앱에 쓰지 않는다). 세션 규칙은 웹과 같고 토큰을 나르는
길만 다르다.

- POST /api/auth/app/login · refresh · logout — 같은 AuthService.login 을 지나 실패 카운터 ·
  2단계 코드 가드 · 앱 비밀번호 모양 거절 · 관리자 차단이 웹과 같고, 로그인 요청 수 한도는
  웹 · 앱이 한 칸을 함께 쓴다. 토큰 응답은 private, no-store
- Bearer 는 앱 표시(cli: app)와 UUID sid 가 있는 액세스 토큰만, 쿠키는 웹 토큰만 — 리프레시 토큰 ·
  세션 표의 client 도 맞아야 하고 다르면 401(재사용으로 보지 않음)
- 앱 세션의 갱신 유예는 그 세션을 끝낸다(app-refresh-grace — 응답을 잃었거나 토큰을 훔친 것이다)
- 지금 세션은 앱이면 액세스 토큰의 sid(비밀번호 바꾸기 · 로그인된 기기 · 앱 logout), 로그아웃은
  리프레시 토큰이 틀리면 sid 로 끝낸다
- 세션 표 client 칸(마이그레이션 AddAppSessionClient — 기본 web), 세션 목록 항목에 client,
  앱 이름 "ARA SYSTEM 앱"은 client · method 로만(꾸민 User-Agent 로 앱처럼 보이지 않게),
  로그인 기록 method app
- 운영 도구: 현황의 마지막 로그인이 앱도 세고, 세션 끊기 글 · 직원 알림 메일이 앱을 말한다
- Swagger 에 Bearer 를 등록하고 쿠키로 보호하는 작업마다 "또는"으로 붙인다

리뷰 반영: 유예에서 앱 세션 끊기, 앱 이름을 진짜 클라이언트로만, sid 없는 앱 토큰 거절,
빈 · 너무 긴 리프레시 토큰은 401(로그아웃은 sid 로), 시험(웹 쿠키 + 앱 logout 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"등록 → 켜기 → … → 끄기" 시나리오는 끄기에 "그때 단계 −1" 코드를 썼다. 켜기와 끄기 사이에
30초 경계를 넘으면 그 코드가 켤 때 쓴 코드와 같아져, 서버의 다시 쓴 코드 막기가 AUTH_006 으로
거절해 가끔 실패했다(코드는 맞게 거절한 것이다). 이미 쓴 코드가 아닌 것을 0 → +1 → −1 단계에서
고르고(0 · +1 은 요청이 경계를 넘어도 서버의 ±1 창 안), −1 만 남았는데 경계가 1초 안이면
경계를 기다린 뒤 다시 고른다. 따로 다섯 번 돌려 모두 통과했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 로그인 서버가 생겨 계정 보안 화면에 앱 세션 · 앱 로그인이 함께 들어온다.

- "로그인된 브라우저" → "로그인된 기기"(계약 5절), 개수는 세션의 client 로 "웹메일 2곳 ·
  ARA SYSTEM 앱 1곳"(0곳인 쪽은 쓰지 않음), 앱 세션은 늘 휴대폰 아이콘
- 로그인 기록 방식 "ARA SYSTEM 앱", 앱 줄의 기기 칸은 운영체제만(방식 칸이 이미 앱 이름)
- [다른 곳 모두 로그아웃] · 비밀번호 바꾸기 · 안내 글이 웹메일만이 아니라 앱까지 끊는다고 말한다
- 웹 · 앱은 client · method 로만 가른다(User-Agent 이름으로 짐작하지 않는다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 계약 1절 "앱 로그인": 서버 구현, 갱신 · 로그아웃의 400 / 401 경계(빈 · 너무 긴 토큰),
  private, no-store, Bearer 는 cli 와 sid 가 있는 앱 토큰만, 웹 · 앱 로그인 요청 수 한 칸,
  앱 세션의 갱신 유예는 세션 끝(서버도 끊는다), 앱 이름은 진짜 클라이언트로만, 알려진 한계
- 5절: "로그인된 기기"(세션 목록 client 칸 · 앱 기기 이름 규칙), 로그인 기록 method app ·
  device 가 앱 기록에도
- 6절: 운영 도구 현황의 마지막 로그인(웹메일 · 앱), 재설정 · 이전 마치기의 세션 끊기가 앱도
- 디자인 README: 12단계 로그인된 기기 · 최근 로그인 · 비밀번호 화면 글, 13단계 직원 알림 메일의
  로그아웃 줄, 2단계 켜기에서 다른 곳이 먼저 켠 경우(서버가 이 세션을 끊는다 — 계약 6절)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- nestjs: Swagger 는 쿠키와 Bearer 둘 다, 인증 가드가 Bearer(앱 토큰만)와 쿠키를 읽는 차례,
  직원 인증 · 계정 보안 줄에 앱 경로 · client 칸 · 앱 세션의 유예, JWT 절을 사실대로(12단계부터
  끊은 세션은 sid 로 바로 막고 13단계부터 rst 토큰만 세션 표를 읽는다)
- vue3: 계정 보안 줄에 로그인된 기기 — 웹 · 앱은 client · method 로만, 앱 이름 MOBILE_APP_NAME
  (짝: backend APP_BROWSER_NAME)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의
한 줄을 더한다. 켜면 목록 출처가 GET /api/conversations 이고, 줄에 하는 일
(고르기 · 읽음 · 중요 · 이동 · 삭제)은 그 줄의 messageIds 에서 붙잡힌 메일을
뺀 메일을 지금의 여러 통 API 로 보낸다 — 붙잡힌 메일은 취소로만 보낸편지함을
떠난다(9단계).

- 대화 목록에서는 messages 가 비므로 읽기 화면 · 삭제 · 예약 취소가 줄 기준
  도우미(rowCount · rowIndexOfMessage · isListedMessage 등)로 같은 규칙을 탄다
- 첫 진입은 이 브라우저가 그 직원에게서 마지막으로 안 값으로 열고, 설정을
  받지 못해도 [대화로 묶기]를 쓸 수 있다
- 리뷰 M1: 로그아웃 · 세션 만료 직후 설정 reset 이 watcher 를 깨워 떠나는
  화면의 목록을 다시 부르던 회귀 — canUseApp 이 false 면 부르지 않고 reset 은
  보이는 값을 뒤집지 않는다
- 리뷰 L7: 버린 목록 요청은 AbortController 로 끊는다(엔진 방식은 한 요청이
  15초까지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 2절 "대화 — 14단계"에 묶은 목록의 클라이언트 줄(첫 진입 짐작 · 맞추기 ·
할 일 메일 · 줄의 별 · 옮기기 뒤 다시 받기 · 알려진 한계), 디자인 README 에
"14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기", vue3 스킬에 골격 줄 ·
밀도 예외 · 줄 기준 도우미 규칙을 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 실사용 시험(2026-11-06)에서 나온 것을 고친다.

- 1만 통 메일함 안 검색이 1.1초였다. 계획기가 메일함만 담은 GIN(메일함 id
  가 계정마다 같은 꼴이라 posting 이 회사 전체)으로 그 메일함 줄을 다 읽고
  묶음 배열을 줄마다 풀었다. 직원 · 묶음 · 메일함을 한 색인에 담은 GIN
  하나로 바꾸고 옛 GIN 둘을 지운다(마이그레이션 SearchIndexUserGramsMailboxes
  — 큰 메일함 15ms · 작은 메일함 1ms · 전체 15ms)
- 한 글자 낱말뿐이면 본문을 찾지 않는데 본문 글자를 강조했다 — 본문 강조는
  본문을 찾은 두 글자 이상 낱말만
- 답장의 결과 미리 보기가 원문 인용 조각이었다 — 인용 줄 밖의 맞은 곳을
  먼저, 인용 안에서만 맞고 다른 칸에서도 맞았으면 본문 첫머리, 인용 조각은
  줄 앞 `>` 를 뗀다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 분할 보기에서 대화 줄을 고르면 도구 줄이 두 줄로 넘치던 것, 목록 머리의
  안 읽은 수가 1440 에서도 늘 잘리던 것
- 묶은 줄에 한 일의 알림은 "{메일함}에 있는 메일 N통을 …"(줄의 통 수는
  대화 전체라서), 되돌리기 알림은 되돌린 메일함을 말한다
- 같은 대화 칸: 펼친 줄의 첨부 수 안내, 우리 원문 인용 머리부터 뺀 미리
  보기, 보내기를 기다리는 메일은 줄 끝에
- 검색 준비 중 · 결과 없음 · 하루짜리 기간 · 내 이름 조건의 글, 예약 시각의
  띄어쓰기, 이름 맞추기(대화로 묶기 · 내용에 든 말 · 표 머리 참여자)
- 다른 탭 · PC 에서 바꾼 설정을 탭으로 돌아올 때 다시 받는다(60초 지났을 때)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 2절에 본문 강조 · 미리 보기 조각 고르기 · 같은 대화 줄 차례 · 설정
다시 받기, 디자인 README 14단계 보완 절에 바뀐 글 · 크기 · 알림과 알려진
한계, nestjs 스킬에 찾기 GIN 하나 규칙, vue3 스킬에 새 도우미를 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 대화로 묶은 휴지통에서 줄을 골라 영구 삭제하면 확인 창 · 알림을
  "휴지통에 있는 메일 N통을 …"으로 쓴다 — 줄의 통 수는 대화 전체라서
  (옮기기 알림 "{메일함}에 있는 메일 N통을 …"과 같은 결)
- 값을 끼워 넣는 글의 띄어 쓴 조사를 붙인다(9단계 "@도메인에서",
  11단계 "25MB를" · "2GB까지" · "20MB가"), 공개 내려받기 화면의 신고
  주소는 끝 글자로 조사를 고른다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 사용자가 사용자 관점 점검의 보드 바꾸기 제안을 추천대로 정했다.
- 같은 대화 칸: 안 읽은 줄을 목록 줄처럼 굵게 그리고 화면 낭독에 "안 읽음, "을
  붙인다. 이 탭이 바꾼 읽음(펼침 · 읽기 화면 · 목록 · 여러 통 처리 · 모두 읽음)은
  줄에 바로 적고, 받는 동안 바꾼 것은 받은 값보다 앞세운다. 읽음만 바뀐 소식으로는
  다시 받지 않는다(메일을 열 때마다 다시 받게 되므로).
- 여러 통 처리가 실패하면 바꾼 표시만 되돌린다(중요 표시 실패가 읽음을 덮지 않게).
- 조건 패널의 [조건 지우기]는 칸만 비우고 패널을 열어 둔다(찾지 않는다). 조건만
  걸린 검색에서 비운 채 [찾기]를 누르면 검색을 닫는다.
- 검색 결과 미리 보기 줄의 강조는 글자를 --color-text-secondary 로 진하게 한다
  (노랑 위 대비 4.05:1 → 6.3:1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
같은 대화 칸의 안 읽은 줄, 패널 [조건 지우기], 미리 보기 강조의 글자색을 "14단계
구현하며 정한 보완"에 적는다. 긴 대화의 [모두 펼치기] · 펼친 줄 머리의 [답장]은
미루기로 해 "처음 판에서 미룬 것"에 둔다(2026-11-07 사용자).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은
글을 고친다(규칙 9 · 10 — 화면 설명 · 번역투 빼기). "일시적인 시스템 오류가
발생했습니다" · "요청하신 정보나 페이지를 찾을 수 없습니다" · "허용 범위를 초과했습니다"
같은 안내문 말투를 무엇이 안 됐고 무엇을 하면 되는지로 바꾼다.
적용하며 셋을 더 다듬었다: REQ_001 은 가장 자주 보는 로그인 · 코드 실패 한도와 진행
중인 일에 함께 맞게, MAIL_003 은 용량이 휴지통도 세므로 휴지통 비우기까지, FILE_001
은 전달하는 원본 첨부에도 맞게. 코드 · 상태 · 뜻은 그대로이고, 웹 · 앱 사전은 sync 로
다시 만들었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
검색 결과 없음 · 주소록 찾기 글에 이/가 · 으로/로 조사를 붙이게 되면서, 초성
검색어(‘ㄱㅁ’)나 숫자로 끝나는 말이 "(으)로" · "이(가)" 꼴로 나왔다. 호환 자모
자음은 이름(기역 · 리을 …)대로 받침 있음(ㄹ 은 "로"), 모음은 받침 없음, 숫자는 읽는
소리(영 · 일 · 이 …)로 고른다. 라틴 글자 · 기호로 끝나면 지금처럼 괄호 꼴이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은
글을 고친다(규칙 9 · 10). "여기에 표시됩니다 · 보입니다 · 나타납니다"처럼 화면을
주어로 한 설명과 "맞는 메일 · 분류된 · 지원하지 않습니다 · 처리" 같은 번역투 · 설계
말을, 내 메일이 어떻게 되는지와 무엇을 하면 되는지로 바꾼다. 정할 것 여섯은 추천안
(읽기 칸이 비었을 때 "고른 메일이 없습니다", 필터 칸 이름 [받은편지함에 이미 있는
메일도 정리하기], 본문 안내에 원문 내려받기까지, 로그인 소개 글)이다. 같은 대화
칸의 펼친 줄은 본문 안내의 앞 문장만 둔다(그 칸의 ⋯ 는 위에 연 메일의 것이다).
사전 글을 견주던 시험은 사전을 참조하게 바꿨다. 시험 2738 → 2762.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
디자인 README 「화면 문구」에 규칙 9(화면이 하는 일을 설명하지 않는다) · 10(번역투 ·
설계 말을 쓰지 않는다)과 11-07 적용 줄을 더하고, 옮겨 적은 화면 글 · 오류 사전 글을
새 글로 맞춘다(계약 · vue3 스킬 · 에러 코드 규칙 표 포함). 디자인 보드의 글은
그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 새 메일 알림(푸시)의 서버 일을 앱 세션과 14단계 위에서 하려고 합친다.
겹친 곳은 두 곳이다. auth.module.ts 는 import 둘을 함께 두고,
AuthService.login() 은 enrollAfterLogin(14단계)을 남긴 뒤 recordSignIn(앱 로그인)으로 기록한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 새 메일 알림(푸시)도 14단계 검색 색인처럼 서버 전체 동시 수와 직원마다 한 번에 하나를 쓴다.
푸시 모듈이 mail-search 를 가져오지 않게 ConcurrencyGate 를 shared/utils 로 옮기고,
직원마다 차례를 KeyedQueue 로 뽑아 MailSearchRuns 가 상속하게 했다. 14단계 동작은 같다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 첫 판의 새 메일 알림을 서버가 보낸다. 알림 글은 "새 메일 N통"뿐이라 보낸 사람 · 제목 · 메일 id 는 Google 을 거치지 않는다.

- 기기 등록: PUT · DELETE /api/push-registrations/current, 봤음 DELETE /api/push-registrations/current/unseen.
  앱 토큰만 받고(AppClientGuard — 웹 세션은 AUTH_003), 세션마다 한 줄, 같은 FID 는 한 줄만 둔다
  (같은 휴대폰에 다른 직원이 로그인하면 앞 직원의 줄을 지운다). 마이그레이션 AddPushRegistrations.
- 새 메일 알아채기: 5단계 엔진 연결을 푸시를 켠 직원에게 붙잡아 둔다(MailEventsService.hold).
  웹 실시간 알림과 같은 판정이고, 같은 직원은 30초에 한 번까지, N 은 보낼 때 엔진에서 센다(웹에서 읽은 메일은 빠진다).
  Stalwart 의 Email/query after 는 그 초를 넣지 않아 어댑터가 한 초 앞을 넘긴다(dev 엔진에서 확인).
- 보내기는 새 의존성 없이 FCM HTTP v1 의 fid 대상으로(RS256 JWT · OAuth 토큰 기억 · 다시 하기 · 보내기 직전 다시 보기).
- 직원이 그 휴대폰을 건드리지 않았는데 앱 세션이 끝나면(저절로 끝남 · 다른 곳에서 비밀번호 바꿈 등)
  그 기기에 "새 메일 알림이 멈췄습니다"를 한 번 보낸다(SESSION_ENDED_NOTICE).
- GET /api/app-config(로그인 없음 · no-store) — 최소 판과 알림을 쓸 수 있는지.
- 서비스 계정 키 FCM_SERVICE_ACCOUNT_KEY(backend env — 없으면 기동하고 알림만 끈다), 퇴사 마무리에 알림 등록을 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 1절 끝의 옛 메모(/api/push-devices · { minVersion, latestVersion, storeUrl })를 확정한 경로 · 모양 · 규칙으로 바꾸고
(N · 간격 · 따라잡기 · 엔진 after 의 초 경계 · "알림이 멈췄습니다"를 보내는 까닭 표 · 실패 처리 · 실기기로 확인할 것),
"클라이언트 — 앱"에 앱이 실제로 부르는 때를 적었다. 2절 실시간 알림의 붙잡기와 6절 퇴사 마무리 목록도 더했다.
nestjs · docker 스킬과 env · 보안 · 로그 규칙에 FCM 키 자리 · 바깥 연결 · 가릴 값을 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
엔진 연결을 붙잡자마자 메일이 오면 따라잡기와 그 메일의 새 메일 신호가 함께 보내 알림이 두 번 갔다(커밋 전 다시 리뷰 N1).
붙잡은 뒤 신호를 이미 받았으면 따라잡기를 건너뛰고, 따라잡기 뒤 간격 끝의 보내기는 따라잡기가 센 뒤에 받은 메일이 있을 때만 보낸다
(기록 저장이 실패해도 겹치지 않게 메모리로 본다). 옛 주석의 "로그인이 저절로 끝남"을 끝난 까닭 표로 고치고 계약 · 스킬의 문장도 맞췄다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stalwart 0.16.27 의 Email/query after 는 그 초를 넣지 않는다(2026-11-08 개발 엔진에서 확인 — RFC 8621 은 "같거나 뒤").
그래서 색인이 준비되기 전에 쓰는 엔진 길의 조건 검색이 계약의 "after 이상"보다 1초 좁았다(시작 날 0시 정각에 받은 메일이 빠짐).
시각을 초로 올린 뒤 한 초 앞을 넘겨 색인 길(>=)과 같은 경계로 맞춘다(toJmapAfterInclusive).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 갤럭시 S23(Android 16)에서 FCM 보내기 · 표시 · 같은 tag 바꿔 쓰기 · 닫힌 앱 깨우기 · 누르면 받은편지함을 확인했다.
앱이 고친 두 가지(매니페스트의 firebase_messaging_installation_id_enabled · 보는 중 받은 알림 뒤의 봤음 1.5초)를
"클라이언트 — 앱"에, 같은 초를 세는 N 의 곁 효과를 N 줄에, 옛 Google Play 서비스 휴대폰의 등록 한계를 알려진 한계에 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 갤럭시 S23 에서 사용자가 notification_count 의 홈 화면 배지를 확인했다 — 아직 보지 않은 것에서 뺐다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 갤럭시 S23 에서 사용자가 확인했다. 남은 확인(절전 중 도착 · 다른 곳 비밀번호 바꿈의 세션 끝 알림)은 사용자가 나중에 본다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: main 을 14단계 검색 브랜치에 합친다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 15m8s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m50s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m55s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m6s
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
9bfa53c03a
#16(백엔드 의존성 넷 · 유예 표)이 갈래점 ba80956 뒤에 main 에 들어갔다 — 이 PR 이 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다. 충돌 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: 14단계 검색 브랜치(main 을 합친 판)를 문구 다듬기 브랜치에 합친다
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m25s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m42s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m58s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m34s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m34s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
c1d0808764
14단계 검색 브랜치에 main(#16 — 백엔드 의존성 넷 · 유예 표)을 합친 판을 받는다.
이 PR 이 14단계 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다. 충돌 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: 문구 다듬기 브랜치(14단계 · main 을 합친 판)를 앱 서버 브랜치에 합친다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m47s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m26s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m42s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m20s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m4s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
644c6e9424
이 PR 이 문구 다듬기 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
충돌 넷은 모두 설정 › 로그인된 기기의 글이다 — 앱 쪽은 앱 세션이 함께 보여 "브라우저"를 "기기"로,
다듬기 쪽은 같은 안내 문장을 규칙 9 로("여기에 보이지 않습니다" → "이 목록에 없습니다") 바꿨다.
뜻은 앱 쪽 · 문장은 다듬은 글로 풀었고(account-security.ts · LoginSessionList.vue 주석 · 디자인 README),
시험은 두 쪽이 더한 상수를 모두 남기고 글자 그대로 견주던 줄을 상수로 바꿨다(AccountLoginHistoryPage.spec.ts).
합친 뒤 backend tsc · lint 0 · 단위 3,701 · e2e 333, frontend type-check · lint 0 · 단위 2,774 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ttipo merged commit 644c6e9424 into main 2026-11-08 15:47:30 +09:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ttipo/arasystem#20