diff --git a/.issueops/OPEN_API_SPEC.md b/.issueops/OPEN_API_SPEC.md index 114278c..21d73dc 100644 --- a/.issueops/OPEN_API_SPEC.md +++ b/.issueops/OPEN_API_SPEC.md @@ -10,7 +10,7 @@ description: Endpoint, DTO, and OpenAPI documentation gate rules. Galpi has **no HTTP API and no OpenAPI/Swagger surface**. The contract surfaces that do exist: -- 24 Tauri IPC commands in `src-tauri/src/adapters/inbound/tauri.rs`, +- 28 Tauri IPC commands in `src-tauri/src/adapters/inbound/tauri.rs`, consumed through `BackendPort` with Zod-validated responses (`src/adapters/tauri-backend.ts`). - Versioned JSONL worker protocol v1 (`worker/galpi_worker/protocol.py` ↔ diff --git a/.issueops/adr/2026-10-09-auth-gateway-google.md b/.issueops/adr/2026-10-09-auth-gateway-google.md new file mode 100644 index 0000000..cdfff44 --- /dev/null +++ b/.issueops/adr/2026-10-09-auth-gateway-google.md @@ -0,0 +1,14 @@ +--- +name: 2026-10-09-auth-gateway-google +description: Accepted decision record with rationale, alternatives, and consequences. +--- + +# 앱 사용에 auth-gateway Google 로그인을 요구하고 호스트가 기능 명령을 막는다 + +- Date: 2026-10-09 +- Kind: `adr` +- Source: issueops-docs io-21fd3b1e1f06 (issue #10); src-tauri/src/application/gateway.rs, src-tauri/src/adapters/outbound/gateway/mod.rs, src-tauri/src/adapters/outbound/browser_sign_in/; cargo test --all-targets gateway (24 passed) +- Summary: Galpi는 auth-gateway의 데스크톱 로그인(RFC 8252: 시스템 브라우저, 루프백 리다이렉트, PKCE)으로 Google 로그인을 마쳐야 쓸 수 있다. Rust Application이 기능 명령 5개(prepare·refine_transcript·transcribe·import_transcript·start_recording)의 첫 줄에서 AUTH_REQUIRED로 막고, 프런트는 inert 셸 앞의 로그인 화면만 보여 준다. +- Context: galpi#10 사용자 결정: auth-gateway에 데스크톱 로그인 흐름을 추가(m16khb-org/auth-gateway#8)하고, 로그인한 모든 Google 계정을 허용하며, 저장된 세션이 있으면 오프라인에서도 쓴다. 기존 ChatGPT 로그인 어댑터가 브라우저·루프백·PKCE를 이미 갖고 있었다. +- Decision: 1) application/gateway.rs AppAccessGate가 메모리 접근 상태를 소유하고, 시작 때 refresh 1회로 판정한다: 성공은 회전 토큰 저장 후 열기, gateway의 400 invalid_grant만 세션 삭제 후 닫기, 그 밖의 실패(연결·5xx·429·형식 오류)는 저장 세션으로 오프라인 열기. 2) 로그아웃은 메모리 상태를 먼저 닫고 POST /auth/native/logout(이 기기 세션만)을 시도한 뒤 결과와 관계없이 로컬 세션을 지운다. 3) 세션은 새 Secret::GatewaySession(Windows Credential Manager com.m16khb.galpi:gateway-session, macOS 0600 settings.json)에, 이메일은 settings.json gatewayEmail에 둔다. 4) ChatGPT와 gateway 로그인이 함께 쓰는 브라우저·루프백·PKCE는 adapters/outbound/browser_sign_in/로 옮기고 오류 코드는 SignInCodes로 호출자가 넘긴다. 5) gateway 주소는 https://auth.m16khb.dev이며 디버그 빌드에서만 GALPI_AUTH_GATEWAY_URL로 바꾼다. +- Consequences: 업데이트 뒤 첫 실행은 로그인 전까지 기능을 쓸 수 없고, 저장 세션이 없는 오프라인 첫 실행은 쓸 수 없다. 새 기능 명령을 추가하면 Application 메서드 첫 줄에 self.access.require()?를 넣어야 한다. 진행 중 작업을 끝내는 명령(stop/cancel)과 설정·진단은 막지 않는다. macOS에서는 refresh token이 서명 전까지 0600 평문 파일에 남는다. diff --git a/.issueops/architecture/overview.md b/.issueops/architecture/overview.md index 47a4590..47ba580 100644 --- a/.issueops/architecture/overview.md +++ b/.issueops/architecture/overview.md @@ -18,7 +18,7 @@ rule, with DDD tactical patterns — applied consistently across three runtimes: ```text TypeScript (WebView) Rust (Tauri host) Python (WhisperX sidecar) ui/ outer adapters/inbound/tauri.rs __main__.py outer -application/ application/ (13 ports) engine/refine use cases +application/ application/ (15 ports) engine/refine use cases domain/ inner domain/ (inner) protocol.py (port: stdout) adapters/tauri-backend composition.rs (root) domain pure modules ``` @@ -32,10 +32,12 @@ adapters/tauri-backend composition.rs (root) domain pure modules - DDD tactical: value objects (`SpeakerHint`, `Participant`, `GlossaryEntry`, `AssistantSettings`), aggregate root `Artifacts::path_for(kind)`, domain services as pure functions, conceptual in-memory `JobRegistry`. -- Inbound adapters: `tauri.rs` (24 `#[tauri::command]`s incl. `save_compute_device`, + event bridge), +- Inbound adapters: `tauri.rs` (28 `#[tauri::command]`s incl. `save_compute_device`, + event bridge), `tauri-backend.ts` (Zod boundary), worker `__main__.py`. - Outbound adapters: `process.rs` (worker supervision), `NativeRecorder` - (CPAL), settings/paths adapters, worker `EventWriter` (stdout JSONL v1). + (CPAL), settings/paths adapters, `ChatGptOAuthAdapter` and + `GatewayOAuthAdapter` over the shared `browser_sign_in/` loopback and PKCE, + worker `EventWriter` (stdout JSONL v1). - Platform branching lives only in outbound adapters and `composition.rs`: outbound code branches on `Os` (`platform.rs`, `Os::current()` is the single `cfg!(windows)`); `#[cfg]` is reserved for code that cannot compile elsewhere diff --git a/.issueops/cautions/2026-10-09-new-stored-credential-needs-four-match-arms-and-is-empty.md b/.issueops/cautions/2026-10-09-new-stored-credential-needs-four-match-arms-and-is-empty.md new file mode 100644 index 0000000..4ff2eda --- /dev/null +++ b/.issueops/cautions/2026-10-09-new-stored-credential-needs-four-match-arms-and-is-empty.md @@ -0,0 +1,13 @@ +--- +name: 2026-10-09-new-stored-credential-needs-four-match-arms-and-is-empty +description: Caution record for a solved false case or recurring risk. +--- + +# 새 Secret 값을 추가하면 LocalSettingsStore의 네 match 분기와 is_empty를 함께 고친다 + +- Date: 2026-10-09 +- Kind: `caution` +- Source: issueops-docs io-21fd3b1e1f06 (issue #10); src-tauri/src/adapters/outbound/settings.rs, src-tauri/src/adapters/outbound/settings/gateway.rs +- Summary: macOS SettingsFile 백엔드는 read가 항상 없음, write가 no-op이고 실제 값은 LocalSettingsStore가 LocalSettings 필드에 직접 쓴다. 새 Secret 변형은 secret()·store_secret()·note_secret_present()·secret_stored()의 match 분기와 LocalSettings 필드, is_empty()를 모두 갱신해야 재시작 뒤에도 남는다. +- Context: galpi#10 계획 1차 리뷰가 찾았다: is_empty()는 필드를 손으로 나열하므로 새 필드를 빠뜨리면 다른 설정이 없는 새 설치에서 로그인 직후 store_settings가 settings.json을 지워 세션이 사라진다. match는 컴파일러가 잡지만 is_empty는 잡지 못한다. +- Resolution: Secret::GatewaySession 추가 때 네 분기와 gateway_email·gateway_session·gateway_session_stored를 is_empty에 넣었고, settings/gateway.rs 테스트가 빈 설정에서 SettingsFile 백엔드로 저장한 세션이 새 store에서 다시 읽히는지와 clear 뒤 파일이 지워지는지를 고정한다. diff --git a/.issueops/cautions/2026-10-09-qa-ipc.md b/.issueops/cautions/2026-10-09-qa-ipc.md new file mode 100644 index 0000000..5f94515 --- /dev/null +++ b/.issueops/cautions/2026-10-09-qa-ipc.md @@ -0,0 +1,13 @@ +--- +name: 2026-10-09-qa-ipc +description: Caution record for a solved false case or recurring risk. +--- + +# 네이티브 런타임 연결 실패는 이제 로그인 화면에 표시되고, 브라우저 QA는 IPC를 가짜로 주입해 확인한다 + +- Date: 2026-10-09 +- Kind: `caution` +- Source: issueops-docs io-21fd3b1e1f06 (issue #10); src/ui/controller.ts, src/ui/access-screen.ts, src/ui/controller.test.ts +- Summary: Google 로그인 필수화 뒤 controller.start()는 이벤트 구독이 실패하면 #app-error 배너 대신 로그인 화면(#access-status)에 재실행 안내를 띄우고 로그인·다시 확인 버튼을 숨긴다. 평범한 브라우저에서는 셸이 inert라 작업 화면을 볼 수 없으므로, 화면 확인은 window.__TAURI_INTERNALS__(invoke·transformCallback)와 __TAURI_EVENT_PLUGIN_INTERNALS__를 init script로 가짜로 넣어 진행한다. +- Context: 2026-08-23 caution은 런타임 연결 실패를 앱 배너로 알린다고 적었다. galpi#10에서 셸은 로그인 전 inert이므로 배너가 보이지 않는다. 또한 app-template에 inert를 정적으로 넣으면 템플릿만 쓰는 happy-dom 테스트(token-guide 포커스 복귀, token-settings)가 포커스를 잃거나 멈춘다. +- Resolution: inert는 AccessScreenView.render가 toggleAttribute로만 건다. 런타임 실패 경로는 controller.test.ts가 로그인 화면 문구·숨은 버튼·signInToGateway 0회로 고정한다. 화면 QA는 Vite 개발 서버 + 가짜 __TAURI_INTERNALS__로 로그인 전·대기·오프라인·로그아웃을 확인했다. diff --git a/.issueops/conventions/overview.md b/.issueops/conventions/overview.md index 217974a..445368d 100644 --- a/.issueops/conventions/overview.md +++ b/.issueops/conventions/overview.md @@ -39,7 +39,7 @@ and `docs/ARCHITECTURE.md`; those win over this overview. ## SOLID as practiced (docs/ARCHITECTURE.md SOLID section) -- Ports exist only at real boundaries (13 Rust traits in +- Ports exist only at real boundaries (15 Rust traits in `application/ports.rs`, TS `BackendPort`), each with a production adapter and a test fake. - Extend by tagged union + exhaustive `match`/`switch`; never absorb new diff --git a/.issueops/issues/10/gates.md b/.issueops/issues/10/gates.md new file mode 100644 index 0000000..3dc8764 --- /dev/null +++ b/.issueops/issues/10/gates.md @@ -0,0 +1,46 @@ +# Gates: 10 + +- [x] G1: 공유 모듈 이동 뒤 ChatGPT 로그인 테스트가 그대로 통과한다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets chatgpt + EXPECT: /test result: ok\. [1-9]\d* passed/ + EVIDENCE: Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) | Running unittests src/main.rs (src-tauri/target/debug/deps/galpi-3a6bea8a51165c32) +- [x] G2: 접근 게이트 application 테스트 (a)~(i) + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets gateway + EXPECT: /test result: ok\. [1-9]\d* passed/ + EVIDENCE: Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) | Running unittests src/main.rs (src-tauri/target/debug/deps/galpi-3a6bea8a51165c32) +- [x] G3: gateway 어댑터·설정 store 테스트 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets adapters::outbound + EXPECT: /test result: ok\. [1-9]\d* passed/ + EVIDENCE: Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) | Running unittests src/main.rs (src-tauri/target/debug/deps/galpi-3a6bea8a51165c32) +- [x] G4: Rust 서식 + CHECK: python3 -c 'import subprocess,sys;r=subprocess.run(["cargo", "fmt", "--manifest-path", "src-tauri/Cargo.toml", "--check"]);print("FMT_OK" if r.returncode==0 else "FMT_FAIL");sys.exit(r.returncode)' + EXPECT: FMT_OK + EVIDENCE: FMT_OK +- [x] G4b: Clippy + CHECK: python3 -c 'import subprocess,sys;r=subprocess.run(["cargo", "clippy", "--manifest-path", "src-tauri/Cargo.toml", "--all-targets", "--", "-D", "warnings"]);print("CLIPPY_OK" if r.returncode==0 else "CLIPPY_FAIL");sys.exit(r.returncode)' + EXPECT: CLIPPY_OK + EVIDENCE: Compiling galpi v0.1.0 (/Users/m16khb/Workspace/galpi.worktrees/10-require-gateway-google-login/src-tauri) | Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.09s +- [x] G4c: Rust 전체 테스트 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets + EXPECT: /test result: ok\. [1-9]\d* passed/ + EVIDENCE: Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) | Running unittests src/main.rs (src-tauri/target/debug/deps/galpi-3a6bea8a51165c32) +- [x] G5: 아키텍처 검사·Biome·tsc + CHECK: python3 -c 'import subprocess,sys;r=subprocess.run(["bun", "run", "check"]);print("CHECK_OK" if r.returncode==0 else "CHECK_FAIL");sys.exit(r.returncode)' + EXPECT: CHECK_OK + EVIDENCE: 138 138 │ } | 139 139 │ +- [x] G5b: 프런트 reducer·DOM·Zod 테스트 + CHECK: bun test + EXPECT: /(?m)^\s*0 fail$/ + EVIDENCE: 549 expect() calls | Ran 201 tests across 27 files. [853.00ms] +- [x] G6: 문서 플랫폼 문구 검사 + CHECK: python3 scripts/verify-docs-platform.py + EXPECT: PLATFORM_DOCS_OK + EVIDENCE: PLATFORM_DOCS_OK +- [x] G7: 명령 28·포트 15와 문서 숫자 일치 + CHECK: python3 -c 'import re,sys,pathlib as P;t=P.Path("src-tauri/src/adapters/inbound/tauri.rs").read_text().count("#[tauri::command]");q=len(re.findall(r"^pub trait",P.Path("src-tauri/src/application/ports.rs").read_text(),re.M));pats=["24 frontend","24 command","24 IPC","24 Tauri","24 커맨드","24개","24 `#[tauri","13 Rust traits","13 ports","13개 포트","13개 trait","포트 13개"];fs=["AGENTS.md","src-tauri/AGENTS.md","docs/ARCHITECTURE.md",".issueops/conventions/overview.md",".issueops/architecture/overview.md",".issueops/OPEN_API_SPEC.md"];left=[(f,x) for f in fs for x in pats if x in P.Path(f).read_text()];ok=t==28 and q==15 and not left;print("COUNTS_OK" if ok else ("COUNTS_FAIL",t,q,left));sys.exit(0 if ok else 1)' + EXPECT: COUNTS_OK + EVIDENCE: COUNTS_OK +- [x] G8: IPC로 나가는 AppAccess JSON에 토큰이 없다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets app_access_serializes_only_state_and_email + EXPECT: /test result: ok\. 1 passed/ + EVIDENCE: Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) | Running unittests src/main.rs (src-tauri/target/debug/deps/galpi-3a6bea8a51165c32) diff --git a/.issueops/issues/10/intent.md b/.issueops/issues/10/intent.md new file mode 100644 index 0000000..31915a1 --- /dev/null +++ b/.issueops/issues/10/intent.md @@ -0,0 +1,26 @@ +# 요청자 의도 계약 + +- lifecycle: io-21fd3b1e1f06 +- issue: https://github.com/m16khb-org/galpi/issues/10 +- intent_class: standard + +## 원문 요청 +galpi를 사용하려면 auth-gateway를 통해 google 로그인을 해야되도록 하려고해 로 진행하자 / (연동 방식 질문 답) 첫번째방법이 일반적으로 사용되는 방법이면 그대로 해줘 / (사용 허용 범위 답) 로그인한 모든 Google 계정 / (오프라인 동작 답) 저장된 세션이 있으면 오프라인에서도 사용 + +## 해석 +Galpi(macOS·Windows 데스크톱)를 쓰려면 auth-gateway를 거친 Google 로그인이 필요하도록 한다. 로그인은 RFC 8252 방식(시스템 브라우저, 루프백 리다이렉트, PKCE)으로 하고, auth-gateway에 새로 추가될 데스크톱 로그인 흐름(일회용 코드 교환, 쿠키 없는 refresh·logout)을 사용한다. 로그인한 모든 Google 계정을 허용하며 역할·도메인 검사는 하지 않는다. refresh token은 OS 비밀 저장소에 보관하고, gateway에 접속할 수 없을 때는 저장된 세션으로 계속 사용하며, gateway가 세션을 명시적으로 거부할 때만 다시 로그인을 요구한다. 로그아웃은 gateway 폐기 요청 후 로컬 세션을 지운다. 기존 ChatGPT 로그인 구현(시스템 브라우저, 루프백, PKCE, 비밀 저장소)을 재사용한다. auth-gateway 쪽 변경은 m16khb-org/auth-gateway의 별도 사이클이 선행 조건이다. + +## 성공 기준 +- 1) 저장된 Galpi 세션이 없으면 앱은 로그인 화면만 보여 주고, 녹음·전사·가져오기·AI 증강·엔진 준비 Tauri 명령은 AUTH_REQUIRED 오류로 거부한다(Rust application 테스트). 2) Google로 로그인 → 시스템 브라우저 → gateway → 루프백 콜백 → 코드 교환 → 토큰을 OS 비밀 저장소에 저장 → 기능 화면 진입(어댑터 테스트 + 실제 gateway 로그인 smoke). 3) 시작 시 refresh: 성공하면 회전된 토큰 저장, gateway가 invalid_grant로 거부하면 세션 삭제 후 로그인 화면, 네트워크 오류·5xx면 저장된 세션으로 계속 사용(포트 테스트로 세 분기 고정). 4) 로그아웃은 gateway 폐기를 시도하고 로컬 세션을 지운 뒤 로그인 화면으로 돌아간다. 5) 로그인 진행 중 취소와 5분 타임아웃을 지원한다. 6) bun run check, bun test, cargo fmt/clippy/test, 워커 게이트 통과. + +## 비목표 +- (없음) + +## 제약 +- (없음) + +## 모호함 +- (없음) + +## 읽는 규칙 +이 문서는 요청자 의도 계약이다. 원격 이슈 본문은 구현 계약이다. 두 문서가 충돌하면 구현을 시작하지 말고 충돌한 줄을 인용해 blocker로 보고한다. diff --git a/.issueops/issues/10/plan-review.md b/.issueops/issues/10/plan-review.md new file mode 100644 index 0000000..c8bff82 --- /dev/null +++ b/.issueops/issues/10/plan-review.md @@ -0,0 +1,23 @@ +# 계획 검토 기록 + +## 1차: 수정 요청 + +- 1차 리뷰(독립 reviewer 서브에이전트, anthropic/claude-opus-5-5 high)는 수정 요청을 냈다. 설정 파일 is_empty와 macOS 설정 파일 비밀 경로를 빠뜨려 재시작 후 세션이 사라질 수 있고, domain이 AppError에 의존하며, refresh 거부 판정이 invalid_grant보다 넓고, 접근 상태 확인·로그아웃 실패 전이가 없고, gateway가 Supabase 장애를 invalid_grant로 돌려준다는 5건과 문서 목록 누락 1건이다. 이 기록은 반영한 개정본에 묶여 있다. + +## 2차: 수정 요청 + +- 2차 delta 리뷰(독립 reviewer 서브에이전트, anthropic/claude-opus-5-5 high)는 1차 결함 1·2·3·5가 해소됐다고 확인했다. 네이티브 구독 실패 경로에서 로그인 화면이 확인 중에 멈추는 문제와 G7 정규식이 한국어 포트 수 표기를 놓치는 문제 2건으로 수정을 요청했다. + +## 3차: 수정 요청 + +- 3차 delta 리뷰(상향 모델 anthropic/claude-opus-5-5 xhigh, omp -p 빈 컨텍스트)는 2차 지적 두 건이 해소됐다고 확인했다. 런타임 연결 실패 상태의 로그인 버튼 표시 규칙이 계획 안에서 서로 다르고, G7이 .issueops/OPEN_API_SPEC.md의 IPC 명령 수를 놓친다는 2건으로 수정을 요청했다. + +## 4차: 수정 요청 + +- 4차 delta 리뷰(상향 모델 anthropic/claude-opus-5-5 xhigh)는 3차 결함 두 건이 해소됐다고 확인했다. 로그인 버튼 규칙이 진행 중 상태(signing-in·signing-out)를 제외하지 않아 전이표와 어긋나고, 로그아웃이 진행 중인 로그인을 먼저 취소하지 않아 상태 경쟁이 생길 수 있다는 1건으로 수정을 요청했다. + +## 5차: 수정 요청 + +생략: 리뷰 5라운드 상한에 도달했다. 마지막 결함은 리뷰어가 제시한 최소 수정 그대로 반영했다: cancel_sign_in은 ChatGPT(sign_in.rs:48-65)처럼 oneshot 신호만 보내고 상태·저장소를 바꾸지 않으며, 상태 변경·revoke·clear는 sign_out에만 둔다. 이를 고정하는 application 테스트 (i)(저장 세션이 있는 상태에서 로그인 취소 → revoke 0회, 세션 유지, CANCELLED)를 G2에 추가했다. 같은 개정에서 게이트를 gates-ledger 형식(argv 명령 하나, 셸 연결자 없음)으로 바꾸고 G7 래퍼를 구현 전 저장소에서 실행해 COUNTS_FAIL(24, 13)로 판정되는 것을 확인했다. + +- 5차 delta 리뷰(상향 모델 anthropic/claude-opus-5-5 xhigh)는 4차 결함(버튼 허용 목록, 로그아웃 전 로그인 취소)이 해소됐다고 확인했다. 남은 결함은 cancel_sign_in이 저장 세션을 지우는 동작으로 정의된 모순 하나다. diff --git a/.issueops/issues/10/plan.md b/.issueops/issues/10/plan.md new file mode 100644 index 0000000..425d78a --- /dev/null +++ b/.issueops/issues/10/plan.md @@ -0,0 +1,167 @@ +# 계획: auth-gateway Google 로그인이 있어야 Galpi를 사용 (galpi#10) + +- Lifecycle ID: `io-21fd3b1e1f06` +- 이슈: https://github.com/m16khb-org/galpi/issues/10 +- 브랜치: `10-require-gateway-google-login` (base `main` @ `e77c87e3ca551ab121377024cbf6a43b1398dcae`) +- 선행 조건: m16khb-org/auth-gateway#8 (`io-4bf3aa5bab5f`, 브랜치 `8-desktop-native-login`)의 데스크톱 로그인 계약. 단위·application 테스트는 가짜 HTTP 서버로 그 계약을 고정하므로 gateway 머지 전에도 구현·검증할 수 있다. 실제 로그인 스모크(수동 확인 기록)만 gateway 브랜치를 로컬로 띄워야 한다. +- 사용자 요청 범위: 구현, 검증, 커밋, push, draft PR 발행, execution complete. merge는 범위 밖이다. +- 세션: 워크트리 준비 뒤 `issueops` 라우터의 환경별 자동 세션 인계를 적용한다. 인계는 실행 위치만 바꾸며 승인 범위를 바꾸지 않는다. + +## 목표와 비목표 + +목표: 유효한 gateway 세션이 없으면 Galpi의 기능을 쓸 수 없게 한다. 로그인은 시스템 브라우저 + 루프백 + PKCE, 세션은 기존 비밀 저장소에 보관, gateway에 닿지 못하면 저장 세션으로 계속 사용, gateway가 명시적으로 거부하거나 사용자가 로그아웃하면 다시 로그인을 요구한다. + +비목표: 역할·도메인 접근 제어, ChatGPT 로그인 동작 변경(오류 코드 포함), 워커·워커 프로토콜 변경, gateway 쪽 변경. + +## 사용하는 gateway 계약 (auth-gateway#8 계획) + +| 단계 | 요청 | 응답 | +|---|---|---| +| 시작 | 브라우저로 `GET {gateway}/auth/native/google?redirect_uri=http://127.0.0.1:/auth/callback&code_challenge=&code_challenge_method=S256&state=<43자>` | Google 로그인 후 `redirect_uri?code=…&state=…`로 302 | +| 교환 | `POST {gateway}/auth/native/token` JSON `{grant_type:"authorization_code", code, code_verifier, redirect_uri}` | 200 `{access_token, refresh_token, expires_in, token_type}` / 400 `{error}` | +| 갱신 | `POST {gateway}/auth/native/token` JSON `{grant_type:"refresh_token", refresh_token}` | 200 같은 형태 / 400 `invalid_grant`(Supabase가 토큰을 거부했거나 폐기 목록에 있음) / 503 `temporarily_unavailable`(gateway가 Supabase에 닿지 못함) / 429 | +| 이메일 | `GET {gateway}/me` + `Authorization: Bearer` | 200 `{sub, role, email, sessionId}` | +| 로그아웃 | `POST {gateway}/auth/native/logout` JSON `{refresh_token}` | 200 `{status:"ok"}` | + +로그인 시작 뒤 사용자가 Google 화면에서 취소하거나 gateway의 Supabase 교환이 실패하면 gateway는 `redirect_uri?error=access_denied&state=…`로 돌려보낸다(앱은 `GATEWAY_SIGN_IN_FAILED`). 데스크톱 로그아웃은 이 세션만 끊는다(Supabase `scope=local`). 반대로 사용자가 웹 서비스에서 로그아웃하면(global) 다음 갱신이 400 `invalid_grant`가 되어 앱은 다시 로그인을 요구한다. + +`{gateway}`는 `https://auth.m16khb.dev`(gateway `README.md:126`, `docker-compose.yml:51`의 `PUBLIC_URL`)다. 디버그 빌드에서만 `GALPI_AUTH_GATEWAY_URL` 환경 변수로 바꿀 수 있다(로컬 gateway 스모크용). 릴리스 빌드는 상수만 쓴다. + +## 현재 동작 (확인한 사실) + +- ChatGPT 로그인 어댑터가 브라우저 열기·루프백·PKCE를 한 모듈 안에 갖고 있다: `TauriBrowser`(`src-tauri/src/adapters/outbound/chatgpt/browser.rs:20-33`, 오류 코드 `CHATGPT_SIGN_IN_FAILED`), `Loopback::bind_preferring`(선호 포트 실패 시 OS 할당, `loopback.rs:48-64`), `wait`(취소 `CANCELLED`, 시간 초과 `CHATGPT_SIGN_IN_TIMEOUT`, `loopback.rs:71-103`), `random_urlsafe`·`code_challenge`(`pkce.rs:14-28`, 오류 코드 `CHATGPT_SIGN_IN_FAILED`). 다른 모듈은 이것들을 쓰지 않는다(`composition.rs:2,50`이 `TauriBrowser`만 가져온다). +- 프런트엔드는 ChatGPT 오류 코드 일부를 문자열로 비교한다(`src/ui/chatgpt-controller.ts:19`, `src/domain/chatgpt.ts:61`, `src/ui/controller.test.ts:564`). 공유로 옮겨도 ChatGPT 경로의 코드는 그대로여야 한다. +- 비밀 저장소는 저장 대상별 enum 값(비밀 저장 모듈 `adapters/outbound/secrets` 37-55행)으로 구분되며, Windows는 Credential Manager의 `com.m16khb.galpi:`, macOS는 0600 `settings.json`에 저장된다(같은 모듈 1-12행 주석). ChatGPT 토큰은 `LocalSettingsStore`의 비밀 저장 메서드에 ChatGPT 토큰 값을 넘겨 저장한다(`adapters/outbound/settings/chatgpt.rs:28-35,123-161`). +- `Application`은 포트 7개와 `ChatGptAccounts`를 받아 만든다(`src-tauri/src/application/use_cases.rs:36-74`). 기능 진입점은 `prepare`(88), `refine_transcript`(149), `transcribe`(267), `import_transcript`(280), `start_recording`(389)이다. +- 프런트 시작 순서: 컨트롤 바인딩 → 이벤트 구독 → `diagnose` → 설정 로드 → ChatGPT 로드(`src/ui/controller.ts:46-83`). +- 문서가 세는 숫자: IPC 명령 24개(`AGENTS.md`, `docs/ARCHITECTURE.md` §2), 포트 13개(`docs/ARCHITECTURE.md` §4, `.issueops/conventions/overview.md`). + +## 설계 + +### Rust domain — `src-tauri/src/domain/gateway.rs` (신규) + +- `GatewayTokens { access_token, refresh_token, expires_at }` — `Debug`를 직접 구현해 토큰을 가린다(ChatGPT 토큰 타입과 같은 방식). +- `AppAccess` (frontend로 나가는 값): `SignedOut` | `SignedIn { email: Option, offline: bool }`. `offline`은 시작 갱신이 네트워크·5xx로 실패해 저장 세션으로 들어왔음을 뜻한다. +- `GatewayRefreshFailure`: `Rejected` | `Unavailable` — payload 없는 enum. domain은 `crate::application`(=`AppError`)에 의존할 수 없으므로(`scripts/check-architecture.ts`의 domain 울타리) 기존 `RefreshFailure`(`domain/chatgpt.rs:190-197`)처럼 값만 둔다. restore 판정에 오류 내용은 필요 없다. + +### Rust application + +- `ports.rs`에 포트 두 개: + - `GatewayAuthPort`: `sign_in(cancel) -> Result`, `refresh(refresh_token) -> Result`, `revoke(refresh_token)`(best-effort, 실패를 호출자에게 올리지 않음). + - `GatewaySessionStore`: `load_email()`(비밀 아님), `load_tokens()`, `save_session(tokens, email)`, `replace_tokens(tokens)`, `clear()`. +- `application/gateway.rs` (신규) `AppAccessGate`: + - 메모리 상태 `Mutex`(초기값 `SignedOut`). + - `restore()`: 토큰 없음 → `SignedOut`. 있으면 `refresh` 1회: 성공 → `replace_tokens` 후 `SignedIn{offline:false}`, `Rejected` → `clear` 후 `SignedOut`, `Unavailable` → `SignedIn{offline:true}`(저장 이메일 사용). refresh는 tokio `Mutex`로 직렬화해 회전 토큰을 한 번만 쓴다(ChatGPT `refresh_lock` 패턴). + - `sign_in()`: ChatGPT와 같은 단일 슬롯(`AtomicBool` + 취소 `oneshot`)으로 동시 로그인 하나만 허용, 성공 → `save_session` 후 `SignedIn`. + - `sign_out()`은 ChatGPT와 같이(`src-tauri/src/application/chatgpt.rs:80`) 진행 중인 로그인을 먼저 `cancel_sign_in()`으로 취소한 뒤 아래 순서를 따른다. + - `cancel_sign_in()`: ChatGPT와 같이(`src-tauri/src/application/chatgpt/sign_in.rs:48-65`) 대기 중인 로그인에 `oneshot` 신호만 보낸다. 메모리 상태와 저장소는 바꾸지 않으며, 로그인이 끝난 뒤 도착한 취소는 슬롯이 sender를 비워 효과가 없다(`sign_in.rs:17-24`). + - `sign_out()`: 메모리 상태를 먼저 `SignedOut`으로 바꾼 뒤(이후 기능 명령은 즉시 `AUTH_REQUIRED`), 토큰이 있으면 `revoke` 시도 → 결과와 관계없이 `clear`. `clear`가 실패하면 오류를 올리지만 메모리 상태는 `SignedOut`으로 남고 프런트는 `signed-out{error}`로 간다. 다음 실행의 `restore`가 남은 토큰을 다시 쓸 수 있다는 사실을 오류 문구로 알린다. + - `require()`: 상태가 `SignedIn`이 아니면 `AppError::new("AUTH_REQUIRED", "Galpi를 사용하려면 Google로 로그인해 주세요.")`. +- `Application`: 생성자에 `AppAccessGate`를 추가하고, `prepare`·`refine_transcript`·`transcribe`·`import_transcript`·`start_recording` 첫 줄에서 `self.access.require()?`를 호출한다. `stop_recording`·`cancel_recording`·`cancel`은 진행 중인 작업을 끝내는 동작이라 막지 않는다. 설정 저장·진단도 막지 않는다(로그인 화면 뒤에서 호출되지 않으며, 막으면 시작 순서만 복잡해진다). +- 새 use case 메서드: `load_app_access()`(= `restore`), `sign_in_to_gateway()`, `cancel_gateway_sign_in()`, `sign_out_of_gateway()`. + +### Rust adapters + +- 공유 모듈 이동: `chatgpt/browser.rs`·`loopback.rs`·`pkce.rs`를 `adapters/outbound/browser_sign_in/`로 옮긴다. 오류 코드는 호출자가 `SignInCodes { failed: &'static str, timed_out: &'static str }`로 넘긴다. ChatGPT는 기존 `CHATGPT_SIGN_IN_FAILED`·`CHATGPT_SIGN_IN_TIMEOUT`을 그대로 넘기므로 관찰 가능한 동작이 같다. `BrowserOpener::open`은 코드를 인자로 받는다. +- `adapters/outbound/gateway/` (신규) `GatewayOAuthAdapter`: + - `sign_in`: `Loopback::bind_preferring(PREFERRED_PORT)` → verifier(64바이트)·state(32바이트) 생성 → 시작 URL을 시스템 브라우저로 연다 → `wait`(5분, 취소) → `state` 일치 확인(불일치·`error` 파라미터·`code` 없음은 `GATEWAY_SIGN_IN_FAILED`) → 토큰 교환 → `GET /me`로 이메일(실패하면 `None`, 로그인은 성공). 루프백 경로는 `/auth/callback`. + - `refresh`: 200 → 토큰. 상태가 400이고 본문 `error`가 `invalid_grant`일 때만 `Rejected`. 그 밖의 모든 비성공(400 `invalid_request`·본문 없는 400·401·403·429·5xx·연결 실패·파싱 실패)은 `Unavailable` — 이슈 정책 "gateway가 세션을 명시적으로 거부했을 때만 다시 로그인"과 기존 ChatGPT 분류(`chatgpt/oauth.rs:182-188`, 나머지는 `Transient`)를 따른다. + - `revoke`: `POST /auth/native/logout` 1회, 실패는 무시하고 로그만 남기지 않는다(토큰·URL을 로그에 쓰지 않는다). + - HTTP 클라이언트는 ChatGPT와 같은 설정(연결 10초, 전체 30초, 리다이렉트 비활성). +- 비밀 저장 모듈(`adapters/outbound/secrets`): 저장 대상 enum에 `GatewaySession` 값(account `gateway-session`)을 추가한다. Windows는 긴 값 조각 저장(`.issueops/adr/2026-10-09-windows-credential-manager.md`)이 그대로 적용된다. macOS의 설정 파일 백엔드는 read가 항상 없음·write가 no-op이고(`secrets` 모듈 140-160행), 실제 값은 `LocalSettingsStore`의 네 `match` 분기(값 읽기 104-107행, 값 쓰기 143-170행, 존재 표시 176-189행, 존재 확인 205-220행)가 `LocalSettings` 필드에 직접 쓴다. 그래서 네 분기에 `GatewaySession` arm을 모두 추가한다. +- `LocalSettings`(`adapters/outbound/settings.rs:255~`)에 `gateway_session: Option`(macOS 평문, 0600), `gateway_session_stored: bool`, `gateway_email: Option`을 추가하고 `is_empty()`(285-308행, 필드를 손으로 나열)에 세 필드를 넣는다. 빠뜨리면 다른 설정이 없는 새 설치에서 로그인 직후 `store_settings`가 파일을 지운다(430-435행). `settings/gateway.rs` (신규): `GatewaySessionStore for LocalSettingsStore`. 토큰은 기존 비밀 저장 메서드에 `GatewaySession` 값으로 넘기고, 이메일은 `gateway_email`에 둔다. +- `inbound/tauri.rs`: 명령 4개(`load_app_access`, `sign_in_to_gateway`, `cancel_gateway_sign_in`, `sign_out_of_gateway`). 새 이벤트는 만들지 않는다 — `sign_in_to_gateway`가 끝날 때까지 프런트는 "브라우저에서 로그인 중" 상태를 보여 준다. +- `composition.rs`: 어댑터·저장소 배선과 명령 등록. + +### Frontend + +- `src/domain/backend.ts`: `BackendPort`에 네 메서드와 `AppAccess` 타입. `src/adapters/tauri-backend.ts`: Zod 스키마로 파싱. +- `src/application/access-machine.ts` (신규, 순수 reducer + 테스트): `checking` → `signed-out` | `signed-in{email, offline}` | `failed{message, retryable}`; `failed{retryable:true}` → `checking`(다시 시도) | `signing-in`(다시 로그인 — 새 세션이 손상된 저장 항목을 덮어쓴다); `signed-out` → `signing-in` → `signed-in` | `signed-out{error}`; `signed-in` → `signing-out` → `signed-out` | `signed-out{error}`. 버튼 규칙은 허용 목록이다: 로그인 버튼은 `signed-out`·`signed-out{error}`·`failed{retryable:true}`에서만 보인다(ChatGPT의 `signIn.hidden = signedIn || flowActive`, `src/ui/chatgpt-settings.ts:148,151`와 같은 방식). `signing-in`에서는 취소 버튼만, `checking`·`signing-out`·`failed{retryable:false}`에서는 로그인 화면의 버튼을 모두 숨긴다. `failed{retryable:false}`에서 나가는 전이는 없다(재실행만 안내). 셸이 inert·무응답으로 남지 않는다는 기존 원칙(`controller.ts:47-48`)을 지킨다. +- `src/ui/access-controller.ts` (신규): 시작 때 `loadAppAccess`, 로그인·취소·로그아웃 처리, 로그인 화면 표시와 앱 셸 `inert` 전환. +- `src/ui/app-template.ts`·`app-view.ts`·`styles.css`: 로그인 화면 섹션(제목, 설명, `Google로 로그인` 버튼, 진행 중 `취소`, `role="status"` 메시지)과 상단 계정 표시(이메일, 오프라인이면 텍스트로 "오프라인 · 저장된 로그인 사용", `로그아웃` 버튼). 스타일은 SEED 토큰만, 버튼은 `src/ui/seed.ts` recipe. +- `src/ui/controller.ts`: `start()`를 "구독 → 접근 확인 → (로그인돼 있으면) 작업 공간 로드"로 나누고, 구독이 실패하면(`controller.ts:61-65`) access-machine을 `failed{message:"네이티브 런타임에 연결할 수 없습니다. 앱을 다시 실행해 주세요.", retryable:false}`로 보내 로그인 화면 자체에 문구를 보여 준다(셸 배너는 로그인 전 가려지므로). 이 실패에서는 다시 시도·로그인 버튼을 숨기고 재실행을 안내한다 — 구독 없이 재시도하면 작업 이벤트를 잃는다. 로그인 성공 시 작업 공간 로드를 실행, 로그아웃 시 진행 중 작업이 없을 때만 허용하고 로그인 화면으로 돌아간다. 녹음·전사·정제·준비가 진행 중이면 로그아웃 버튼을 비활성화한다. + +### 문서 + +- `README.md`·`README.en.md`: 첫 실행에 Google 로그인 단계, 저장 위치(`com.m16khb.galpi:gateway-session`, macOS `settings.json`), 오프라인 동작, 로그아웃. +- `DESIGN.md`: 로그인 화면·계정 표시 상태와 문구. +- `.issueops/OPEN_API_SPEC.md`(13행 "24 Tauri IPC commands"), `AGENTS.md`·`src-tauri/AGENTS.md`(11행 "all 24 IPC commands")·`src-tauri/src/adapters/outbound/AGENTS.md`(STRUCTURE 표에 `browser_sign_in/`·`gateway/`)·`docs/ARCHITECTURE.md`·`.issueops/conventions/overview.md`·`.issueops/architecture/overview.md`: 명령 24→28, 포트 13→15, gateway 위치 행. + +## 적용되는 결정과 주의사항 + +- `AGENTS.md` — Conventions — 의존성은 안쪽(domain)으로만 향하고, `#[tauri::command]`는 `inbound/tauri.rs`, 배선·`generate_handler!`는 `composition.rs`에만 둔다. +- `docs/ARCHITECTURE.md` §2·§4·§7 — 명령 수 표, 포트 수와 ISP 설명, "새 외부 능력은 port → outbound → composition → FakePort" 순서를 같은 변경에서 갱신한다. +- `scripts/check-architecture.ts` — inbound↔outbound 교차 import 금지, domain·application·inbound의 `cfg(windows)`/`cfg!(` 금지, ui의 `@tauri-apps`·adapters import 금지 — 디버그 전용 gateway URL 분기(`cfg!(debug_assertions)`)는 outbound 어댑터에만 둔다. +- `src-tauri/AGENTS.md` — 취소 가능한 작업은 `oneshot` receiver, `unwrap`/`expect`/`panic` 금지, 테스트는 `application/tests.rs`의 `FakePort` Given/When/Then. +- `src-tauri/src/adapters/outbound/AGENTS.md` — `ChatGptStore`의 저장 구조는 바꾸지 않는다 — gateway 세션은 새 `Secret` 값과 별도 store 구현으로 추가하고 ChatGPT 키·필드는 건드리지 않는다. +- `.issueops/CONSTITUTION.md` — 토큰을 로그·문서·픽스처·IPC 응답에 넣지 않는다 — `AppAccess`에는 이메일·상태만 있고, 테스트 토큰은 `galpi-test-…` 더미 값이다. +- `.issueops/adr/2026-10-09-windows-credential-manager.md` — 2,560바이트를 넘는 비밀은 세대별 조각 저장 — Supabase 세션 토큰도 같은 경로로 저장되므로 별도 처리가 필요 없다. +- `.issueops/adr/2026-10-09-windows-x64-os-cpu-cuda.md` — macOS 비밀은 서명 전까지 0600 설정 파일 — gateway 세션도 같은 정책을 따른다. +- `.issueops/cautions/2026-10-09-windows-env-clear-expect-dead-code.md` — 한 플랫폼에서만 쓰는 항목은 `cfg_attr`로 정확히 조건을 건다 — 새 `Secret` 값의 `account()` 사용처도 기존 `expect(dead_code)` 조건을 따른다. +- `.issueops/cautions/2026-10-09-readme.md` — README 두 벌을 같은 변경에서 고친다. +- `.issueops/conventions/overview.md` — 포트는 실제 경계에만, 각 포트에 프로덕션 어댑터와 테스트 페이크 — 두 포트 모두 `FakePort`에 구현한다. 새 변형은 태그드 유니온과 완전 match. +- `.issueops/testing/overview.md` — `styles.test.ts`(hex·px 리터럴·비 `--seed-` 커스텀 속성 금지), `seed.test.ts`(버튼 recipe), happy-dom 가시성 한계 — 로그인 화면은 실제 앱(`bun run dev`)에서 눈으로 확인한다. Windows 자격 증명 저장은 실기 수동 확인 항목이다. +- `DESIGN.md` — 상태는 색과 텍스트를 함께, 40px 이상 터치 영역, `role="status"`, `prefers-reduced-motion`, `word-break: keep-all`, 창은 토큰을 갖지 않고 계정 상태·이메일만 — 로그인 화면과 계정 표시에 적용한다. +- `DESIGN.md` — Tauri 이벤트는 invoke 전에 구독 — 이번 변경은 새 이벤트를 만들지 않으므로 기존 구독 순서를 유지한다. +- 대조했으나 해당 없음: `.issueops/adr/2026-10-09-7-fluent-korean.md`(회의록 프롬프트), `.issueops/cautions/2026-10-09-gated-token-implicit-token.md`(워커 HF 토큰), `DESIGN.md` §8 Accepted Debt. + +## 재사용하는 기존 구현 + +- `TauriBrowser`·`BrowserOpener`, `Loopback`(선호 포트·OS 할당·취소·5분 시간 초과·완료 페이지), `random_urlsafe`·`code_challenge`(RFC 7636 벡터 테스트 포함) — 공유 모듈로 옮겨 두 로그인 어댑터가 같이 쓴다. 다시 만들지 않는다. +- `ChatGptAccounts`의 패턴(`refresh_lock`으로 회전 토큰 1회 사용, `sign_in_active`·`sign_in_cancel` 단일 슬롯, 로그아웃 시 서버 폐기 후 무조건 로컬 삭제) — `AppAccessGate`가 같은 구조를 따른다. `ChatGptAccounts` 자체는 OpenAI 등록·모델·동의 개념이 섞여 있어 재사용하지 않는다. +- `LocalSettingsStore::store_secret`·`secret`·캐시(`settings.rs:19-37`) — gateway 세션 저장에 그대로 쓴다. +- `SecretStore` 구현(Credential Manager 조각 저장, 설정 파일, 테스트용 `InMemorySecrets`). +- `application/tests.rs`의 `FakePort` — 두 포트를 구현해 application 테스트를 쓴다. +- 프런트: `chatgpt-controller.ts`의 로그인·취소·로그아웃 흐름과 `chatgpt-machine.ts` reducer 형식, `src/ui/seed.ts` 버튼 recipe, `required()` DOM helper. + +새로 만드는 것: gateway HTTP 어댑터(엔드포인트·오류 분류가 OpenAI와 다름), `AppAccessGate`(앱 사용 게이트라는 새 규칙), 로그인 화면. + +## 성능 영향 + +- 시작 시 네트워크 요청이 최대 1회(refresh) 늘어난다. 연결 10초·전체 30초 제한이므로 gateway가 응답하지 않으면 로그인 확인이 최대 30초 걸릴 수 있다. 이 시간 동안 로그인 화면에 "로그인 상태 확인 중"을 보여 준다(시간 추정은 표시하지 않음 — `AGENTS.md`의 ETA 금지). +- `require()`는 메모리 `Mutex` 읽기 한 번이라 기능 진입점에 측정할 만한 비용을 더하지 않는다. +- 로그인 시 HTTP 요청 2회(교환, `/me`), 로그아웃 시 1회. + +## 하위 호환성과 side effect + +- 동작 변경(의도): 업데이트 후 첫 실행에는 로그인하기 전까지 기능을 쓸 수 없다. 오프라인 첫 실행은 로그인할 수 없으므로 사용할 수 없다(사용자 결정 범위 안: 저장된 세션이 있을 때만 오프라인 허용). +- ChatGPT 로그인: 공유 모듈 이동 뒤에도 오류 코드·완료 페이지·포트 동작이 같다. 기존 ChatGPT 어댑터 테스트(`chatgpt/tests/`)를 수정 없이(import 경로 제외) 통과시켜 증명한다. +- 저장 데이터: `settings.json`에 `gatewayEmail`, `gatewaySessionStored`가, macOS에서는 `gatewaySession`(토큰 JSON 평문, 파일 권한 0600 — HF 토큰·ChatGPT 토큰과 같은 정책)이 추가된다. 구버전은 모르는 키를 무시한다(`LocalSettings`에 `deny_unknown_fields` 없음 — `settings.rs:252-255` 정의 확인). Windows는 Credential Manager에 `com.m16khb.galpi:gateway-session` 항목이 생긴다. 로그아웃하면 둘 다 지운다. +- IPC: 명령 4개 추가, 기존 명령의 인자·응답은 그대로다. 단 기능 명령 5개가 로그인 전에는 `AUTH_REQUIRED`로 실패한다. +- 롤백: 커밋 되돌리기. 남는 것은 설정 파일의 `gatewayEmail`·`gatewaySessionStored`, macOS의 `gatewaySession`(평문 refresh token, 0600), Windows Credential Manager 항목이다. 구버전은 읽지 않지만 macOS 평문 refresh token은 사용자가 지우기 전까지 남으므로 릴리스 노트에 적는다. + +## 구현 순서 (RED → GREEN) + +1. 공유 모듈 이동(동작 불변): 파일 이동 + `SignInCodes` 인자. 기존 ChatGPT 테스트가 그대로 통과(G1). +2. domain·포트·`AppAccessGate` RED: `FakePort`로 (a) 토큰 없음 → `SignedOut`·기능 5개 `AUTH_REQUIRED`, (b) refresh 성공 → 회전 토큰 저장·`SignedIn{offline:false}`, (c) `Rejected` → 저장소 비움·`SignedOut`, (d) `Unavailable` → `SignedIn{offline:true}`·토큰 유지, (e) 로그인 성공 → 저장·기능 허용, (f) 로그인 중 두 번째 시도 거부·취소, (g) 로그아웃 → revoke 1회·저장소 비움·`AUTH_REQUIRED`, revoke 실패해도 비움, (h) 로그인 진행 중 로그아웃 → 로그인이 취소되고 이후 저장된 세션 없음, (i) 저장 세션이 있는 상태에서 로그인 시작 후 취소 → `revoke` 0회·저장 세션 유지·로그인 결과 `CANCELLED`. 테스트 이름에 `gateway`를 넣어 G2 필터로 실행되게 한다. GREEN. +3. 어댑터 RED: 로컬 가짜 HTTP 서버로 교환 요청 본문(JSON 필드 4개), refresh 분류(200 → 토큰, 400 `invalid_grant` → `Rejected`, 400 `invalid_request`·본문 없는 400·401·429·503·연결 거부 → `Unavailable`), `/me` 실패 시 이메일 `None`, state 불일치 거부, 시작 URL 쿼리(redirect_uri·challenge·method·state). 설정 store: (i) 빈 설정에서 설정 파일 백엔드로 `save_session` → 같은 경로로 새로 만든 store가 토큰·이메일을 읽음(재시작), (ii) `InMemorySecrets` 백엔드에서 재시작 후 이메일 유지·설정 파일 평문에 토큰 없음, (iii) `clear` 후 둘 다 비움·다른 설정이 없으면 파일 삭제. GREEN. +4. Tauri 명령·배선, 프런트 reducer 테스트(`failed`에서 다시 시도·다시 로그인, 로그아웃 실패 → `signed-out{error}` 포함), DOM 테스트(로그인 전 앱 셸 `inert`·로그인 화면 표시, `loadAppAccess` 실패 시 오류 문구와 로그인 버튼 표시, `signInToGateway`가 끝나지 않은 동안 로그인 버튼 `hidden`·취소 버튼 표시·두 번째 `signInToGateway` 호출 0회, 모든 IPC가 거부되는 백엔드에서 로그인 화면 영역에 '네이티브 런타임' 문구가 보이고 상태가 `checking`이 아니며 로그인·다시 시도 버튼이 둘 다 `hidden`이고 `signInToGateway` 호출이 0회, 로그인 성공 후 작업 공간 로드, 로그아웃 후 복귀, 오프라인 표시 텍스트), Zod 경계 테스트. +5. 문서 갱신. + +## 게이트 + +```text +G1: 공유 모듈 이동 뒤 ChatGPT 로그인 테스트가 그대로 통과한다 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets chatgpt | EXPECT: /test result: ok\. [1-9]\d* passed/ +G2: 접근 게이트 application 테스트 (a)~(i) | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets gateway | EXPECT: /test result: ok\. [1-9]\d* passed/ +G3: gateway 어댑터·설정 store 테스트 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets adapters::outbound | EXPECT: /test result: ok\. [1-9]\d* passed/ +G4: Rust 서식 | CHECK: python3 -c 'import subprocess,sys;r=subprocess.run(["cargo", "fmt", "--manifest-path", "src-tauri/Cargo.toml", "--check"]);print("FMT_OK" if r.returncode==0 else "FMT_FAIL");sys.exit(r.returncode)' | EXPECT: FMT_OK +G4b: Clippy | CHECK: python3 -c 'import subprocess,sys;r=subprocess.run(["cargo", "clippy", "--manifest-path", "src-tauri/Cargo.toml", "--all-targets", "--", "-D", "warnings"]);print("CLIPPY_OK" if r.returncode==0 else "CLIPPY_FAIL");sys.exit(r.returncode)' | EXPECT: CLIPPY_OK +G4c: Rust 전체 테스트 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets | EXPECT: /test result: ok\. [1-9]\d* passed/ +G5: 아키텍처 검사·Biome·tsc | CHECK: python3 -c 'import subprocess,sys;r=subprocess.run(["bun", "run", "check"]);print("CHECK_OK" if r.returncode==0 else "CHECK_FAIL");sys.exit(r.returncode)' | EXPECT: CHECK_OK +G5b: 프런트 reducer·DOM·Zod 테스트 | CHECK: bun test | EXPECT: /^\s*0 fail$/ +G6: 문서 플랫폼 문구 검사 | CHECK: python3 scripts/verify-docs-platform.py | EXPECT: PLATFORM_DOCS_OK +G7: 명령 28·포트 15와 문서 숫자 일치 | CHECK: python3 -c 'import re,sys,pathlib as P;t=P.Path("src-tauri/src/adapters/inbound/tauri.rs").read_text().count("#[tauri::command]");q=len(re.findall(r"^pub trait",P.Path("src-tauri/src/application/ports.rs").read_text(),re.M));pats=["24 frontend","24 command","24 IPC","24 Tauri","24 커맨드","24개","24 `#[tauri","13 Rust traits","13 ports","13개 포트","13개 trait","포트 13개"];fs=["AGENTS.md","src-tauri/AGENTS.md","docs/ARCHITECTURE.md",".issueops/conventions/overview.md",".issueops/architecture/overview.md",".issueops/OPEN_API_SPEC.md"];left=[(f,x) for f in fs for x in pats if x in P.Path(f).read_text()];ok=t==28 and q==15 and not left;print("COUNTS_OK" if ok else ("COUNTS_FAIL",t,q,left));sys.exit(0 if ok else 1)' | EXPECT: COUNTS_OK +G8: IPC로 나가는 AppAccess JSON에 토큰이 없다 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets app_access_serializes_only_state_and_email | EXPECT: /test result: ok\. 1 passed/ +``` + +수동 확인 기록(원장 게이트가 아님 — argv 명령 하나로 관찰할 수 없음): + +- 실제 로그인 스모크: auth-gateway#8 브랜치를 로컬로 띄우고 `GALPI_AUTH_GATEWAY_URL`로 `bun run dev` → 로그인 → 앱 재시작 후 유지 → 로그아웃 → 로그인 화면. gateway 로컬 실행에 Supabase 자격 증명이 필요하며, 쓸 수 없으면 근거와 함께 보류하고 gateway 배포 후 운영 gateway로 확인한다. +- Windows Credential Manager의 `com.m16khb.galpi:gateway-session` 항목 생성·삭제(실기). + +모든 CHECK는 한 개의 argv 명령이며 `|`·`&&`·`;` 셸 연결자를 쓰지 않는다(`gates-ledger` 규칙). 여러 단계는 `python3 -c` 하나로 감싸 표지 문자열을 출력한다. + +## 의존성과 로컬 설정 + +- 의존성: 워크트리에서 `bun install`. Rust 크레이트는 추가하지 않는다(reqwest·serde·sha2·base64·getrandom 이미 사용 중). +- 로컬 설정: 없음. 디버그 스모크의 `GALPI_AUTH_GATEWAY_URL`은 셸 환경 변수로만 넘긴다. diff --git a/.issueops/issues/10/report.md b/.issueops/issues/10/report.md new file mode 100644 index 0000000..cf8ae33 --- /dev/null +++ b/.issueops/issues/10/report.md @@ -0,0 +1,60 @@ +# 검증 보고서 초안: galpi#10 Google 로그인 필수화 (io-21fd3b1e1f06) + +## 범위 + +- 공유 모듈: `adapters/outbound/browser_sign_in/`(브라우저·루프백·PKCE, `SignInCodes`로 호출자 오류 코드 전달), 테스트용 가짜 HTTP 서버를 같은 위치로 옮겼다. +- Rust: `domain/gateway.rs`(`GatewayTokens`·`GatewayGrant`·`AppAccess`·`GatewayRefreshFailure`), 포트 `GatewayAuthPort`·`GatewaySessionStore`, `application/gateway.rs` `AppAccessGate`, 기능 명령 5개(`prepare`·`refine_transcript`·`transcribe`·`import_transcript`·`start_recording`)의 `AUTH_REQUIRED` 가드, `adapters/outbound/gateway/` HTTP 어댑터, `settings/gateway.rs` 저장소, `Secret::GatewaySession`, Tauri 명령 4개, `composition.rs` 배선. +- 프런트엔드: `BackendPort` 네 메서드와 `AppAccess`, Zod `appAccessSchema`, `access-machine.ts`, `access-screen.ts`, `access-controller.ts`, 로그인 화면·계정 표시 마크업과 스타일, `AppController` 시작 순서 변경. +- 문서: README 두 벌, DESIGN.md, AGENTS.md, src-tauri/AGENTS.md, outbound AGENTS.md, docs/ARCHITECTURE.md, `.issueops` 개요 3개. + +## RED → GREEN 증거 + +- RED: `AppAccessGate::require`를 항상 `Ok`로, 갱신 분류를 "모든 4xx는 거부"로 바꾼 상태에서 `cargo test --all-targets gateway` → 24개 중 8개 실패(기능 차단 7개, `only_invalid_grant_rejects_the_session` 1개). 원래 코드로 되돌린 뒤 24개 모두 통과. +- 게이트 원장 `.issueops/issues/10/gates.md`: G1~G8 11개 모두 PASS(`issueops gates check --write`). + +## 실행한 검증 + +| 명령 | 결과 | +|---|---| +| `cargo test --all-targets chatgpt` | 68 passed | +| `cargo test --all-targets gateway` | 24 passed | +| `cargo test --all-targets adapters::outbound` | 138 passed, 1 ignored | +| `cargo test --all-targets` | 224 passed, 1 ignored | +| `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` | 통과 | +| `bun run check` | 통과(기존 info 수준 Biome 안내만 출력) | +| `bun test` | 201 pass, 0 fail | +| `python3 scripts/verify-docs-platform.py` | PLATFORM_DOCS_OK | +| G7 명령·포트 수 대조 | COUNTS_OK(명령 28, 포트 15) | + +## 화면 확인 + +- Vite 개발 서버 + Chromium에서 `__TAURI_INTERNALS__`를 가짜로 둔 상태로 로그인 화면(로그인 전), 브라우저 대기(취소만 표시), 취소 후 복귀, 오프라인 로그인 후 작업 공간(상단 `Google 계정 / 오프라인 · 저장된 로그인 사용` + `로그아웃`), 로그아웃 후 로그인 화면, 375px 폭을 확인했다. 로그인 전 `.app-shell`은 `inert`였고 작업 공간 IPC(`diagnose_environment` 등)는 로그인 성공 뒤에만 호출됐다. +- `cargo tauri dev`로 실제 앱을 띄워 컴파일·실행까지 확인했다. 창 캡처와 접근성 트리 조회는 이 환경에 화면 기록·손쉬운 사용 권한이 없어 실패했다. + +## 수동 확인으로 남긴 항목 + +- 실제 로그인 스모크: auth-gateway#8 브랜치(`8-desktop-native-login`)의 엔드포인트·본문·오류 형태를 소스로 대조했다(`POST /auth/native/token` JSON + `Content-Type: application/json` 필수, 400 `invalid_grant`, 503 `temporarily_unavailable`, `POST /auth/native/logout` `{refresh_token}`, `GET /me`의 `email`). 로컬 gateway 실행에 필요한 Supabase·Redis 자격 증명(`.env`)이 워크트리에 없어 실제 로그인 스모크는 gateway 배포 뒤 운영 gateway로 확인한다. +- Windows Credential Manager의 `com.m16khb.galpi:gateway-session` 생성·삭제: Windows 실기에서 확인한다. + +## side effect + +- 파일: `settings.json`에 `gatewayEmail`, `gatewaySessionStored`(macOS는 `gatewaySession` 평문, 0600) 키가 추가된다. 로그아웃하면 지운다. +- OS 비밀 저장소: Windows `com.m16khb.galpi:gateway-session`. +- 네트워크: 시작 시 refresh 1회, 로그인 시 교환·`/me` 2회, 로그아웃 시 1회. +- durable state: 없음(메모리의 접근 상태만 추가). + +## 계약 변경 + +- IPC 명령 4개 추가(`load_app_access`, `sign_in_to_gateway`, `cancel_gateway_sign_in`, `sign_out_of_gateway`). 기존 명령의 인자·응답은 그대로다. +- 새 공개 오류 코드: `AUTH_REQUIRED`(기능 명령 5개, 로그인 전), `GATEWAY_SIGN_IN_FAILED`, `GATEWAY_SIGN_IN_TIMEOUT`, `GATEWAY_SIGN_IN_BUSY`, `GATEWAY_SIGN_OUT_INCOMPLETE`, `CANCELLED`(기존 코드 재사용). +- ChatGPT 경로의 오류 코드는 바뀌지 않았다(G1). + +## ai-slop-clean + +- 바꾼 것: `adapters/outbound/gateway/mod.rs`에서 한 번만 쓰던 `error_code` 헬퍼(`serde_json::Value` 수동 탐색)를 `ErrorBody` 역직렬화로 바꿨다(category: needless-abstraction). 동작은 같다(`only_invalid_grant_rejects_the_session` 통과). +- 유지한 것: `AppAccessGate.access`의 `pub(super)` 가시성은 application 테스트가 로그인 없이 게이트를 여는 데 필요하다. `AccessController.cancelSignIn`의 오류 무시는 취소 실패 시 대기 중인 로그인이 자기 결과로 화면을 정리하기 때문이다. +- 측정: 정리 전후 `git diff --shortstat`은 57개 파일, 2,973줄 추가·129줄 삭제(정리로 헬퍼 6줄 삭제·구조체 6줄 추가). 정리 뒤 `cargo clippy -D warnings` 통과, `cargo test --all-targets` 224 passed. + +## 성능 + +- `require()`는 메모리 `Mutex` 한 번 잠금이다. 시작 시 네트워크 요청 1회(연결 10초·전체 30초 제한)가 추가된다. 별도 벤치마크는 하지 않았다. diff --git a/AGENTS.md b/AGENTS.md index 1a52fdb..73319d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,9 +46,13 @@ Generated trees (`node_modules`, `dist`, `src-tauri/target`, | Frontend backend port | `src/domain/backend.ts` | Port contract owned by the inner layer; `TauriBackend` implements it | | Tauri IPC client | `src/adapters/tauri-backend.ts` | `invoke`/`listen` plus Zod boundary parsing | | Native composition | `src-tauri/src/composition.rs` | Only concrete port wiring and Tauri registration | -| IPC command surface | `src-tauri/src/adapters/inbound/tauri.rs` | 24 frontend commands and event bridges (`job-event`, `recording-event`, `chatgpt-event`) | +| IPC command surface | `src-tauri/src/adapters/inbound/tauri.rs` | 28 frontend commands and event bridges (`job-event`, `recording-event`, `chatgpt-event`) | +| Google sign-in gate (use cases) | `src-tauri/src/application/gateway.rs` | `AppAccessGate`: restore/renew, sign-in, sign-out, `AUTH_REQUIRED` on feature commands behind `GatewayAuthPort`/`GatewaySessionStore` | +| Google sign-in gate (adapters) | `src-tauri/src/adapters/outbound/gateway/`, `settings/gateway.rs` | auth-gateway `/auth/native/*` client; session in the secret store | +| Google sign-in gate (UI) | `src/ui/access-controller.ts`, `src/application/access-machine.ts` | Login screen in front of the inert shell, account chip, sign-out | +| Shared browser sign-in | `src-tauri/src/adapters/outbound/browser_sign_in/` | System browser, loopback callback, PKCE; callers pass their error codes | | ChatGPT sign-in (use cases) | `src-tauri/src/application/chatgpt.rs` | Session, token refresh, model list, sign-out behind `ChatGptAuthPort`/`ChatGptStore` | -| ChatGPT sign-in (adapters) | `src-tauri/src/adapters/outbound/chatgpt/` | OAuth/PKCE, loopback callback, ID-token, models, system browser | +| ChatGPT sign-in (adapters) | `src-tauri/src/adapters/outbound/chatgpt/` | OAuth authorize/callback, ID-token, models | | ChatGPT sign-in (UI) | `src/ui/chatgpt-controller.ts` | Sign-in/out, model choice, `chatgpt-event` handling | | Responses API stream | `worker/galpi_worker/responses_stream.py` | Worker-side refinement transport for ChatGPT sign-in | | Backend use cases | `src-tauri/src/application/use_cases.rs` | Central `Application` facade | @@ -70,7 +74,7 @@ call sites, not semantic workspace references. |--------|------|----------|------|------| | `AppController` | class | `src/ui/controller.ts` | 1 construction | Frontend workflow coordinator | | `TauriBackend` | class | `src/adapters/tauri-backend.ts` | 1 construction | IPC and runtime-validation boundary | -| `Application` | struct | `src-tauri/src/application/use_cases.rs` | 24 command paths | Backend use-case facade | +| `Application` | struct | `src-tauri/src/application/use_cases.rs` | 28 command paths | Backend use-case facade | | `run` | function | `src-tauri/src/composition.rs` | 1 entry call | Native composition root | | `run_process` | async function | `src-tauri/src/adapters/outbound/process.rs` | 3 production calls | Worker/process supervisor | | `NativeRecorder` | struct | `src-tauri/src/adapters/outbound/recording/mod.rs` | 1 production wiring | Recording port implementation | diff --git a/DESIGN.md b/DESIGN.md index 1d1a6cc..5183c88 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -109,7 +109,7 @@ Former 600 weights round up to bold; SEED has no letter-spacing tokens, so track ### Base Unit Padding, margin, and gap use SEED's 4px dimension scale with half steps. Off-scale values moved to the -nearest step, ties going up; structural sizes (248px rail, 560px sheet, 132px chip area, icon tiles) stay literal. +nearest step, ties going up; structural sizes (248px rail, 560px sheet, 480px login card, 132px chip area, icon tiles) stay literal. | Token | Value | Usage | |---|---:|---| @@ -146,6 +146,14 @@ nearest step, ties going up; structural sizes (248px rail, 560px sheet, 132px ch ## 5. Components +### Login Screen and Account Chip + +- **Structure**: a full-window `#access-screen` sits in front of the app shell, which stays `inert` until someone is signed in, so nothing behind it is reachable by pointer, keyboard, or screen reader. One centered card holds the app icon tile, the `GALPI ACCOUNT` eyebrow, the heading `Google 계정으로 로그인`, one sentence saying sign-in happens in the system browser and Galpi never sees the password, a `role="status"` live line, and the actions. Signed in, the top bar shows an account chip (the e-mail, or `Google 계정` when the gateway did not give one, over `온라인` or `오프라인 · 저장된 로그인 사용`) and a `로그아웃` outline button. +- **States**: the status line states each state in words beside its color — `로그인 상태를 확인하는 중입니다.` (warning), `Google 계정으로 로그인하면 바로 사용할 수 있습니다.` (success), the browser wait with its cancel hint (warning), `로그아웃하는 중입니다.` (warning), and any failure in the host's Korean message (critical). Actions are an allow-list: `Google로 로그인` only when signed out (with or without an error) or after a check that can be retried, `다시 확인` only after such a check, `취소` only while the browser sign-in waits; checking, signing out, and an unreachable native runtime show no action, the last one asking for a restart because a retry without the event channel would lose job events. +- **Offline**: a stored sign-in the gateway could not renew still opens the app; the chip's e-mail turns the warning color and the second line says `오프라인 · 저장된 로그인 사용`, so the state never rides on color alone. +- **Sign-out**: disabled while recording, transcription, augmentation, or setup runs. It closes the app at once and returns to the login screen; a failed local clear stays on that screen as a critical message. +- **Accessibility**: buttons use the SEED `action-button` recipe at the 40px minimum height with `word-break: keep-all`; the window never holds a token, only the sign-in state and e-mail. + ### Step Rail - **Structure**: three user stages — `01 회의 전사`, `02 전사 결과`, `03 전사 결과 AI 증강` — each with a state label and short explanation. The rail lockup shows the app icon (`assets/app-icon.svg`, the same mark as the bundled DMG/app icon) in a 42px rounded tile above the `갈피` brand; it is decorative (`aria-hidden`) because the adjacent text carries the name. Engine and model preparation are a pre-gate panel (`00 / 준비`), not rail stages; they disappear once the local environment is ready. diff --git a/README.en.md b/README.en.md index 7a92194..ff71ab5 100644 --- a/README.en.md +++ b/README.en.md @@ -59,11 +59,25 @@ bun run dev 1. Install the [Microsoft Visual C++ 2015–2022 x64 redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist). The app does not bundle it. 2. Run the unsigned NSIS installer (`.exe`). If SmartScreen warns, choose **More info → Run anyway**. -3. Follow "2. Prepare the local engine" below. +3. Follow "2. Sign in with Google" and "3. Prepare the local engine" below. On Windows the only transcription engine is `WhisperX` (Qwen3/MLX requires Apple Silicon). It runs on CPU by default; CUDA is optional in settings. CUDA downloads an additional PyTorch cu128 build of about 3.5 GB and requires an NVIDIA driver. ASR itself stays on CTranslate2 CPU int8; CUDA accelerates only alignment and diarization. -### 2. Prepare the local engine +### 2. Sign in with Google + +Galpi requires a Google sign-in through auth-gateway. Until you sign in, only the login screen is shown, and recording, transcription, transcript import, AI augmentation, and engine preparation cannot run. + +1. On the login screen that opens with the app, select `Google로 로그인` (Sign in with Google). +2. Finish the Google sign-in in your default browser. The app opens once the browser returns to the sign-in callback on `127.0.0.1`. Galpi never sees your Google password. +3. If the login screen stays up, select `취소` (Cancel) and try again. + +- Any signed-in Google account may use the app. +- Later launches open with the stored sign-in. The app asks auth-gateway to renew it once at startup. +- If auth-gateway cannot be reached, the stored sign-in keeps the app usable and the account chip in the top-right corner reads `오프라인 · 저장된 로그인 사용` (Offline · using the stored sign-in). A first launch without a stored sign-in needs an internet connection to sign in. +- If auth-gateway refuses the sign-in (you signed out of the web service, or the session is no longer valid), the stored sign-in is deleted and the login screen returns. +- `로그아웃` (Sign out) in the top-right corner asks auth-gateway to end this device's session, then deletes the sign-in stored on this computer whatever the answer. Signing out is unavailable while recording, transcription, AI augmentation, or engine preparation is running. + +### 3. Prepare the local engine 1. Open `설정` (Settings) from the top-right corner. 2. On a new computer, save a Hugging Face token if the diarization model requires access. @@ -72,7 +86,7 @@ On Windows the only transcription engine is `WhisperX` (Qwen3/MLX requires Apple The first setup installs an app-specific Python 3.12 environment and may download several gigabytes of models. Later runs reuse the same app data directory and model cache. The two presets install into separate virtual environments (`engine/`, `engine/qwen3/`), so preparing one does not affect the other. -### 3. Hugging Face token +### 4. Hugging Face token The token is only required when the diarization model is downloaded for the first time. @@ -202,7 +216,7 @@ The completion screen can open each artifact or reveal its output folder in Find - Audio and transcription artifacts are stored in the local folder you choose. - Transcription, alignment, and diarization models (Qwen3, WhisperX, pyannote) are stored in Galpi's app-specific Hugging Face cache. -- The Hugging Face token, the AI augmentation API key, and the ChatGPT sign-in tokens are stored differently per platform. On Windows they live in Windows Credential Manager under `com.m16khb.galpi:hugging-face-token`, `com.m16khb.galpi:assistant-api-key`, and `com.m16khb.galpi:chatgpt-tokens`; long values are split across several entries. On macOS they are stored in the Application Support settings file (`settings.json`) with `0600` permissions. The ChatGPT access and refresh tokens are kept in that file in plain text, so a leaked file can expose API access for the account. +- The Hugging Face token, the AI augmentation API key, the ChatGPT sign-in tokens, and the Google sign-in session (auth-gateway) are stored differently per platform. On Windows they live in Windows Credential Manager under `com.m16khb.galpi:hugging-face-token`, `com.m16khb.galpi:assistant-api-key`, `com.m16khb.galpi:chatgpt-tokens`, and `com.m16khb.galpi:gateway-session`; long values are split across several entries. On macOS they are stored in the Application Support settings file (`settings.json`) with `0600` permissions. The ChatGPT access and refresh tokens and the Google sign-in session are kept in that file in plain text, so a leaked file can expose API access for the account or the Galpi sign-in. The signed-in Google account's email is kept in `settings.json` on both platforms. - On macOS credentials are not encrypted with the Keychain yet. The Keychain ties access to an item to the app's code signature, and ad-hoc signing changes that signature on every build, so each update would ask users to re-authorize a token they never touched. The macOS move to the Keychain lands together with Developer ID signing. - Transcripts are not sent to an external LLM API unless AI minutes are run. - The worker launches fixed programs with explicit argv and does not execute shell strings. diff --git a/README.md b/README.md index 43d3356..2517bcb 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,25 @@ bun run dev 1. [Microsoft Visual C++ 2015–2022 x64 재배포 패키지](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist)를 설치합니다. 앱에 포함되어 있지 않습니다. 2. 서명되지 않은 NSIS 설치 프로그램(`.exe`)을 실행합니다. SmartScreen 경고가 나오면 **추가 정보 → 실행**을 누릅니다. -3. 아래 '최초 엔진 준비'를 진행합니다. +3. 아래 'Google 계정으로 로그인'과 '최초 엔진 준비'를 진행합니다. Windows에서는 전사 엔진으로 `WhisperX`만 선택할 수 있습니다(Qwen3/MLX는 Apple Silicon 전용). 기본은 CPU이며, 설정에서 CUDA를 고를 수 있습니다. CUDA는 PyTorch cu128 빌드를 추가로 약 3.5 GB 내려받고 NVIDIA 드라이버가 필요합니다. ASR 자체는 CTranslate2 CPU int8로 동작하며 CUDA는 정렬·화자분리만 가속합니다. -### 2. 최초 엔진 준비 +### 2. Google 계정으로 로그인 + +Galpi를 사용하려면 auth-gateway를 거쳐 Google 계정으로 로그인해야 합니다. 로그인하기 전에는 로그인 화면만 보이고, 녹음·전사·전사문 가져오기·AI 증강·엔진 준비를 실행할 수 없습니다. + +1. 앱을 실행하면 나타나는 로그인 화면에서 **Google로 로그인**을 누릅니다. +2. 시스템 기본 브라우저에서 Google 로그인을 마칩니다. 브라우저가 `127.0.0.1`의 로그인 응답 주소로 돌아오면 앱이 열립니다. Galpi는 Google 비밀번호를 보지 않습니다. +3. 로그인 화면이 계속 보이면 **취소**를 누르고 다시 시도합니다. + +- 로그인한 Google 계정이라면 모두 사용할 수 있습니다. +- 다음 실행 때는 저장된 로그인으로 바로 열립니다. 이때 앱은 auth-gateway에 로그인 갱신을 한 번 요청합니다. +- auth-gateway에 연결할 수 없으면 저장된 로그인으로 계속 사용하며, 우측 상단 계정 표시에 `오프라인 · 저장된 로그인 사용`이 나타납니다. 저장된 로그인이 없는 첫 실행에서는 인터넷에 연결해야 로그인할 수 있습니다. +- auth-gateway가 로그인을 거부하면(웹 서비스에서 로그아웃했거나 세션이 더 이상 유효하지 않은 경우) 저장된 로그인을 지우고 다시 로그인 화면을 보여 줍니다. +- 우측 상단 **로그아웃**은 auth-gateway에 이 기기의 세션 종료를 요청한 뒤, 응답과 관계없이 이 컴퓨터에 저장된 로그인을 지웁니다. 녹음·전사·AI 증강·엔진 준비가 진행 중일 때는 로그아웃할 수 없습니다. + +### 3. 최초 엔진 준비 1. 앱 우측 상단의 **설정**을 엽니다. 2. 새 컴퓨터에서 화자분리 모델을 처음 받는다면 Hugging Face 토큰을 저장합니다. @@ -72,7 +86,7 @@ Windows에서는 전사 엔진으로 `WhisperX`만 선택할 수 있습니다(Qw 최초 준비에서는 앱 전용 Python 3.12 환경과 수 GB의 모델을 내려받을 수 있습니다. 이후에는 같은 앱 데이터 폴더와 모델 캐시를 재사용합니다. 두 프리셋은 서로 다른 가상환경(`engine/`, `engine/qwen3/`)에 설치되므로 한쪽을 준비해도 다른쪽에 영향을 주지 않습니다. -### 3. Hugging Face 토큰 +### 4. Hugging Face 토큰 화자분리 모델을 처음 내려받을 때만 필요합니다. @@ -202,7 +216,7 @@ API Key 대신 ChatGPT 계정으로 로그인해 회의록 정제 모델을 사 - 음성과 전사 산출물은 사용자가 선택한 로컬 폴더에 저장됩니다. - 전사·정렬·화자분리 모델(Qwen3, WhisperX, pyannote)은 Galpi의 앱 전용 Hugging Face 캐시에 저장됩니다. -- Hugging Face 토큰, AI 증강 API 키, ChatGPT 로그인 토큰은 플랫폼에 따라 다르게 저장됩니다. Windows에서는 Windows Credential Manager의 `com.m16khb.galpi:hugging-face-token`, `com.m16khb.galpi:assistant-api-key`, `com.m16khb.galpi:chatgpt-tokens` 항목에 저장되며, 긴 값은 여러 항목에 나누어 저장됩니다. macOS에서는 Application Support 아래 설정 파일(`settings.json`)에 `0600` 권한으로 저장됩니다. ChatGPT 토큰(access·refresh)도 이 파일에 평문으로 들어가므로, 파일이 유출되면 계정의 API 접근이 노출될 수 있습니다. +- Hugging Face 토큰, AI 증강 API 키, ChatGPT 로그인 토큰, Google 로그인 세션(auth-gateway)은 플랫폼에 따라 다르게 저장됩니다. Windows에서는 Windows Credential Manager의 `com.m16khb.galpi:hugging-face-token`, `com.m16khb.galpi:assistant-api-key`, `com.m16khb.galpi:chatgpt-tokens`, `com.m16khb.galpi:gateway-session` 항목에 저장되며, 긴 값은 여러 항목에 나누어 저장됩니다. macOS에서는 Application Support 아래 설정 파일(`settings.json`)에 `0600` 권한으로 저장됩니다. ChatGPT 토큰(access·refresh)과 Google 로그인 세션도 이 파일에 평문으로 들어가므로, 파일이 유출되면 계정의 API 접근이나 Galpi 로그인이 노출될 수 있습니다. 로그인한 Google 계정의 이메일은 두 플랫폼 모두 `settings.json`에 저장됩니다. - macOS에서는 자격 증명을 아직 Keychain으로 암호화하지 않습니다. Keychain은 항목 접근 권한을 앱의 코드 서명에 묶는데, 현재 빌드는 ad-hoc 서명이라 빌드마다 서명이 달라져 사용자가 업데이트할 때마다 접근 허용 창을 다시 보게 됩니다. macOS의 Keychain 전환은 Developer ID 서명과 함께 적용할 예정입니다. - AI 회의록을 실행하지 않으면 전사문은 외부 LLM API로 전송되지 않습니다. - worker는 고정된 프로그램과 argv로 실행되며 셸 문자열을 실행하지 않습니다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e4e43d..b25d458 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,12 +16,12 @@ Galpi는 세 개의 런타임으로 구성된다. 각각 내부적으로 같은 TypeScript (WebView) Rust (Tauri 호스트) Python (WhisperX 사이드카) ┌────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ │ ui/ (외부) │ │ adapters/inbound (외부) │ │ __main__.py (외부) │ -│ ├─ controller │ IPC │ ├─ tauri.rs (24 커맨드) │ │ engine.py (유스케이스)│ +│ ├─ controller │ IPC │ ├─ tauri.rs (28 커맨드) │ │ engine.py (유스케이스)│ │ ├─ app-view │ ─────▶ │ └─ TauriEvents │ │ refine.py │ │ └─ settings/… │ │ │ │ protocol.py (포트: stdout)│ ├────────────────────┤ │ application/ (유스케이스) │ ├────────────────────────┤ │ application/ │ │ ├─ use_cases.rs (facade) │ │ domain 순수 모듈 │ -│ ├─ job-machine │ │ ├─ ports.rs (13개 포트) │ │ ├─ core.py │ +│ ├─ job-machine │ │ ├─ ports.rs (15개 포트) │ │ ├─ core.py │ │ └─ recording-machine│ │ └─ jobs.rs (registry) │ │ ├─ artifacts.py │ ├────────────────────┤ ├──────────────────────────┤ │ ├─ minutes_*.py │ │ domain/ │ │ domain/ (내부) │ │ └─ assistant_stream.py │ @@ -50,11 +50,11 @@ TypeScript (WebView) Rust (Tauri 호스트) Python (Whispe | 방향 | Rust | TypeScript | Python | |------|------|-----------|--------| -| **-driving (inbound)** | `adapters/inbound/tauri.rs` — 24개 `#[tauri::command]`(`save_compute_device` 포함, `composition.rs`의 `generate_handler!`와 일치) + `TauriEvents` 이벤트 브리지(`job-event`·`recording-event`·`chatgpt-event`) | `adapters/tauri-backend.ts` — `BackendPort` 구현 + Zod 스키마 검증 | `__main__.py` CLI 인자 파싱 | -| **-driven (outbound)** | `DesktopAdapter`(엔진·전사·산출물), `NativeRecorder`(CPAL), `LocalSettingsStore`(`ChatGptStore` 포함), `ChatGptOAuthAdapter`(`ChatGptAuthPort`: OAuth/PKCE·토큰 갱신·폐기·모델 목록), `SystemClock`(`ClockPort`), `process.rs`(워커 감독) | (없음 — 프론트엔드는 driven 포트가 없다; 브라우저 API는 어댑터 내부 처리) | `protocol.py` `EventWriter`(stdout), `assistant_stream.py`·`responses_stream.py`(HTTP) | +| **-driving (inbound)** | `adapters/inbound/tauri.rs` — 28개 `#[tauri::command]`(`save_compute_device` 포함, `composition.rs`의 `generate_handler!`와 일치) + `TauriEvents` 이벤트 브리지(`job-event`·`recording-event`·`chatgpt-event`) | `adapters/tauri-backend.ts` — `BackendPort` 구현 + Zod 스키마 검증 | `__main__.py` CLI 인자 파싱 | +| **-driven (outbound)** | `DesktopAdapter`(엔진·전사·산출물), `NativeRecorder`(CPAL), `LocalSettingsStore`(`ChatGptStore`·`GatewaySessionStore` 포함), `ChatGptOAuthAdapter`(`ChatGptAuthPort`: OAuth/PKCE·토큰 갱신·폐기·모델 목록), `GatewayOAuthAdapter`(`GatewayAuthPort`: auth-gateway Google 로그인·세션 갱신·로그아웃), 두 로그인 어댑터가 함께 쓰는 `browser_sign_in/`(시스템 브라우저·루프백·PKCE), `SystemClock`(`ClockPort`), `process.rs`(워커 감독) | (없음 — 프론트엔드는 driven 포트가 없다; 브라우저 API는 어댑터 내부 처리) | `protocol.py` `EventWriter`(stdout), `assistant_stream.py`·`responses_stream.py`(HTTP) | **포트 소유 규칙 (DIP)**: 포트 인터페이스는 사용하는 쪽(내부 계층)이 소유하고, -어댑터가 그것을 구현한다. Rust는 `application/ports.rs`에 13개 trait이 있어 정확히 지켜진다. +어댑터가 그것을 구현한다. Rust는 `application/ports.rs`에 15개 trait이 있어 정확히 지켜진다. TypeScript는 `BackendPort`가 **어댑터 모듈에** 정의되어 있어 위반이었다 — 리팩토링으로 `domain/backend.ts`로 옮겼다(§6). 프론트엔드의 `ui → adapters` 의존은 이제 타입 재수출 경로(`domain/backend`)로만 존재한다. @@ -69,7 +69,9 @@ TypeScript는 `BackendPort`가 **어댑터 모듈에** 정의되어 있어 위 FFI(`process/guard/windows.rs`, `recording/power/windows.rs`, `secrets/credential_manager.rs`)와 `nix`(`process/guard/unix.rs`). `scripts/check-architecture.ts`는 domain/application/inbound에서 `cfg(windows)`, -`cfg(unix)`, `target_os`, `cfg!(`를 금지한다. +`cfg(unix)`, `target_os`, `cfg!(`를 금지한다. 빌드 종류에 따른 분기도 같은 규칙을 따른다: +디버그 빌드에서만 `GALPI_AUTH_GATEWAY_URL`로 로그인 서버를 바꾸는 `cfg!(debug_assertions)`는 +`adapters/outbound/gateway/`에만 있다. ## 3. DDD 관점 — 전술적 패턴 매핑 @@ -96,7 +98,7 @@ CPAL은 도메인에 금지다. | **S**RP | 유스케이스 메서드는 하나의 사용자 의도만; `process.rs`는 감독만, `writer.rs`는 WAV 직렬화만; TS 설정 위젯은 화면 하나씩 | 250 LOC 순수 코드 상향선 초과 시 모듈 분리 | | **O**CP | 이벤트/요청은 tagged union + `switch`/`match`로 확장 — 새 이벤트 추가는 새 variant 추가와 전 매치 컴파일 오류로 유도 (Rust `match`는 exhaustive) | `default:` 분기로 확장을 흡수하지 않는다 | | **L**SP | 포트 구현체(`FakePort`, `TauriBackend`, `DesktopAdapter`)는 계약(오류 코드, 이벤트 순서)을 지킨다 | 테스트 페이크가 프로덕션 계약과 어긋나면 페이크를 고친다 | -| **I**SP | Rust 포트 13개는 소비자별 분리(`EnginePort` ≠ `ArtifactPort` ≠ `SettingsPort` ≠ `ChatGptAuthPort`…); TS `BackendPort`는 단일 소비자(controller)를 위한 하나의 응집된 계약 | 컨트롤러가 사용하지 않는 메서드를 포트에 추가하지 않는다 | +| **I**SP | Rust 포트 15개는 소비자별 분리(`EnginePort` ≠ `ArtifactPort` ≠ `SettingsPort` ≠ `ChatGptAuthPort` ≠ `GatewayAuthPort`…); TS `BackendPort`는 단일 소비자(controller)를 위한 하나의 응집된 계약 | 컨트롤러가 사용하지 않는 메서드를 포트에 추가하지 않는다 | | **D**IP | 내부 계층은 포트만 안다: Rust `Arc`, TS `BackendPort` 타입 주입, 워커는 `EventWriter` 주입 | `ui/`가 `@tauri-apps/*` 또는 `adapters/` 구현체를 import하면 `check-architecture.ts`가 실패한다 | ## 5. OOP 구조 관례 @@ -143,7 +145,8 @@ Rust는 전통적 상속이 없으므로 OOP 원칙은 **trait + 조합**으로 `composition.rs`의 `generate_handler!` 항목 수와 맞춘다). 백엔드 이벤트를 추가하면 `TauriEvents`에 해당 `*Events` 포트 구현과 `tauri-backend.ts`의 `listen` 파싱을 함께 갱신한다. 3. **새 외부 능력**: `application/ports.rs`에 trait → outbound 구현 → `composition.rs` wiring - (ChatGPT 로그인 계열은 `ChatGptAuthPort`/`ChatGptStore`/`ChatGptEvents`/`ClockPort`) + (ChatGPT 로그인 계열은 `ChatGptAuthPort`/`ChatGptStore`/`ChatGptEvents`/`ClockPort`, + 앱 사용 로그인은 `GatewayAuthPort`/`GatewaySessionStore`) → `application/tests.rs`의 `FakePort` 확장. 4. **새 UI 상태**: `application/*-machine.ts`에 리듀서 + 동반 테스트; 뷰는 렌더만. diff --git a/src-tauri/AGENTS.md b/src-tauri/AGENTS.md index 8779fc9..8bec7db 100644 --- a/src-tauri/AGENTS.md +++ b/src-tauri/AGENTS.md @@ -8,7 +8,7 @@ covers the crate only. Crate `galpi` (lib `galpi_lib`, edition 2024, MSRV 1.85). Entry chain is `main.rs` -> `lib.rs::run()` -> `composition.rs`, the only file that builds concrete adapters and registers Tauri state and handlers. `Application` -(`src/application/use_cases.rs`) is the facade behind all 24 IPC commands. +(`src/application/use_cases.rs`) is the facade behind all 28 IPC commands. It drives ports declared in `src/application/ports.rs`: `DesktopAdapter` implements engine, transcription, transcript import, refinement, and artifacts ports; `NativeRecorder` covers CPAL capture. Architecture principles (DDD value diff --git a/src-tauri/src/adapters/inbound/tauri.rs b/src-tauri/src/adapters/inbound/tauri.rs index 6e993f1..ae47db6 100644 --- a/src-tauri/src/adapters/inbound/tauri.rs +++ b/src-tauri/src/adapters/inbound/tauri.rs @@ -10,6 +10,7 @@ use crate::domain::chatgpt::{ ChatGptModel, ChatGptPreferences, ChatGptSettings, ChatGptSignInEvent, SignOutResult, }; use crate::domain::engine::{ComputeDevice, EnginePreset}; +use crate::domain::gateway::AppAccess; use crate::domain::job::{SetupRequest, TranscriptImportRequest, TranscriptionRequest}; use crate::domain::roster::AssistantSettings; use crate::domain::worker::WorkerEvent; @@ -210,6 +211,31 @@ pub async fn sign_out_of_chatgpt( application.sign_out_of_chatgpt().await } +/// Whether the stored sign-in opens the app. Renews it once with the gateway. +#[tauri::command] +pub async fn load_app_access(application: State<'_, Application>) -> Result { + application.load_app_access().await +} + +/// Opens the system browser and resolves once the sign-in completes, fails, +/// is cancelled, or times out. +#[tauri::command] +pub async fn sign_in_to_gateway( + application: State<'_, Application>, +) -> Result { + application.sign_in_to_gateway().await +} + +#[tauri::command] +pub async fn cancel_gateway_sign_in(application: State<'_, Application>) -> Result<(), AppError> { + application.cancel_gateway_sign_in() +} + +#[tauri::command] +pub async fn sign_out_of_gateway(application: State<'_, Application>) -> Result<(), AppError> { + application.sign_out_of_gateway().await +} + #[derive(Debug, Clone)] pub struct TauriEvents { app: AppHandle, diff --git a/src-tauri/src/adapters/outbound/AGENTS.md b/src-tauri/src/adapters/outbound/AGENTS.md index 64d9cc7..8ee2e7a 100644 --- a/src-tauri/src/adapters/outbound/AGENTS.md +++ b/src-tauri/src/adapters/outbound/AGENTS.md @@ -10,8 +10,11 @@ cleanup, and stable error codes are the contract here. ```text outbound/ -├── chatgpt/ # ChatGptAuthPort: OAuth/PKCE, loopback callback, ID-token, /v1/models, system browser; SystemClock +├── browser_sign_in/ # Shared system-browser sign-in: TauriBrowser, loopback callback, PKCE; callers pass SignInCodes +├── chatgpt/ # ChatGptAuthPort: OAuth authorize/callback, ID-token, /v1/models; SystemClock +├── gateway/ # GatewayAuthPort: auth-gateway /auth/native Google sign-in, refresh, logout ├── settings/chatgpt.rs # ChatGptStore on LocalSettingsStore (tokens in 0600 settings.json) +├── settings/gateway.rs # GatewaySessionStore on LocalSettingsStore (Secret::GatewaySession) ├── desktop.rs # EnginePort, TranscriptionPort, TranscriptImportPort, ArtifactPort facade ├── environment.rs # Readiness probe and worker environment ├── import.rs # Transcript import into a per-meeting folder diff --git a/src-tauri/src/adapters/outbound/chatgpt/browser.rs b/src-tauri/src/adapters/outbound/browser_sign_in/browser.rs similarity index 74% rename from src-tauri/src/adapters/outbound/chatgpt/browser.rs rename to src-tauri/src/adapters/outbound/browser_sign_in/browser.rs index d057da3..994d230 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/browser.rs +++ b/src-tauri/src/adapters/outbound/browser_sign_in/browser.rs @@ -1,12 +1,13 @@ -//! Opening the authorization URL in the system browser. +//! Opening the sign-in URL in the system browser. +use super::SignInCodes; use crate::application::error::AppError; use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; /// Opens a URL in the user's default browser. pub trait BrowserOpener: Send + Sync { - fn open(&self, url: &str) -> Result<(), AppError>; + fn open(&self, url: &str, codes: SignInCodes) -> Result<(), AppError>; } /// The production opener, backed by the Tauri opener plugin. @@ -21,11 +22,11 @@ impl TauriBrowser { } impl BrowserOpener for TauriBrowser { - fn open(&self, url: &str) -> Result<(), AppError> { + fn open(&self, url: &str, codes: SignInCodes) -> Result<(), AppError> { // The URL carries `state`, so the opener's error text is not forwarded. self.app.opener().open_url(url, None::<&str>).map_err(|_| { AppError::new( - "CHATGPT_SIGN_IN_FAILED", + codes.failed, "시스템 브라우저를 열지 못했습니다. 기본 브라우저 설정을 확인해 주세요.", ) }) diff --git a/src-tauri/src/adapters/outbound/chatgpt/testing/server.rs b/src-tauri/src/adapters/outbound/browser_sign_in/fake_server.rs similarity index 100% rename from src-tauri/src/adapters/outbound/chatgpt/testing/server.rs rename to src-tauri/src/adapters/outbound/browser_sign_in/fake_server.rs diff --git a/src-tauri/src/adapters/outbound/chatgpt/loopback.rs b/src-tauri/src/adapters/outbound/browser_sign_in/loopback.rs similarity index 98% rename from src-tauri/src/adapters/outbound/chatgpt/loopback.rs rename to src-tauri/src/adapters/outbound/browser_sign_in/loopback.rs index df9e496..6be2d24 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/loopback.rs +++ b/src-tauri/src/adapters/outbound/browser_sign_in/loopback.rs @@ -4,6 +4,7 @@ //! answered and ignored, so a speculative browser connection, a favicon fetch, //! or a silent socket never ends or stalls the wait. +use super::SignInCodes; use crate::application::error::AppError; use reqwest::Url; use std::time::Duration; @@ -72,6 +73,7 @@ impl Loopback { self, cancel: &mut oneshot::Receiver<()>, limit: Duration, + codes: SignInCodes, ) -> Result { let mut connections = JoinSet::new(); let deadline = sleep(limit); @@ -83,7 +85,7 @@ impl Loopback { } () = &mut deadline => { return Err(AppError::new( - "CHATGPT_SIGN_IN_TIMEOUT", + codes.timed_out, "브라우저에서 로그인이 완료되지 않아 대기를 마쳤습니다. 다시 시도해 주세요.", )); } diff --git a/src-tauri/src/adapters/outbound/chatgpt/loopback/tests.rs b/src-tauri/src/adapters/outbound/browser_sign_in/loopback/tests.rs similarity index 94% rename from src-tauri/src/adapters/outbound/chatgpt/loopback/tests.rs rename to src-tauri/src/adapters/outbound/browser_sign_in/loopback/tests.rs index 2425bc5..fa5a7c8 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/loopback/tests.rs +++ b/src-tauri/src/adapters/outbound/browser_sign_in/loopback/tests.rs @@ -1,4 +1,5 @@ use super::{Callback, Loopback}; +use crate::adapters::outbound::browser_sign_in::SignInCodes; use crate::application::error::AppError; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -8,6 +9,10 @@ use tokio::task::JoinHandle; use tokio::time::timeout; const LONG: Duration = Duration::from_secs(30); +const CODES: SignInCodes = SignInCodes { + failed: "TEST_SIGN_IN_FAILED", + timed_out: "TEST_SIGN_IN_TIMEOUT", +}; type Waiting = ( u16, @@ -19,7 +24,7 @@ async fn start(limit: Duration) -> Result { let loopback = Loopback::bind_preferring(0).await?; let port = loopback.port; let (cancel, mut receiver) = oneshot::channel(); - let task = tokio::spawn(async move { loopback.wait(&mut receiver, limit).await }); + let task = tokio::spawn(async move { loopback.wait(&mut receiver, limit, CODES).await }); Ok((port, cancel, task)) } @@ -151,7 +156,7 @@ async fn a_silent_connection_neither_blocks_nor_outlives_the_read_limit() -> Res loopback.read_timeout = Duration::from_millis(50); let port = loopback.port; let (_cancel, mut receiver) = oneshot::channel(); - let task = tokio::spawn(async move { loopback.wait(&mut receiver, LONG).await }); + let task = tokio::spawn(async move { loopback.wait(&mut receiver, LONG, CODES).await }); let mut silent = TcpStream::connect(("127.0.0.1", port)) .await .map_err(|error| AppError::io("connect", &error))?; @@ -179,14 +184,14 @@ async fn cancelling_ends_the_wait_with_cancelled() -> Result<(), AppError> { } #[tokio::test] -async fn an_elapsed_deadline_ends_the_wait_with_a_timeout() -> Result<(), AppError> { +async fn an_elapsed_deadline_ends_the_wait_with_the_callers_timeout_code() -> Result<(), AppError> { let (_port, _cancel, task) = start(Duration::from_millis(30)).await?; let error = task.await.map_err(|error| join_error(&error))?.err(); assert_eq!( error.map(|error| error.code), - Some("CHATGPT_SIGN_IN_TIMEOUT".to_owned()) + Some(CODES.timed_out.to_owned()) ); Ok(()) } diff --git a/src-tauri/src/adapters/outbound/browser_sign_in/mod.rs b/src-tauri/src/adapters/outbound/browser_sign_in/mod.rs new file mode 100644 index 0000000..05e27a6 --- /dev/null +++ b/src-tauri/src/adapters/outbound/browser_sign_in/mod.rs @@ -0,0 +1,22 @@ +//! What every system-browser sign-in shares (RFC 8252): opening the browser, +//! the loopback redirect listener, and PKCE values. +//! +//! Each sign-in adapter passes its own [`SignInCodes`], so a failure here +//! carries that adapter's stable error codes. + +pub mod browser; +#[cfg(test)] +pub mod fake_server; +pub mod loopback; +pub mod pkce; + +pub use browser::{BrowserOpener, TauriBrowser}; + +/// The error codes a sign-in reports for failures in this module. +#[derive(Clone, Copy)] +pub struct SignInCodes { + /// The browser could not be opened or a random value could not be made. + pub failed: &'static str, + /// The browser did not come back before the deadline. + pub timed_out: &'static str, +} diff --git a/src-tauri/src/adapters/outbound/chatgpt/pkce.rs b/src-tauri/src/adapters/outbound/browser_sign_in/pkce.rs similarity index 77% rename from src-tauri/src/adapters/outbound/chatgpt/pkce.rs rename to src-tauri/src/adapters/outbound/browser_sign_in/pkce.rs index 595bbaa..fb00b5a 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/pkce.rs +++ b/src-tauri/src/adapters/outbound/browser_sign_in/pkce.rs @@ -1,5 +1,6 @@ //! Random values and the S256 code challenge (RFC 7636). +use super::SignInCodes; use crate::application::error::AppError; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -11,11 +12,11 @@ pub const VERIFIER_BYTES: usize = 64; pub const NONCE_BYTES: usize = 32; /// `bytes` of OS randomness as unpadded base64url. -pub fn random_urlsafe(bytes: usize) -> Result { +pub fn random_urlsafe(bytes: usize, codes: SignInCodes) -> Result { let mut buffer = vec![0_u8; bytes]; getrandom::fill(&mut buffer).map_err(|_| { AppError::new( - "CHATGPT_SIGN_IN_FAILED", + codes.failed, "보안 난수를 만들지 못해 로그인을 시작하지 못했습니다.", ) })?; @@ -30,8 +31,14 @@ pub fn code_challenge(verifier: &str) -> String { #[cfg(test)] mod tests { use super::{NONCE_BYTES, VERIFIER_BYTES, code_challenge, random_urlsafe}; + use crate::adapters::outbound::browser_sign_in::SignInCodes; use crate::application::error::AppError; + const CODES: SignInCodes = SignInCodes { + failed: "TEST_FAILED", + timed_out: "TEST_TIMEOUT", + }; + #[test] fn challenge_matches_the_rfc_7636_appendix_b_vector() { assert_eq!( @@ -42,8 +49,8 @@ mod tests { #[test] fn random_values_are_unpadded_urlsafe_and_distinct() -> Result<(), AppError> { - let verifier = random_urlsafe(VERIFIER_BYTES)?; - let state = random_urlsafe(NONCE_BYTES)?; + let verifier = random_urlsafe(VERIFIER_BYTES, CODES)?; + let state = random_urlsafe(NONCE_BYTES, CODES)?; // RFC 7636 §4.1: 43..=128 characters from the unreserved set. assert!((43..=128).contains(&verifier.len())); @@ -55,7 +62,7 @@ mod tests { .all(|character| character.is_ascii_alphanumeric() || "-_".contains(character)) ); } - assert_ne!(verifier, random_urlsafe(VERIFIER_BYTES)?); + assert_ne!(verifier, random_urlsafe(VERIFIER_BYTES, CODES)?); Ok(()) } } diff --git a/src-tauri/src/adapters/outbound/chatgpt/authorize.rs b/src-tauri/src/adapters/outbound/chatgpt/authorize.rs index 159886e..0e7617c 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/authorize.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/authorize.rs @@ -1,7 +1,10 @@ //! The authorization request and the judgement of its callback. -use super::loopback::Callback; -use super::pkce::{NONCE_BYTES, VERIFIER_BYTES, code_challenge, random_urlsafe}; +use super::CODES; +use crate::adapters::outbound::browser_sign_in::loopback::Callback; +use crate::adapters::outbound::browser_sign_in::pkce::{ + NONCE_BYTES, VERIFIER_BYTES, code_challenge, random_urlsafe, +}; use crate::application::error::AppError; use crate::domain::chatgpt::{DYNAMIC_CLIENT_ID, SignInRequest}; use reqwest::Url; @@ -21,9 +24,9 @@ pub struct Session { impl Session { pub fn new() -> Result { Ok(Self { - state: random_urlsafe(NONCE_BYTES)?, - nonce: random_urlsafe(NONCE_BYTES)?, - verifier: random_urlsafe(VERIFIER_BYTES)?, + state: random_urlsafe(NONCE_BYTES, CODES)?, + nonce: random_urlsafe(NONCE_BYTES, CODES)?, + verifier: random_urlsafe(VERIFIER_BYTES, CODES)?, }) } } diff --git a/src-tauri/src/adapters/outbound/chatgpt/mod.rs b/src-tauri/src/adapters/outbound/chatgpt/mod.rs index 647d8c8..018e06e 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/mod.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/mod.rs @@ -4,19 +4,16 @@ //! Nothing here logs a token, `code`, `state`, or authorization URL. mod authorize; -mod browser; mod id_token; -mod loopback; mod models; mod oauth; -mod pkce; #[cfg(test)] mod testing; #[cfg(test)] mod tests; -pub use browser::{BrowserOpener, TauriBrowser}; - +use super::browser_sign_in::loopback::{Loopback, PREFERRED_PORT, SIGN_IN_TIMEOUT}; +use super::browser_sign_in::{BrowserOpener, SignInCodes}; use crate::application::error::AppError; use crate::application::ports::{ChatGptAuthPort, ChatGptEvents, ClockPort}; use crate::domain::chatgpt::{ @@ -25,13 +22,18 @@ use crate::domain::chatgpt::{ }; use async_trait::async_trait; use authorize::{Session, accept_callback, authorization_url}; -use loopback::{Loopback, PREFERRED_PORT, SIGN_IN_TIMEOUT}; use oauth::RevocationPolicy; use reqwest::{Client, redirect::Policy}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::oneshot; +/// The ChatGPT codes for failures in the shared browser sign-in. +const CODES: SignInCodes = SignInCodes { + failed: "CHATGPT_SIGN_IN_FAILED", + timed_out: "CHATGPT_SIGN_IN_TIMEOUT", +}; + /// The wall clock. pub struct SystemClock; @@ -147,10 +149,12 @@ impl ChatGptAuthPort for ChatGptOAuthAdapter { let loopback = Loopback::bind_preferring(self.tuning.preferred_port).await?; let redirect_uri = loopback.redirect_uri(); let url = authorization_url(&endpoints.authorize, request, &session, &redirect_uri)?; - self.browser.open(&url)?; + self.browser.open(&url, CODES)?; self.announce(ChatGptSignInPhase::AwaitingBrowser); - let callback = loopback.wait(cancel, self.tuning.sign_in_timeout).await?; + let callback = loopback + .wait(cancel, self.tuning.sign_in_timeout, CODES) + .await?; let accepted = accept_callback(&callback, request, &session)?; self.announce(ChatGptSignInPhase::Exchanging); let exchanged = oauth::exchange_code( diff --git a/src-tauri/src/adapters/outbound/chatgpt/testing/mod.rs b/src-tauri/src/adapters/outbound/chatgpt/testing/mod.rs index 210cf1c..489ec8f 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/testing/mod.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/testing/mod.rs @@ -1,6 +1,4 @@ -//! Test fixtures: a throwaway RS256 key pair and a fake HTTP server. - -pub mod server; +//! Test fixtures: a throwaway RS256 key pair. use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; diff --git a/src-tauri/src/adapters/outbound/chatgpt/tests/models.rs b/src-tauri/src/adapters/outbound/chatgpt/tests/models.rs index 132a70f..90a640d 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/tests/models.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/tests/models.rs @@ -1,5 +1,5 @@ use super::support::{Harness, approve}; -use crate::adapters::outbound::chatgpt::testing::server::FakeServer; +use crate::adapters::outbound::browser_sign_in::fake_server::FakeServer; use crate::application::ports::ChatGptAuthPort; use serde_json::json; use std::sync::{Arc, Mutex}; diff --git a/src-tauri/src/adapters/outbound/chatgpt/tests/sign_in.rs b/src-tauri/src/adapters/outbound/chatgpt/tests/sign_in.rs index 5f709a6..97c355a 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/tests/sign_in.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/tests/sign_in.rs @@ -2,7 +2,7 @@ use super::support::{ Behavior, GRANTED, Harness, NOW, approve, deny, issuing_tokens, new_request, registration, wrong_state, }; -use crate::adapters::outbound::chatgpt::pkce::code_challenge; +use crate::adapters::outbound::browser_sign_in::pkce::code_challenge; use crate::application::error::AppError; use crate::application::ports::ChatGptAuthPort; use crate::domain::chatgpt::{ChatGptSignInEvent, ChatGptSignInPhase, SignInGrant, SignInRequest}; diff --git a/src-tauri/src/adapters/outbound/chatgpt/tests/support.rs b/src-tauri/src/adapters/outbound/chatgpt/tests/support.rs index dac5e74..839363f 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/tests/support.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/tests/support.rs @@ -1,11 +1,10 @@ //! A fake OpenAI server, fake browser, and recording events around the adapter. +use crate::adapters::outbound::browser_sign_in::fake_server::{FakeServer, Recorded}; +use crate::adapters::outbound::browser_sign_in::{BrowserOpener, SignInCodes}; use crate::adapters::outbound::chatgpt::oauth::RevocationPolicy; -use crate::adapters::outbound::chatgpt::testing::server::{FakeServer, Recorded}; use crate::adapters::outbound::chatgpt::testing::{KEY_ID, claims, jwks_json, sign}; -use crate::adapters::outbound::chatgpt::{ - BrowserOpener, ChatGptOAuthAdapter, OpenAiEndpoints, Tuning, -}; +use crate::adapters::outbound::chatgpt::{ChatGptOAuthAdapter, OpenAiEndpoints, Tuning}; use crate::application::error::AppError; use crate::application::ports::{ChatGptEvents, ClockPort}; use crate::domain::chatgpt::{AgentHostId, ChatGptRegistration, ChatGptSignInEvent, SignInRequest}; @@ -43,7 +42,7 @@ pub struct FakeBrowser { } impl BrowserOpener for FakeBrowser { - fn open(&self, url: &str) -> Result<(), AppError> { + fn open(&self, url: &str, _codes: SignInCodes) -> Result<(), AppError> { let parsed = Url::parse(url).map_err(|_| AppError::new("TEST", "bad url"))?; let param = |name: &str| { parsed diff --git a/src-tauri/src/adapters/outbound/chatgpt/tests/tokens.rs b/src-tauri/src/adapters/outbound/chatgpt/tests/tokens.rs index e588ee1..b5fbca2 100644 --- a/src-tauri/src/adapters/outbound/chatgpt/tests/tokens.rs +++ b/src-tauri/src/adapters/outbound/chatgpt/tests/tokens.rs @@ -1,5 +1,5 @@ use super::support::{Harness, approve, registration}; -use crate::adapters::outbound::chatgpt::testing::server::FakeServer; +use crate::adapters::outbound::browser_sign_in::fake_server::FakeServer; use crate::application::ports::ChatGptAuthPort; use crate::domain::chatgpt::{RefreshFailure, RevocationOutcome}; use std::sync::{Arc, Mutex}; diff --git a/src-tauri/src/adapters/outbound/gateway/mod.rs b/src-tauri/src/adapters/outbound/gateway/mod.rs new file mode 100644 index 0000000..1a8c6f5 --- /dev/null +++ b/src-tauri/src/adapters/outbound/gateway/mod.rs @@ -0,0 +1,280 @@ +//! The auth-gateway desktop sign-in (RFC 8252): Google through the system +//! browser and a loopback redirect with PKCE, then a token exchange, renewal, +//! and sign-out against the gateway's `/auth/native` endpoints. +//! +//! Nothing here logs a token, `code`, `state`, or sign-in URL. + +#[cfg(test)] +mod tests; + +use super::browser_sign_in::loopback::{Loopback, PREFERRED_PORT, SIGN_IN_TIMEOUT}; +use super::browser_sign_in::pkce::{NONCE_BYTES, VERIFIER_BYTES, code_challenge, random_urlsafe}; +use super::browser_sign_in::{BrowserOpener, SignInCodes}; +use crate::application::error::AppError; +use crate::application::ports::{ClockPort, GatewayAuthPort}; +use crate::domain::gateway::{GatewayGrant, GatewayRefreshFailure, GatewayTokens}; +use async_trait::async_trait; +use reqwest::header::CONTENT_TYPE; +use reqwest::{Client, RequestBuilder, StatusCode, Url, redirect::Policy}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +/// Where the gateway is deployed. +const PRODUCTION_URL: &str = "https://auth.m16khb.dev"; +/// Debug builds only: point the app at a locally running gateway. +const URL_OVERRIDE: &str = "GALPI_AUTH_GATEWAY_URL"; + +const CODES: SignInCodes = SignInCodes { + failed: "GATEWAY_SIGN_IN_FAILED", + timed_out: "GATEWAY_SIGN_IN_TIMEOUT", +}; + +fn failed(message: &str) -> AppError { + AppError::new(CODES.failed, message) +} + +/// The gateway base URL: the production one, or in a debug build the override. +fn gateway_url() -> String { + if cfg!(debug_assertions) + && let Ok(url) = std::env::var(URL_OVERRIDE) + && !url.trim().is_empty() + { + return url.trim().trim_end_matches('/').to_owned(); + } + PRODUCTION_URL.to_owned() +} + +/// Everything about an attempt that production fixes and tests shorten. +pub struct Tuning { + pub base_url: String, + pub preferred_port: u16, + pub sign_in_timeout: Duration, +} + +impl Tuning { + fn production() -> Self { + Self { + base_url: gateway_url(), + preferred_port: PREFERRED_PORT, + sign_in_timeout: SIGN_IN_TIMEOUT, + } + } +} + +#[derive(Deserialize)] +struct TokenBody { + access_token: String, + refresh_token: Option, + expires_in: u64, +} + +#[derive(Deserialize)] +struct MeBody { + email: Option, +} + +/// An OAuth error body: `{"error": ".."}`. +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +pub struct GatewayOAuthAdapter { + http: Client, + tuning: Tuning, + browser: Arc, + clock: Arc, +} + +impl GatewayOAuthAdapter { + pub fn new( + browser: Arc, + clock: Arc, + ) -> Result { + Self::with_tuning(Tuning::production(), browser, clock) + } + + fn with_tuning( + tuning: Tuning, + browser: Arc, + clock: Arc, + ) -> Result { + let http = Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(Policy::none()) + .build() + .map_err(|_| failed("로그인 서버 연결을 준비하지 못했습니다."))?; + Ok(Self { + http, + tuning, + browser, + clock, + }) + } + + fn endpoint(&self, path: &str) -> String { + format!("{}{path}", self.tuning.base_url) + } + + fn post_json(&self, path: &str, body: &Value) -> RequestBuilder { + self.http + .post(self.endpoint(path)) + .header(CONTENT_TYPE, "application/json") + .body(body.to_string()) + } + + /// The URL for the system browser. It carries `state`, so never log it. + fn sign_in_url( + &self, + redirect_uri: &str, + verifier: &str, + state: &str, + ) -> Result { + let mut url = Url::parse(&self.endpoint("/auth/native/google")) + .map_err(|_| failed("로그인 주소를 만들지 못했습니다."))?; + url.query_pairs_mut() + .append_pair("redirect_uri", redirect_uri) + .append_pair("code_challenge", &code_challenge(verifier)) + .append_pair("code_challenge_method", "S256") + .append_pair("state", state); + Ok(url.into()) + } + + fn session_from( + &self, + body: TokenBody, + fallback_refresh: Option<&str>, + ) -> Option { + let refresh_token = body + .refresh_token + .or_else(|| fallback_refresh.map(str::to_owned))?; + Some(GatewayTokens { + access_token: body.access_token, + refresh_token, + expires_at: self.clock.now_unix().saturating_add(body.expires_in), + }) + } + + async fn exchange_code( + &self, + code: &str, + verifier: &str, + redirect_uri: &str, + ) -> Result { + let body = json!({ + "grant_type": "authorization_code", + "code": code, + "code_verifier": verifier, + "redirect_uri": redirect_uri, + }); + let response = self + .post_json("/auth/native/token", &body) + .send() + .await + .map_err(|_| failed("로그인 서버에 연결하지 못했습니다. 네트워크를 확인해 주세요."))?; + if !response.status().is_success() { + return Err(failed( + "로그인 서버가 로그인 코드를 받아들이지 않았습니다. 다시 시도해 주세요.", + )); + } + let bytes = response + .bytes() + .await + .map_err(|_| failed("로그인 서버 응답을 끝까지 받지 못했습니다."))?; + serde_json::from_slice::(&bytes) + .ok() + .and_then(|body| self.session_from(body, None)) + .ok_or_else(|| failed("로그인 서버 응답 형식을 알 수 없습니다.")) + } + + /// Who signed in; `None` when the gateway cannot say. + async fn email(&self, access_token: &str) -> Option { + let response = self + .http + .get(self.endpoint("/me")) + .bearer_auth(access_token) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let bytes = response.bytes().await.ok()?; + serde_json::from_slice::(&bytes) + .ok()? + .email + .map(|email| email.trim().to_owned()) + .filter(|email| !email.is_empty()) + } +} + +#[async_trait] +impl GatewayAuthPort for GatewayOAuthAdapter { + async fn sign_in(&self, cancel: &mut oneshot::Receiver<()>) -> Result { + let state = random_urlsafe(NONCE_BYTES, CODES)?; + let verifier = random_urlsafe(VERIFIER_BYTES, CODES)?; + let loopback = Loopback::bind_preferring(self.tuning.preferred_port).await?; + let redirect_uri = loopback.redirect_uri(); + let url = self.sign_in_url(&redirect_uri, &verifier, &state)?; + self.browser.open(&url, CODES)?; + + let callback = loopback + .wait(cancel, self.tuning.sign_in_timeout, CODES) + .await?; + if callback.get("state") != Some(state.as_str()) { + return Err(failed( + "로그인 응답이 이 요청과 맞지 않아 로그인을 마치지 못했습니다. 다시 시도해 주세요.", + )); + } + if callback.get("error").is_some() { + return Err(failed( + "Google 로그인이 취소되었거나 완료되지 않았습니다. 다시 시도해 주세요.", + )); + } + let code = callback + .get("code") + .filter(|code| !code.is_empty()) + .ok_or_else(|| failed("로그인 응답에 로그인 코드가 없습니다. 다시 시도해 주세요."))?; + let tokens = self.exchange_code(code, &verifier, &redirect_uri).await?; + let email = self.email(&tokens.access_token).await; + Ok(GatewayGrant { tokens, email }) + } + + /// Only `400 invalid_grant` ends the session; every other failure keeps it. + async fn refresh(&self, refresh_token: &str) -> Result { + let body = json!({"grant_type": "refresh_token", "refresh_token": refresh_token}); + let response = self + .post_json("/auth/native/token", &body) + .send() + .await + .map_err(|_| GatewayRefreshFailure::Unavailable)?; + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|_| GatewayRefreshFailure::Unavailable)?; + if status.is_success() { + return serde_json::from_slice::(&bytes) + .ok() + .and_then(|body| self.session_from(body, Some(refresh_token))) + .ok_or(GatewayRefreshFailure::Unavailable); + } + if status == StatusCode::BAD_REQUEST + && serde_json::from_slice::(&bytes) + .is_ok_and(|body| body.error == "invalid_grant") + { + return Err(GatewayRefreshFailure::Rejected); + } + Err(GatewayRefreshFailure::Unavailable) + } + + async fn revoke(&self, refresh_token: &str) { + let body = json!({"refresh_token": refresh_token}); + // Best effort: the local session is forgotten whatever the gateway says. + let _answered = self.post_json("/auth/native/logout", &body).send().await; + } +} diff --git a/src-tauri/src/adapters/outbound/gateway/tests.rs b/src-tauri/src/adapters/outbound/gateway/tests.rs new file mode 100644 index 0000000..3e9961c --- /dev/null +++ b/src-tauri/src/adapters/outbound/gateway/tests.rs @@ -0,0 +1,354 @@ +use super::{GatewayOAuthAdapter, Tuning}; +use crate::adapters::outbound::browser_sign_in::fake_server::{FakeServer, Handler, Recorded}; +use crate::adapters::outbound::browser_sign_in::pkce::code_challenge; +use crate::adapters::outbound::browser_sign_in::{BrowserOpener, SignInCodes}; +use crate::application::error::AppError; +use crate::application::ports::{ClockPort, GatewayAuthPort}; +use crate::domain::gateway::{GatewayRefreshFailure, GatewayTokens}; +use reqwest::Url; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::oneshot; + +const NOW: u64 = 1_000_000; + +/// What the browser "user" sends back, given the `state` of the request. +type Behavior = Box String + Send + Sync>; + +struct FakeBrowser { + behavior: Behavior, + urls: Mutex>, +} + +impl BrowserOpener for FakeBrowser { + fn open(&self, url: &str, _codes: SignInCodes) -> Result<(), AppError> { + let parsed = Url::parse(url).map_err(|_| AppError::new("TEST", "bad url"))?; + let param = |name: &str| query(&parsed, name).unwrap_or_default(); + if let Ok(mut urls) = self.urls.lock() { + urls.push(url.to_owned()); + } + let target = format!( + "{}?{}", + param("redirect_uri"), + (self.behavior)(¶m("state")) + ); + tokio::spawn(async move { + let _response = reqwest::get(target).await; + }); + Ok(()) + } +} + +struct FixedClock; + +impl ClockPort for FixedClock { + fn now_unix(&self) -> u64 { + NOW + } +} + +fn query(url: &Url, name: &str) -> Option { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) +} + +fn approve() -> Behavior { + Box::new(|state| format!("state={state}&code=galpi-test-code")) +} + +fn json_body(request: &Recorded) -> Value { + serde_json::from_str(&request.body).unwrap_or(Value::Null) +} + +fn token_body(refresh: &str) -> String { + json!({ + "access_token": "galpi-test-access", + "refresh_token": refresh, + "expires_in": 3600, + "token_type": "bearer", + }) + .to_string() +} + +/// A gateway that issues tokens and answers `/me` with `me`. +fn gateway(me: (u16, String)) -> Handler { + Arc::new(move |request| match request.path.as_str() { + "/auth/native/token" => (200, token_body("galpi-test-refresh-1")), + "/me" => me.clone(), + _ => (404, String::new()), + }) +} + +struct Harness { + server: FakeServer, + browser: Arc, + adapter: GatewayOAuthAdapter, +} + +impl Harness { + async fn start(behavior: Behavior, handler: Handler) -> Result { + let server = FakeServer::start(handler) + .await + .map_err(|error| AppError::io("server", &error))?; + let browser = Arc::new(FakeBrowser { + behavior, + urls: Mutex::new(Vec::new()), + }); + let adapter = GatewayOAuthAdapter::with_tuning( + Tuning { + base_url: server.base.clone(), + preferred_port: 0, + sign_in_timeout: Duration::from_secs(30), + }, + Arc::clone(&browser) as Arc, + Arc::new(FixedClock), + )?; + Ok(Self { + server, + browser, + adapter, + }) + } + + fn opened_url(&self) -> Result { + let urls = self + .browser + .urls + .lock() + .map_err(|_| AppError::new("TEST", "urls lock"))?; + let url = urls + .first() + .ok_or_else(|| AppError::new("TEST", "no browser was opened"))?; + Url::parse(url).map_err(|_| AppError::new("TEST", "bad url")) + } +} + +async fn signed_in(harness: &Harness) -> Result { + let (_cancel, mut receiver) = oneshot::channel(); + harness.adapter.sign_in(&mut receiver).await +} + +#[tokio::test] +async fn sign_in_opens_the_gateway_with_pkce_and_exchanges_the_code() -> Result<(), AppError> { + // Given + let me = ( + 200, + json!({"sub": "u1", "email": "user@example.com"}).to_string(), + ); + let harness = Harness::start(approve(), gateway(me)).await?; + + // When + let grant = signed_in(&harness).await?; + + // Then: the browser was sent to the gateway's Google entry point + let url = harness.opened_url()?; + assert_eq!(url.path(), "/auth/native/google"); + let redirect_uri = query(&url, "redirect_uri").unwrap_or_default(); + assert!(redirect_uri.starts_with("http://127.0.0.1:")); + assert!(redirect_uri.ends_with("/auth/callback")); + assert_eq!( + query(&url, "code_challenge_method").as_deref(), + Some("S256") + ); + assert_eq!(query(&url, "state").map(|state| state.len()), Some(43)); + + // And the code was exchanged with the verifier behind the challenge + let exchange = harness + .server + .requests() + .into_iter() + .find(|request| request.path == "/auth/native/token") + .ok_or_else(|| AppError::new("TEST", "no exchange"))?; + let body = json_body(&exchange); + assert_eq!(exchange.method, "POST"); + assert_eq!(body["grant_type"], "authorization_code"); + assert_eq!(body["code"], "galpi-test-code"); + assert_eq!(body["redirect_uri"], redirect_uri.as_str()); + let verifier = body["code_verifier"].as_str().unwrap_or_default(); + assert_eq!( + query(&url, "code_challenge"), + Some(code_challenge(verifier)) + ); + assert_eq!(body.as_object().map(serde_json::Map::len), Some(4)); + + // And the grant carries the tokens and who signed in + assert_eq!( + grant.tokens, + GatewayTokens { + access_token: "galpi-test-access".to_owned(), + refresh_token: "galpi-test-refresh-1".to_owned(), + expires_at: NOW + 3600, + } + ); + assert_eq!(grant.email.as_deref(), Some("user@example.com")); + let me = harness + .server + .requests() + .into_iter() + .find(|request| request.path == "/me") + .ok_or_else(|| AppError::new("TEST", "no /me"))?; + assert_eq!( + me.authorization.as_deref(), + Some("Bearer galpi-test-access") + ); + Ok(()) +} + +#[tokio::test] +async fn sign_in_succeeds_without_an_email_when_me_fails() -> Result<(), AppError> { + let harness = Harness::start(approve(), gateway((503, String::new()))).await?; + + let grant = signed_in(&harness).await?; + + assert_eq!(grant.email, None); + assert_eq!(grant.tokens.refresh_token, "galpi-test-refresh-1"); + Ok(()) +} + +#[tokio::test] +async fn a_callback_for_another_attempt_is_refused_before_any_exchange() -> Result<(), AppError> { + let forged: Behavior = Box::new(|_| "state=forged&code=galpi-test-code".to_owned()); + let harness = Harness::start(forged, gateway((200, "{}".to_owned()))).await?; + + let error = signed_in(&harness).await.err(); + + assert_eq!( + error.map(|error| error.code), + Some("GATEWAY_SIGN_IN_FAILED".to_owned()) + ); + assert_eq!(harness.server.count("/auth/native/token"), 0); + Ok(()) +} + +#[tokio::test] +async fn a_denied_sign_in_is_reported_without_any_exchange() -> Result<(), AppError> { + let denied: Behavior = Box::new(|state| format!("state={state}&error=access_denied")); + let harness = Harness::start(denied, gateway((200, "{}".to_owned()))).await?; + + let error = signed_in(&harness).await.err(); + + assert_eq!( + error.map(|error| error.code), + Some("GATEWAY_SIGN_IN_FAILED".to_owned()) + ); + assert_eq!(harness.server.count("/auth/native/token"), 0); + Ok(()) +} + +async fn refresh_against( + status: u16, + body: &str, +) -> Result, AppError> { + let body = body.to_owned(); + let harness = Harness::start(approve(), Arc::new(move |_| (status, body.clone()))).await?; + Ok(harness.adapter.refresh("galpi-test-refresh-old").await) +} + +#[tokio::test] +async fn refresh_posts_the_refresh_token_and_returns_the_rotated_session() -> Result<(), AppError> { + let harness = Harness::start( + approve(), + Arc::new(|_| (200, token_body("galpi-test-refresh-new"))), + ) + .await?; + + let tokens = harness.adapter.refresh("galpi-test-refresh-old").await; + + assert_eq!( + tokens.map(|tokens| tokens.refresh_token), + Ok("galpi-test-refresh-new".to_owned()) + ); + let requests = harness.server.requests(); + let body = requests.first().map_or(Value::Null, json_body); + assert_eq!( + body, + json!({"grant_type": "refresh_token", "refresh_token": "galpi-test-refresh-old"}) + ); + Ok(()) +} + +#[tokio::test] +async fn only_invalid_grant_rejects_the_session() -> Result<(), AppError> { + let invalid_grant = json!({"error": "invalid_grant"}).to_string(); + let invalid_request = json!({"error": "invalid_request"}).to_string(); + let unavailable = json!({"error": "temporarily_unavailable"}).to_string(); + + let outcomes = [ + refresh_against(400, &invalid_grant).await?, + refresh_against(400, &invalid_request).await?, + refresh_against(400, "").await?, + refresh_against(401, &invalid_grant).await?, + refresh_against(429, "").await?, + refresh_against(503, &unavailable).await?, + refresh_against(200, "{not json").await?, + ]; + + let rejected = GatewayRefreshFailure::Rejected; + let kept = GatewayRefreshFailure::Unavailable; + assert_eq!( + outcomes.map(Result::err), + [ + Some(rejected), + Some(kept), + Some(kept), + Some(kept), + Some(kept), + Some(kept), + Some(kept) + ] + ); + Ok(()) +} + +#[tokio::test] +async fn an_unreachable_gateway_keeps_the_session() -> Result<(), AppError> { + // Given: a port nothing listens on + let closed = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|error| AppError::io("bind", &error))?; + let port = closed + .local_addr() + .map_err(|error| AppError::io("addr", &error))? + .port(); + drop(closed); + let adapter = GatewayOAuthAdapter::with_tuning( + Tuning { + base_url: format!("http://127.0.0.1:{port}"), + preferred_port: 0, + sign_in_timeout: Duration::from_secs(30), + }, + Arc::new(FakeBrowser { + behavior: approve(), + urls: Mutex::new(Vec::new()), + }), + Arc::new(FixedClock), + )?; + + // When + let outcome = adapter.refresh("galpi-test-refresh-old").await; + + // Then + assert_eq!(outcome, Err(GatewayRefreshFailure::Unavailable)); + Ok(()) +} + +#[tokio::test] +async fn revoke_posts_the_refresh_token_to_the_logout_endpoint() -> Result<(), AppError> { + let harness = Harness::start(approve(), Arc::new(|_| (500, String::new()))).await?; + + harness.adapter.revoke("galpi-test-refresh-old").await; + + let requests = harness.server.requests(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests.first().map(|request| request.path.as_str()), + Some("/auth/native/logout") + ); + assert_eq!( + requests.first().map(json_body), + Some(json!({"refresh_token": "galpi-test-refresh-old"})) + ); + Ok(()) +} diff --git a/src-tauri/src/adapters/outbound/mod.rs b/src-tauri/src/adapters/outbound/mod.rs index ebe98f0..c26c176 100644 --- a/src-tauri/src/adapters/outbound/mod.rs +++ b/src-tauri/src/adapters/outbound/mod.rs @@ -1,6 +1,8 @@ +pub mod browser_sign_in; pub mod chatgpt; pub mod desktop; mod environment; +pub mod gateway; pub mod import; mod model_cache; mod paths; diff --git a/src-tauri/src/adapters/outbound/secrets.rs b/src-tauri/src/adapters/outbound/secrets.rs index f4c3da8..b9eb2d9 100644 --- a/src-tauri/src/adapters/outbound/secrets.rs +++ b/src-tauri/src/adapters/outbound/secrets.rs @@ -1,4 +1,4 @@ -//! Secret storage for the two credentials Galpi holds. +//! Secret storage for the credentials Galpi holds. //! //! Where a secret lives depends on the platform, and the composition root //! picks the store: @@ -39,6 +39,8 @@ pub enum Secret { AssistantApiKey, /// The serialized ChatGPT OAuth tokens. ChatGptTokens, + /// The serialized auth-gateway session. + GatewaySession, } impl Secret { @@ -51,6 +53,7 @@ impl Secret { Self::HuggingFaceToken => "hugging-face-token", Self::AssistantApiKey => "assistant-api-key", Self::ChatGptTokens => "chatgpt-tokens", + Self::GatewaySession => "gateway-session", } } } diff --git a/src-tauri/src/adapters/outbound/settings.rs b/src-tauri/src/adapters/outbound/settings.rs index fb28aaa..8023847 100644 --- a/src-tauri/src/adapters/outbound/settings.rs +++ b/src-tauri/src/adapters/outbound/settings.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use tauri::AppHandle; mod chatgpt; +mod gateway; #[derive(Debug)] pub struct LocalSettingsStore { @@ -105,6 +106,7 @@ impl LocalSettingsStore { Secret::HuggingFaceToken => settings.hugging_face_token.clone(), Secret::AssistantApiKey => settings.assistant_api_key.clone(), Secret::ChatGptTokens => settings.chatgpt_tokens.clone(), + Secret::GatewaySession => settings.gateway_session.clone(), }) }; let Some(value) = legacy else { @@ -165,6 +167,14 @@ impl LocalSettingsStore { } settings.chatgpt_tokens_stored = present; } + Secret::GatewaySession => { + if let Some(value) = retained { + settings.gateway_session = value; + } else { + settings.gateway_session = None; + } + settings.gateway_session_stored = present; + } }) .await } @@ -177,6 +187,7 @@ impl LocalSettingsStore { Secret::HuggingFaceToken => settings.hugging_face_token_stored == present, Secret::AssistantApiKey => settings.assistant_api_key_stored == present, Secret::ChatGptTokens => settings.chatgpt_tokens_stored == present, + Secret::GatewaySession => settings.gateway_session_stored == present, }) }; if already { @@ -186,6 +197,7 @@ impl LocalSettingsStore { Secret::HuggingFaceToken => settings.hugging_face_token_stored = present, Secret::AssistantApiKey => settings.assistant_api_key_stored = present, Secret::ChatGptTokens => settings.chatgpt_tokens_stored = present, + Secret::GatewaySession => settings.gateway_session_stored = present, }) .await } @@ -215,6 +227,10 @@ impl LocalSettingsStore { settings.chatgpt_tokens_stored, settings.chatgpt_tokens.is_some(), ), + Secret::GatewaySession => ( + settings.gateway_session_stored, + settings.gateway_session.is_some(), + ), }) }; match recorded { @@ -279,6 +295,9 @@ struct LocalSettings { chatgpt_auth_mode: AssistantAuthMode, chatgpt_model: Option, chatgpt_welcome_acknowledged: bool, + gateway_email: Option, + gateway_session: Option, + gateway_session_stored: bool, } impl LocalSettings { @@ -305,6 +324,9 @@ impl LocalSettings { && self.chatgpt_auth_mode == AssistantAuthMode::default() && self.chatgpt_model.is_none() && !self.chatgpt_welcome_acknowledged + && self.gateway_email.is_none() + && self.gateway_session.is_none() + && !self.gateway_session_stored } } diff --git a/src-tauri/src/adapters/outbound/settings/gateway.rs b/src-tauri/src/adapters/outbound/settings/gateway.rs new file mode 100644 index 0000000..666acb5 --- /dev/null +++ b/src-tauri/src/adapters/outbound/settings/gateway.rs @@ -0,0 +1,187 @@ +//! The auth-gateway session on top of the shared settings document. +//! +//! The email is a plain field of `settings.json`. The token record is a +//! secret and rides the same `SecretStore` path as the other credentials. + +use super::super::secrets::Secret; +use super::LocalSettingsStore; +use crate::application::error::AppError; +use crate::application::ports::GatewaySessionStore; +use crate::domain::gateway::GatewayTokens; +use async_trait::async_trait; + +impl LocalSettingsStore { + async fn store_gateway_tokens(&self, tokens: &GatewayTokens) -> Result<(), AppError> { + let record = serde_json::to_string(tokens).map_err(|error| { + AppError::new( + "SETTINGS_SERIALIZE_ERROR", + format!("로그인 정보를 저장 형식으로 바꾸지 못했습니다: {error}"), + ) + })?; + self.store_secret(Secret::GatewaySession, Some(&record)) + .await + } +} + +#[async_trait] +impl GatewaySessionStore for LocalSettingsStore { + async fn load_email(&self) -> Result, AppError> { + let state = self.load().await?; + Ok(state + .as_ref() + .and_then(|settings| settings.gateway_email.clone())) + } + + /// A record that no longer parses is as good as none: signing in again + /// replaces it, and refusing to start would strand the app. + async fn load_tokens(&self) -> Result, AppError> { + Ok(self + .secret(Secret::GatewaySession) + .await? + .and_then(|record| serde_json::from_str(&record).ok())) + } + + async fn save_session( + &self, + tokens: GatewayTokens, + email: Option, + ) -> Result<(), AppError> { + self.update(|settings| settings.gateway_email = email) + .await?; + self.store_gateway_tokens(&tokens).await + } + + async fn replace_tokens(&self, tokens: GatewayTokens) -> Result<(), AppError> { + self.store_gateway_tokens(&tokens).await + } + + /// Tokens first, so an interruption leaves the app signed out. + async fn clear(&self) -> Result<(), AppError> { + self.store_secret(Secret::GatewaySession, None).await?; + self.update(|settings| settings.gateway_email = None).await + } +} + +#[cfg(test)] +mod tests { + use super::LocalSettingsStore; + use crate::adapters::outbound::secrets::{InMemorySecrets, SecretStore, SettingsFile}; + use crate::application::ports::GatewaySessionStore; + use crate::domain::gateway::GatewayTokens; + use std::path::PathBuf; + use std::sync::Arc; + use uuid::Uuid; + + type TestResult = Result<(), Box>; + + fn directory() -> PathBuf { + std::env::temp_dir().join(format!("galpi-gateway-settings-{}", Uuid::now_v7())) + } + + fn tokens(refresh: &str) -> GatewayTokens { + GatewayTokens { + access_token: "galpi-test-access".to_owned(), + refresh_token: refresh.to_owned(), + expires_at: 9_000, + } + } + + fn store(path: PathBuf, secrets: Arc) -> LocalSettingsStore { + LocalSettingsStore::with_secrets(path, secrets) + } + + #[tokio::test] + async fn the_settings_file_backend_keeps_the_gateway_session_across_a_restart() -> TestResult { + // Given: an empty install where the settings file is the secret store + let directory = directory(); + let path = directory.join("settings.json"); + store(path.clone(), Arc::new(SettingsFile)) + .save_session( + tokens("galpi-test-refresh-1"), + Some("user@example.com".to_owned()), + ) + .await?; + + // When: the next launch reads it back + let restarted = store(path.clone(), Arc::new(SettingsFile)); + + // Then + assert_eq!( + restarted.load_tokens().await?, + Some(tokens("galpi-test-refresh-1")) + ); + assert_eq!( + restarted.load_email().await?.as_deref(), + Some("user@example.com") + ); + #[cfg(unix)] + assert_eq!( + std::os::unix::fs::PermissionsExt::mode( + &tokio::fs::metadata(&path).await?.permissions() + ) & 0o777, + 0o600 + ); + tokio::fs::remove_dir_all(directory).await?; + Ok(()) + } + + #[tokio::test] + async fn an_os_secret_store_keeps_the_gateway_tokens_out_of_the_settings_file() -> TestResult { + // Given: a store that keeps secrets outside the settings file + let directory = directory(); + let path = directory.join("settings.json"); + let secrets = Arc::new(InMemorySecrets::default()); + store(path.clone(), secrets.clone()) + .save_session( + tokens("galpi-test-refresh-1"), + Some("user@example.com".to_owned()), + ) + .await?; + + // When: the next launch reads it back + let restarted = store(path.clone(), secrets); + + // Then: both survive, and the file on disk holds no token + assert_eq!( + restarted.load_email().await?.as_deref(), + Some("user@example.com") + ); + assert_eq!( + restarted.load_tokens().await?, + Some(tokens("galpi-test-refresh-1")) + ); + let document = tokio::fs::read_to_string(&path).await?; + assert!(!document.contains("galpi-test"), "{document}"); + assert!( + document.contains("\"gatewaySessionStored\":true"), + "{document}" + ); + tokio::fs::remove_dir_all(directory).await?; + Ok(()) + } + + #[tokio::test] + async fn clearing_the_gateway_session_removes_an_otherwise_empty_settings_file() -> TestResult { + // Given: a session is the only thing on file + let directory = directory(); + let path = directory.join("settings.json"); + let store = store(path.clone(), Arc::new(SettingsFile)); + store + .save_session( + tokens("galpi-test-refresh-1"), + Some("user@example.com".to_owned()), + ) + .await?; + assert!(tokio::fs::try_exists(&path).await?); + + // When + store.clear().await?; + + // Then + assert_eq!(store.load_tokens().await?, None); + assert_eq!(store.load_email().await?, None); + assert!(!tokio::fs::try_exists(&path).await?); + let _removed = tokio::fs::remove_dir_all(directory).await; + Ok(()) + } +} diff --git a/src-tauri/src/application/gateway.rs b/src-tauri/src/application/gateway.rs new file mode 100644 index 0000000..3c31924 --- /dev/null +++ b/src-tauri/src/application/gateway.rs @@ -0,0 +1,174 @@ +//! The auth-gateway sign-in that every feature requires. +//! +//! The gate lives in memory and starts signed out; `restore` decides at +//! startup whether the stored session still opens the app. Only an explicit +//! refusal from the gateway or a sign-out discards the session: an unreachable +//! gateway keeps the app usable with the stored one. + +use crate::application::error::AppError; +use crate::application::ports::{GatewayAuthPort, GatewaySessionStore}; +use crate::domain::gateway::{AppAccess, GatewayRefreshFailure}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use tokio::sync::oneshot; + +pub struct AppAccessGate { + auth: Arc, + store: Arc, + /// Visible to the application tests, which open the gate without a sign-in. + pub(super) access: Mutex, + /// Serializes every session write so a rotated refresh token is spent + /// once and a sign-out is never undone by a write still in flight. + writes: tokio::sync::Mutex<()>, + sign_in_active: AtomicBool, + sign_in_cancel: Mutex>>, +} + +fn sign_in_failed() -> AppError { + AppError::new( + "GATEWAY_SIGN_IN_FAILED", + "로그인 상태를 확인하지 못했습니다.", + ) +} + +/// Releases the sign-in slot however the sign-in ends, including a dropped call. +struct SignInSlot<'a> { + gate: &'a AppAccessGate, +} + +impl Drop for SignInSlot<'_> { + fn drop(&mut self) { + if let Ok(mut cancel) = self.gate.sign_in_cancel.lock() { + *cancel = None; + } + self.gate.sign_in_active.store(false, Ordering::Release); + } +} + +impl AppAccessGate { + pub fn new(auth: Arc, store: Arc) -> Self { + Self { + auth, + store, + access: Mutex::new(AppAccess::SignedOut), + writes: tokio::sync::Mutex::new(()), + sign_in_active: AtomicBool::new(false), + sign_in_cancel: Mutex::new(None), + } + } + + fn set(&self, access: AppAccess) -> AppAccess { + *self.access.lock().unwrap_or_else(PoisonError::into_inner) = access.clone(); + access + } + + /// Fail with `AUTH_REQUIRED` unless someone is signed in. + pub fn require(&self) -> Result<(), AppError> { + match *self.access.lock().unwrap_or_else(PoisonError::into_inner) { + AppAccess::SignedIn { .. } => Ok(()), + AppAccess::SignedOut => Err(AppError::new( + "AUTH_REQUIRED", + "Galpi를 사용하려면 Google로 로그인해 주세요.", + )), + } + } + + /// Renew the stored session once and open or close the app accordingly. + pub async fn restore(&self) -> Result { + let _writing = self.writes.lock().await; + let Some(tokens) = self.store.load_tokens().await? else { + return Ok(self.set(AppAccess::SignedOut)); + }; + match self.auth.refresh(&tokens.refresh_token).await { + Ok(rotated) => { + self.store.replace_tokens(rotated).await?; + let email = self.store.load_email().await?; + Ok(self.set(AppAccess::SignedIn { + email, + offline: false, + })) + } + Err(GatewayRefreshFailure::Rejected) => { + self.store.clear().await?; + Ok(self.set(AppAccess::SignedOut)) + } + Err(GatewayRefreshFailure::Unavailable) => { + let email = self.store.load_email().await?; + Ok(self.set(AppAccess::SignedIn { + email, + offline: true, + })) + } + } + } + + /// Run the browser sign-in and, on success, open the app. + pub async fn sign_in(&self) -> Result { + let (_slot, mut cancel) = self.claim_sign_in()?; + let grant = self.auth.sign_in(&mut cancel).await?; + let _writing = self.writes.lock().await; + // A sign-out that arrived after the browser came back cancels too. + if cancel.try_recv().is_ok() { + return Err(AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.")); + } + self.store + .save_session(grant.tokens, grant.email.clone()) + .await?; + Ok(self.set(AppAccess::SignedIn { + email: grant.email, + offline: false, + })) + } + + /// Ask a waiting sign-in to stop. Harmless when none is waiting; the + /// stored session and the gate are untouched. + pub fn cancel_sign_in(&self) -> Result<(), AppError> { + let sender = self + .sign_in_cancel + .lock() + .map_err(|_| sign_in_failed())? + .take(); + if let Some(sender) = sender { + // The receiver is gone only if the sign-in already finished. + let _already_finished = sender.send(()); + } + Ok(()) + } + + /// Close the app at once, revoke on the gateway when possible, then + /// forget the session locally whatever the gateway said. + pub async fn sign_out(&self) -> Result<(), AppError> { + self.cancel_sign_in()?; + self.set(AppAccess::SignedOut); + let _writing = self.writes.lock().await; + // A restore that held the lock before us may have opened the app again. + self.set(AppAccess::SignedOut); + // A session that cannot be read cannot be revoked either, and signing + // out must still finish. + if let Some(tokens) = self.store.load_tokens().await.ok().flatten() { + self.auth.revoke(&tokens.refresh_token).await; + } + self.store.clear().await.map_err(|error| { + AppError::new( + "GATEWAY_SIGN_OUT_INCOMPLETE", + format!( + "로그아웃했지만 저장된 로그인 정보를 지우지 못했습니다. 다음 실행 때 로그인된 상태로 열릴 수 있습니다. ({})", + error.message + ), + ) + }) + } + + fn claim_sign_in(&self) -> Result<(SignInSlot<'_>, oneshot::Receiver<()>), AppError> { + if self.sign_in_active.swap(true, Ordering::AcqRel) { + return Err(AppError::new( + "GATEWAY_SIGN_IN_BUSY", + "이미 Google 로그인이 진행 중입니다.", + )); + } + let slot = SignInSlot { gate: self }; + let (sender, receiver) = oneshot::channel(); + *self.sign_in_cancel.lock().map_err(|_| sign_in_failed())? = Some(sender); + Ok((slot, receiver)) + } +} diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 5d3e888..5491a8e 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -1,5 +1,6 @@ pub mod chatgpt; pub mod error; +pub mod gateway; pub mod jobs; pub mod model; pub mod ports; diff --git a/src-tauri/src/application/ports.rs b/src-tauri/src/application/ports.rs index eeef5c9..4792eb1 100644 --- a/src-tauri/src/application/ports.rs +++ b/src-tauri/src/application/ports.rs @@ -9,6 +9,7 @@ use crate::domain::chatgpt::{ SignInGrant, SignInRequest, }; use crate::domain::engine::{ComputeDevice, EnginePreset, EngineSelection}; +use crate::domain::gateway::{GatewayGrant, GatewayRefreshFailure, GatewayTokens}; use crate::domain::job::{SetupRequest, SpeakerHint}; use crate::domain::roster::{AssistantSettings, GlossaryEntry, Participant}; use crate::domain::worker::WorkerEvent; @@ -187,6 +188,33 @@ pub trait ChatGptEvents: Send + Sync { fn emit(&self, event: ChatGptSignInEvent) -> Result<(), AppError>; } +/// The auth-gateway's desktop sign-in: browser sign-in, renewal, revocation. +#[async_trait] +pub trait GatewayAuthPort: Send + Sync { + /// Run the browser sign-in until the callback is exchanged, the user + /// cancels, or it times out. + async fn sign_in(&self, cancel: &mut oneshot::Receiver<()>) -> Result; + /// Exchange the refresh token; the result carries the rotated refresh token. + async fn refresh(&self, refresh_token: &str) -> Result; + /// Best-effort server-side sign-out; never fails the caller. + async fn revoke(&self, refresh_token: &str); +} + +/// The stored gateway session. Only the token methods touch the secret store. +#[async_trait] +pub trait GatewaySessionStore: Send + Sync { + async fn load_email(&self) -> Result, AppError>; + async fn load_tokens(&self) -> Result, AppError>; + async fn save_session( + &self, + tokens: GatewayTokens, + email: Option, + ) -> Result<(), AppError>; + async fn replace_tokens(&self, tokens: GatewayTokens) -> Result<(), AppError>; + /// Forget the tokens and the email. + async fn clear(&self) -> Result<(), AppError>; +} + pub trait ClockPort: Send + Sync { fn now_unix(&self) -> u64; } diff --git a/src-tauri/src/application/tests.rs b/src-tauri/src/application/tests.rs index 8b8d112..e601b1d 100644 --- a/src-tauri/src/application/tests.rs +++ b/src-tauri/src/application/tests.rs @@ -1,14 +1,17 @@ use super::chatgpt::ChatGptAccounts; use super::error::AppError; +use super::gateway::AppAccessGate; use super::model::{CompletedTranscription, EnvironmentStatus}; use super::ports::{ - ArtifactPort, ChatGptAuthPort, ChatGptStore, ClockPort, EnginePort, RecordingPort, - RefinementJob, RefinementPort, SettingsPort, TranscriptImportPort, TranscriptionPort, + ArtifactPort, ChatGptAuthPort, ChatGptStore, ClockPort, EnginePort, GatewayAuthPort, + GatewaySessionStore, RecordingPort, RefinementJob, RefinementPort, SettingsPort, + TranscriptImportPort, TranscriptionPort, }; use super::use_cases::Application; use crate::domain::artifact::{ArtifactKind, Artifacts}; use crate::domain::chatgpt::AssistantTransport; use crate::domain::engine::{ComputeDevice, EnginePreset, EngineSelection}; +use crate::domain::gateway::AppAccess; use crate::domain::job::{ SetupRequest, SpeakerHint, TranscriptImportRequest, TranscriptionRequest, }; @@ -74,6 +77,7 @@ struct FakePort { ready_presets: Mutex>, behavior: TranscriptionBehavior, chatgpt: chatgpt::ChatGptFake, + gateway: gateway::GatewayFake, } impl FakePort { @@ -94,11 +98,25 @@ impl FakePort { seen_prepare_engines: Mutex::new(Vec::new()), ready_presets: Mutex::new(vec![EnginePreset::Qwen3, EnginePreset::WhisperX]), chatgpt: chatgpt::ChatGptFake::new(), + gateway: gateway::GatewayFake::default(), behavior, } } + /// An application someone has already signed in to. fn application(self: &Arc) -> Application { + self.application_with(AppAccess::SignedIn { + email: None, + offline: false, + }) + } + + /// An application as it starts: signed out until `load_app_access`. + fn signed_out_application(self: &Arc) -> Application { + self.application_with(AppAccess::SignedOut) + } + + fn application_with(self: &Arc, access: AppAccess) -> Application { let engine: Arc = self.clone(); let transcription: Arc = self.clone(); let imports: Arc = self.clone(); @@ -109,6 +127,12 @@ impl FakePort { let chatgpt_auth: Arc = self.clone(); let chatgpt_store: Arc = self.clone(); let clock: Arc = self.clone(); + let gateway_auth: Arc = self.clone(); + let gateway_store: Arc = self.clone(); + let gate = AppAccessGate::new(gateway_auth, gateway_store); + if let Ok(mut opened) = gate.access.lock() { + *opened = access; + } Application::new( engine, transcription, @@ -118,6 +142,7 @@ impl FakePort { settings, refinement, ChatGptAccounts::new(chatgpt_auth, chatgpt_store, clock), + gate, ) } } @@ -852,4 +877,5 @@ async fn save_compute_device_is_delegated_to_the_settings_port() -> Result<(), A } mod chatgpt; +mod gateway; mod recording; diff --git a/src-tauri/src/application/tests/gateway.rs b/src-tauri/src/application/tests/gateway.rs new file mode 100644 index 0000000..d45e0d1 --- /dev/null +++ b/src-tauri/src/application/tests/gateway.rs @@ -0,0 +1,483 @@ +use super::{FakePort, TranscriptionBehavior, request}; +use crate::application::error::AppError; +use crate::application::ports::{GatewayAuthPort, GatewaySessionStore}; +use crate::application::use_cases::Application; +use crate::domain::gateway::{AppAccess, GatewayGrant, GatewayRefreshFailure, GatewayTokens}; +use crate::domain::job::{SetupRequest, SpeakerHint, TranscriptImportRequest}; +use async_trait::async_trait; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use tokio::sync::{Notify, oneshot}; +use uuid::Uuid; + +fn lock(mutex: &Mutex) -> Result, AppError> { + mutex + .lock() + .map_err(|_| AppError::new("TEST_ERROR", "gateway fake lock poisoned")) +} + +#[derive(Default)] +struct Stored { + tokens: Option, + email: Option, +} + +#[derive(Default)] +struct Calls { + sign_ins: usize, + refresh_tokens: Vec, + revoked: Vec, +} + +/// The gateway side of `FakePort`: an in-memory session store plus a +/// scripted gateway that records every call. +#[derive(Default)] +pub(super) struct GatewayFake { + stored: Mutex, + calls: Mutex, + sign_in_result: Mutex>>, + block_sign_in: AtomicBool, + sign_in_started: Notify, + refreshes: Mutex>>, + unreadable_tokens: AtomicBool, + clear_failure: Mutex>, +} + +impl GatewayFake { + fn stored(&self) -> Result, AppError> { + lock(&self.stored) + } + + fn calls(&self) -> Result, AppError> { + lock(&self.calls) + } + + fn seed(&self, tokens: GatewayTokens, email: Option<&str>) -> Result<(), AppError> { + let mut stored = self.stored()?; + stored.tokens = Some(tokens); + stored.email = email.map(str::to_owned); + Ok(()) + } + + fn script_refresh( + &self, + result: Result, + ) -> Result<(), AppError> { + lock(&self.refreshes)?.push_back(result); + Ok(()) + } + + fn script_sign_in(&self, result: Result) -> Result<(), AppError> { + *lock(&self.sign_in_result)? = Some(result); + Ok(()) + } +} + +#[async_trait] +impl GatewayAuthPort for FakePort { + async fn sign_in(&self, cancel: &mut oneshot::Receiver<()>) -> Result { + self.gateway.calls()?.sign_ins += 1; + if self.gateway.block_sign_in.load(Ordering::Relaxed) { + self.gateway.sign_in_started.notify_one(); + let _cancelled = cancel.await; + return Err(AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.")); + } + lock(&self.gateway.sign_in_result)? + .take() + .unwrap_or_else(|| Err(AppError::new("TEST_ERROR", "no sign-in was scripted"))) + } + + async fn refresh(&self, refresh_token: &str) -> Result { + if let Ok(mut calls) = self.gateway.calls() { + calls.refresh_tokens.push(refresh_token.to_owned()); + } + tokio::task::yield_now().await; + self.gateway + .refreshes + .lock() + .ok() + .and_then(|mut queue| queue.pop_front()) + .unwrap_or(Err(GatewayRefreshFailure::Unavailable)) + } + + async fn revoke(&self, refresh_token: &str) { + if let Ok(mut calls) = self.gateway.calls() { + calls.revoked.push(refresh_token.to_owned()); + } + } +} + +#[async_trait] +impl GatewaySessionStore for FakePort { + async fn load_email(&self) -> Result, AppError> { + Ok(self.gateway.stored()?.email.clone()) + } + + async fn load_tokens(&self) -> Result, AppError> { + if self.gateway.unreadable_tokens.load(Ordering::Relaxed) { + return Err(AppError::new("SECRET_READ_FAILED", "unreadable")); + } + Ok(self.gateway.stored()?.tokens.clone()) + } + + async fn save_session( + &self, + tokens: GatewayTokens, + email: Option, + ) -> Result<(), AppError> { + let mut stored = self.gateway.stored()?; + stored.tokens = Some(tokens); + stored.email = email; + Ok(()) + } + + async fn replace_tokens(&self, tokens: GatewayTokens) -> Result<(), AppError> { + self.gateway.stored()?.tokens = Some(tokens); + Ok(()) + } + + async fn clear(&self) -> Result<(), AppError> { + if let Some(error) = lock(&self.gateway.clear_failure)?.clone() { + return Err(error); + } + *self.gateway.stored()? = Stored::default(); + Ok(()) + } +} + +fn tokens(refresh: &str) -> GatewayTokens { + GatewayTokens { + access_token: format!("galpi-test-access-{refresh}"), + refresh_token: format!("galpi-test-refresh-{refresh}"), + expires_at: 3_600, + } +} + +fn port() -> Arc { + Arc::new(FakePort::new(TranscriptionBehavior::Success)) +} + +fn failure(result: Result) -> Result { + match result { + Ok(_) => Err(AppError::new("TEST_ERROR", "expected a failure")), + Err(error) => Ok(error), + } +} + +/// The codes of every gated feature, in a fixed order. +async fn feature_codes(app: &Application) -> Vec { + let code = |result: Result<(), AppError>| result.err().map(|error| error.code); + vec![ + code( + app.prepare(SetupRequest { + job_id: Uuid::now_v7(), + hugging_face_token: None, + }) + .await + .map(drop), + ), + code( + app.refine_transcript(Uuid::now_v7(), Uuid::now_v7(), &[]) + .await + .map(drop), + ), + code(app.transcribe(request(SpeakerHint::Auto)).await.map(drop)), + code( + app.import_transcript(TranscriptImportRequest { + job_id: Uuid::now_v7(), + input_path: "/tmp/meeting.txt".to_owned(), + output_root: "/tmp".to_owned(), + }) + .await + .map(drop), + ), + code(app.start_recording("/tmp".to_owned()).await.map(drop)), + ] + .into_iter() + .map(Option::unwrap_or_default) + .collect() +} + +const ALL_REQUIRE_SIGN_IN: [&str; 5] = [ + "AUTH_REQUIRED", + "AUTH_REQUIRED", + "AUTH_REQUIRED", + "AUTH_REQUIRED", + "AUTH_REQUIRED", +]; + +#[tokio::test] +async fn gateway_without_a_stored_session_keeps_every_feature_closed() -> Result<(), AppError> { + // Given + let port = port(); + let app = port.signed_out_application(); + + // When + let access = app.load_app_access().await?; + + // Then + assert_eq!(access, AppAccess::SignedOut); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + assert_eq!(port.prepare_calls.load(Ordering::SeqCst), 0); + assert_eq!(port.gateway.calls()?.refresh_tokens, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn gateway_renewal_stores_the_rotated_session_and_opens_the_app() -> Result<(), AppError> { + // Given + let port = port(); + port.gateway.seed(tokens("old"), Some("user@example.com"))?; + port.gateway.script_refresh(Ok(tokens("rotated")))?; + let app = port.signed_out_application(); + + // When + let access = app.load_app_access().await?; + + // Then + assert_eq!( + access, + AppAccess::SignedIn { + email: Some("user@example.com".to_owned()), + offline: false, + } + ); + assert_eq!( + port.gateway.calls()?.refresh_tokens, + ["galpi-test-refresh-old"] + ); + assert_eq!(port.gateway.stored()?.tokens, Some(tokens("rotated"))); + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} + +#[tokio::test] +async fn gateway_refusal_forgets_the_session_and_keeps_the_app_closed() -> Result<(), AppError> { + // Given + let port = port(); + port.gateway.seed(tokens("old"), Some("user@example.com"))?; + port.gateway + .script_refresh(Err(GatewayRefreshFailure::Rejected))?; + let app = port.signed_out_application(); + + // When + let access = app.load_app_access().await?; + + // Then + assert_eq!(access, AppAccess::SignedOut); + assert_eq!(port.gateway.stored()?.tokens, None); + assert_eq!(port.gateway.stored()?.email, None); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + Ok(()) +} + +#[tokio::test] +async fn gateway_unreachable_opens_the_app_offline_with_the_stored_session() -> Result<(), AppError> +{ + // Given + let port = port(); + port.gateway.seed(tokens("old"), Some("user@example.com"))?; + port.gateway + .script_refresh(Err(GatewayRefreshFailure::Unavailable))?; + let app = port.signed_out_application(); + + // When + let access = app.load_app_access().await?; + + // Then + assert_eq!( + access, + AppAccess::SignedIn { + email: Some("user@example.com".to_owned()), + offline: true, + } + ); + assert_eq!(port.gateway.stored()?.tokens, Some(tokens("old"))); + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} + +#[tokio::test] +async fn gateway_sign_in_stores_the_session_and_opens_the_app() -> Result<(), AppError> { + // Given + let port = port(); + port.gateway.script_sign_in(Ok(GatewayGrant { + tokens: tokens("new"), + email: Some("user@example.com".to_owned()), + }))?; + let app = port.signed_out_application(); + + // When + let access = app.sign_in_to_gateway().await?; + + // Then + assert_eq!( + access, + AppAccess::SignedIn { + email: Some("user@example.com".to_owned()), + offline: false, + } + ); + assert_eq!(port.gateway.stored()?.tokens, Some(tokens("new"))); + assert_eq!( + port.gateway.stored()?.email.as_deref(), + Some("user@example.com") + ); + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} + +#[tokio::test] +async fn gateway_sign_in_allows_one_attempt_at_a_time_and_can_be_cancelled() -> Result<(), AppError> +{ + // Given: a sign-in waiting for the browser + let port = port(); + port.gateway.block_sign_in.store(true, Ordering::Relaxed); + let app = Arc::new(port.signed_out_application()); + let first = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.sign_in_to_gateway().await }) + }; + port.gateway.sign_in_started.notified().await; + + // When + let second = failure(app.sign_in_to_gateway().await)?; + app.cancel_gateway_sign_in()?; + let first = failure( + first + .await + .map_err(|error| AppError::new("TEST_JOIN", error.to_string()))?, + )?; + + // Then + assert_eq!(second.code, "GATEWAY_SIGN_IN_BUSY"); + assert_eq!(first.code, "CANCELLED"); + assert_eq!(port.gateway.calls()?.sign_ins, 1); + assert_eq!(port.gateway.stored()?.tokens, None); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + Ok(()) +} + +#[tokio::test] +async fn gateway_sign_out_revokes_once_forgets_the_session_and_closes_the_app() +-> Result<(), AppError> { + // Given + let port = port(); + port.gateway.seed(tokens("old"), Some("user@example.com"))?; + port.gateway.script_refresh(Ok(tokens("rotated")))?; + let app = port.signed_out_application(); + app.load_app_access().await?; + + // When + app.sign_out_of_gateway().await?; + + // Then + assert_eq!( + port.gateway.calls()?.revoked, + ["galpi-test-refresh-rotated"] + ); + assert_eq!(port.gateway.stored()?.tokens, None); + assert_eq!(port.gateway.stored()?.email, None); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + Ok(()) +} + +#[tokio::test] +async fn gateway_sign_out_clears_even_when_the_session_cannot_be_read() -> Result<(), AppError> { + // Given: tokens the store can no longer read, so nothing can be revoked + let port = port(); + port.gateway.seed(tokens("old"), Some("user@example.com"))?; + port.gateway + .unreadable_tokens + .store(true, Ordering::Relaxed); + let app = port.application(); + + // When + app.sign_out_of_gateway().await?; + + // Then + assert_eq!(port.gateway.calls()?.revoked, Vec::::new()); + assert_eq!(port.gateway.stored()?.email, None); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + Ok(()) +} + +#[tokio::test] +async fn gateway_sign_out_that_cannot_clear_still_closes_the_app() -> Result<(), AppError> { + // Given + let port = port(); + port.gateway.seed(tokens("old"), None)?; + *lock(&port.gateway.clear_failure)? = Some(AppError::new("SECRET_WRITE_FAILED", "locked")); + let app = port.application(); + + // When + let error = failure(app.sign_out_of_gateway().await)?; + + // Then + assert_eq!(error.code, "GATEWAY_SIGN_OUT_INCOMPLETE"); + assert!(error.message.contains("다음 실행")); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + Ok(()) +} + +#[tokio::test] +async fn gateway_sign_out_during_sign_in_cancels_it_and_stores_nothing() -> Result<(), AppError> { + // Given: a sign-in waiting for the browser + let port = port(); + port.gateway.block_sign_in.store(true, Ordering::Relaxed); + let app = Arc::new(port.signed_out_application()); + let signing_in = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.sign_in_to_gateway().await }) + }; + port.gateway.sign_in_started.notified().await; + + // When + app.sign_out_of_gateway().await?; + let signed_in = failure( + signing_in + .await + .map_err(|error| AppError::new("TEST_JOIN", error.to_string()))?, + )?; + + // Then + assert_eq!(signed_in.code, "CANCELLED"); + assert_eq!(port.gateway.stored()?.tokens, None); + assert_eq!(port.gateway.calls()?.revoked, Vec::::new()); + assert_eq!(feature_codes(&app).await, ALL_REQUIRE_SIGN_IN); + Ok(()) +} + +#[tokio::test] +async fn gateway_cancelled_sign_in_keeps_the_stored_session() -> Result<(), AppError> { + // Given: a stored session and a second sign-in waiting for the browser + let port = port(); + port.gateway.seed(tokens("old"), Some("user@example.com"))?; + port.gateway.block_sign_in.store(true, Ordering::Relaxed); + let app = Arc::new(port.application()); + let signing_in = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.sign_in_to_gateway().await }) + }; + port.gateway.sign_in_started.notified().await; + + // When + app.cancel_gateway_sign_in()?; + let signed_in = failure( + signing_in + .await + .map_err(|error| AppError::new("TEST_JOIN", error.to_string()))?, + )?; + + // Then + assert_eq!(signed_in.code, "CANCELLED"); + assert_eq!(port.gateway.calls()?.revoked, Vec::::new()); + assert_eq!(port.gateway.stored()?.tokens, Some(tokens("old"))); + assert_eq!( + port.gateway.stored()?.email.as_deref(), + Some("user@example.com") + ); + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} diff --git a/src-tauri/src/application/use_cases.rs b/src-tauri/src/application/use_cases.rs index 143f856..8aaad31 100644 --- a/src-tauri/src/application/use_cases.rs +++ b/src-tauri/src/application/use_cases.rs @@ -1,5 +1,6 @@ use crate::application::chatgpt::ChatGptAccounts; use crate::application::error::AppError; +use crate::application::gateway::AppAccessGate; use crate::application::jobs::JobRegistry; use crate::application::model::{ EnvironmentStatus, RefinementResult, SetupResult, TranscriptImportResult, TranscriptionResult, @@ -15,6 +16,7 @@ use crate::domain::chatgpt::{ SignOutResult, }; use crate::domain::engine::{ComputeDevice, EnginePreset, EngineSelection}; +use crate::domain::gateway::AppAccess; use crate::domain::job::{ SetupRequest, TranscriptImportRequest, TranscriptionRequest, validate_speaker_hint, }; @@ -42,6 +44,7 @@ pub struct Application { settings: Arc, refinement: Arc, chatgpt: ChatGptAccounts, + access: AppAccessGate, jobs: JobRegistry, active_recording: tokio::sync::Mutex>, } @@ -58,6 +61,7 @@ impl Application { settings: Arc, refinement: Arc, chatgpt: ChatGptAccounts, + access: AppAccessGate, ) -> Self { Self { engine, @@ -68,11 +72,30 @@ impl Application { settings, refinement, chatgpt, + access, jobs: JobRegistry::default(), active_recording: tokio::sync::Mutex::new(None), } } + /// Whether the stored gateway session opens the app; renews it once. + pub async fn load_app_access(&self) -> Result { + self.access.restore().await + } + + /// Resolves when the browser sign-in finishes, is cancelled, or times out. + pub async fn sign_in_to_gateway(&self) -> Result { + self.access.sign_in().await + } + + pub fn cancel_gateway_sign_in(&self) -> Result<(), AppError> { + self.access.cancel_sign_in() + } + + pub async fn sign_out_of_gateway(&self) -> Result<(), AppError> { + self.access.sign_out().await + } + pub async fn diagnose(&self) -> Result { let selection = self.engine_selection().await?; self.engine.diagnose(selection).await @@ -86,6 +109,7 @@ impl Application { } pub async fn prepare(&self, request: SetupRequest) -> Result { + self.access.require()?; let request = if request.hugging_face_token.is_some() { request } else { @@ -152,6 +176,7 @@ impl Application { target: Uuid, attendees: &[String], ) -> Result { + self.access.require()?; let artifacts = self.jobs.artifacts(target)?; let assistant = self.settings.load_assistant().await?.trimmed(); let participants: Vec = assistant @@ -268,6 +293,7 @@ impl Application { &self, request: TranscriptionRequest, ) -> Result { + self.access.require()?; validate_speaker_hint(&request.speaker_hint) .map_err(|error| AppError::new("INVALID_SPEAKER_HINT", error.to_string()))?; let (job, mut cancel) = self.jobs.claim_with_id(request.job_id)?; @@ -281,6 +307,7 @@ impl Application { &self, request: TranscriptImportRequest, ) -> Result { + self.access.require()?; let (job, _unused_cancel) = self.jobs.claim_with_id(request.job_id)?; let job_id = job.id(); let result = self @@ -387,6 +414,7 @@ impl Application { } pub async fn start_recording(&self, output_root: String) -> Result { + self.access.require()?; let mut active = self.active_recording.lock().await; if active.is_some() { return Err(AppError::new( diff --git a/src-tauri/src/composition.rs b/src-tauri/src/composition.rs index 99afeb0..ff063f2 100644 --- a/src-tauri/src/composition.rs +++ b/src-tauri/src/composition.rs @@ -1,6 +1,8 @@ use crate::adapters::inbound::tauri::TauriEvents; -use crate::adapters::outbound::chatgpt::{ChatGptOAuthAdapter, SystemClock, TauriBrowser}; +use crate::adapters::outbound::browser_sign_in::TauriBrowser; +use crate::adapters::outbound::chatgpt::{ChatGptOAuthAdapter, SystemClock}; use crate::adapters::outbound::desktop::DesktopAdapter; +use crate::adapters::outbound::gateway::GatewayOAuthAdapter; use crate::adapters::outbound::recording::NativeRecorder; #[cfg(windows)] use crate::adapters::outbound::secrets::CredentialManager; @@ -9,10 +11,11 @@ use crate::adapters::outbound::secrets::SecretStore; use crate::adapters::outbound::secrets::SettingsFile; use crate::adapters::outbound::settings::LocalSettingsStore; use crate::application::chatgpt::ChatGptAccounts; +use crate::application::gateway::AppAccessGate; use crate::application::ports::{ - ArtifactPort, ChatGptAuthPort, ChatGptEvents, ChatGptStore, ClockPort, EnginePort, JobEvents, - RecordingEvents, RecordingPort, RefinementPort, SettingsPort, TranscriptImportPort, - TranscriptionPort, + ArtifactPort, ChatGptAuthPort, ChatGptEvents, ChatGptStore, ClockPort, EnginePort, + GatewayAuthPort, GatewaySessionStore, JobEvents, RecordingEvents, RecordingPort, + RefinementPort, SettingsPort, TranscriptImportPort, TranscriptionPort, }; use crate::application::use_cases::Application; use std::sync::Arc; @@ -44,13 +47,17 @@ pub fn run() { // settings.json would race each other's read-modify-write. let local_settings = Arc::new(LocalSettingsStore::new(app.handle(), secrets)?); let settings: Arc = local_settings.clone(); - let chatgpt_store: Arc = local_settings; + let chatgpt_store: Arc = local_settings.clone(); + let gateway_store: Arc = local_settings; let clock: Arc = Arc::new(SystemClock); + let browser = Arc::new(TauriBrowser::new(app.handle().clone())); let chatgpt_auth: Arc = Arc::new(ChatGptOAuthAdapter::new( - Arc::new(TauriBrowser::new(app.handle().clone())), + browser.clone(), chatgpt_events, clock.clone(), )?); + let gateway_auth: Arc = + Arc::new(GatewayOAuthAdapter::new(browser, clock.clone())?); app.manage(Application::new( engine, transcription, @@ -60,6 +67,7 @@ pub fn run() { settings, refinement, ChatGptAccounts::new(chatgpt_auth, chatgpt_store, clock), + AppAccessGate::new(gateway_auth, gateway_store), )); Ok(()) }) @@ -88,6 +96,10 @@ pub fn run() { crate::adapters::inbound::tauri::cancel_chatgpt_sign_in, crate::adapters::inbound::tauri::list_chatgpt_models, crate::adapters::inbound::tauri::sign_out_of_chatgpt, + crate::adapters::inbound::tauri::load_app_access, + crate::adapters::inbound::tauri::sign_in_to_gateway, + crate::adapters::inbound::tauri::cancel_gateway_sign_in, + crate::adapters::inbound::tauri::sign_out_of_gateway, ]) .run(tauri::generate_context!()) .unwrap_or_else(|error| { diff --git a/src-tauri/src/domain/gateway.rs b/src-tauri/src/domain/gateway.rs new file mode 100644 index 0000000..586162d --- /dev/null +++ b/src-tauri/src/domain/gateway.rs @@ -0,0 +1,100 @@ +//! The auth-gateway session that unlocks the app. +//! +//! `GatewayTokens` is secret and never travels in any IPC type: its `Debug` +//! hides every value and only the secret store serializes it. `AppAccess` is +//! what the window sees: the sign-in state and, at most, an email. + +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Debug, Formatter}; + +/// The Supabase session the gateway issued. Serialized only by the secret store. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayTokens { + pub access_token: String, + pub refresh_token: String, + /// Unix seconds. + pub expires_at: u64, +} + +impl Debug for GatewayTokens { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayTokens") + .field("access_token", &"") + .field("refresh_token", &"") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// A completed browser sign-in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayGrant { + pub tokens: GatewayTokens, + /// `None` when the gateway could not say who signed in; the sign-in stands. + pub email: Option, +} + +/// Whether the app may be used. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum AppAccess { + SignedOut, + SignedIn { + email: Option, + /// The gateway could not be reached at startup, so the stored session + /// was trusted without being renewed. + offline: bool, + }, +} + +/// Why renewing the stored session failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayRefreshFailure { + /// The gateway refused the session (`invalid_grant`): sign in again. + Rejected, + /// Anything else: network, server, or an unexpected answer. Keep the session. + Unavailable, +} + +#[cfg(test)] +mod tests { + use super::{AppAccess, GatewayTokens}; + use serde_json::json; + + #[test] + fn app_access_serializes_only_state_and_email() -> Result<(), serde_json::Error> { + // Given + let signed_in = AppAccess::SignedIn { + email: Some("user@example.com".to_owned()), + offline: true, + }; + + // When + let signed_in = serde_json::to_value(&signed_in)?; + let signed_out = serde_json::to_value(AppAccess::SignedOut)?; + + // Then + assert_eq!( + signed_in, + json!({"state": "signedIn", "email": "user@example.com", "offline": true}) + ); + assert_eq!(signed_out, json!({"state": "signedOut"})); + Ok(()) + } + + #[test] + fn gateway_tokens_debug_hides_every_token() { + let tokens = GatewayTokens { + access_token: "galpi-test-access".to_owned(), + refresh_token: "galpi-test-refresh".to_owned(), + expires_at: 7, + }; + + let printed = format!("{tokens:?}"); + + assert!(!printed.contains("galpi-test")); + assert!(printed.contains("expires_at: 7")); + } +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 31e549c..f1ed636 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -1,6 +1,7 @@ pub mod artifact; pub mod chatgpt; pub mod engine; +pub mod gateway; pub mod job; pub mod roster; pub mod worker; diff --git a/src/adapters/tauri-backend.test.ts b/src/adapters/tauri-backend.test.ts index 08a5ee8..528df44 100644 --- a/src/adapters/tauri-backend.test.ts +++ b/src/adapters/tauri-backend.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, test } from "bun:test" import { + appAccessSchema, chatGptModelsSchema, chatGptSettingsSchema, chatGptSignOutSchema, @@ -177,3 +178,22 @@ describe("parseChatGptEvent", () => { expect(parseChatGptEvent(null)).toBeNull() }) }) + +describe("appAccessSchema", () => { + test("reads both access states", () => { + expect(appAccessSchema.parse({ state: "signedOut" })).toEqual({ state: "signedOut" }) + expect( + appAccessSchema.parse({ state: "signedIn", email: "dev@example.com", offline: true }), + ).toEqual({ state: "signedIn", email: "dev@example.com", offline: true }) + }) + + test("rejects a response that carries a session token", () => { + const leaked = { state: "signedIn", email: null, offline: false, refreshToken: "secret" } + + expect(appAccessSchema.safeParse(leaked).success).toBe(false) + }) + + test("rejects a signed-in answer without the offline flag", () => { + expect(appAccessSchema.safeParse({ state: "signedIn", email: null }).success).toBe(false) + }) +}) diff --git a/src/adapters/tauri-backend.ts b/src/adapters/tauri-backend.ts index 99dd055..3ae2580 100644 --- a/src/adapters/tauri-backend.ts +++ b/src/adapters/tauri-backend.ts @@ -5,6 +5,7 @@ import { openUrl } from "@tauri-apps/plugin-opener" import { z } from "zod" import type { + AppAccess, ArtifactKind, BackendPort, RecordingFailure, @@ -119,6 +120,16 @@ export const chatGptSignOutSchema = z.strictObject({ const chatGptEventSchema = z.object({ phase: z.enum(["awaitingBrowser", "exchanging"]) }) +// Strict for the same reason: no session token may ever reach the window. +export const appAccessSchema = z.discriminatedUnion("state", [ + z.strictObject({ state: z.literal("signedOut") }), + z.strictObject({ + state: z.literal("signedIn"), + email: z.string().nullable(), + offline: z.boolean(), + }), +]) + const transcriptImportSchema = z.object({ jobId: z.string(), txt: z.string(), @@ -350,6 +361,22 @@ export class TauriBackend implements BackendPort { if (phase !== null) handler(phase) }) } + + async loadAppAccess(): Promise { + return appAccessSchema.parse(await invoke("load_app_access")) + } + + async signInToGateway(): Promise { + return appAccessSchema.parse(await invoke("sign_in_to_gateway")) + } + + async cancelGatewaySignIn(): Promise { + await invoke("cancel_gateway_sign_in") + } + + async signOutOfGateway(): Promise { + await invoke("sign_out_of_gateway") + } } /** diff --git a/src/application/access-machine.test.ts b/src/application/access-machine.test.ts new file mode 100644 index 0000000..2955f5b --- /dev/null +++ b/src/application/access-machine.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" + +import { + type AccessEvent, + type AccessState, + canSignIn, + initialAccess, + reduceAccess, +} from "./access-machine" + +const signedIn = { state: "signedIn", email: "user@example.com", offline: false } as const + +function run(events: readonly AccessEvent[]): AccessState { + return events.reduce(reduceAccess, initialAccess) +} + +describe("reduceAccess", () => { + test("a stored sign-in opens the app and a missing one asks to sign in", () => { + expect(run([{ type: "loaded", access: signedIn }])).toEqual({ + status: "signedIn", + email: "user@example.com", + offline: false, + }) + expect(run([{ type: "loaded", access: { state: "signedOut" } }])).toEqual({ + status: "signedOut", + error: null, + }) + }) + + test("a failed check offers both a new check and a fresh sign-in", () => { + // Given + const failed = run([{ type: "loadFailed", message: "읽지 못했습니다." }]) + + // Then + expect(failed).toEqual({ status: "failed", message: "읽지 못했습니다.", retryable: true }) + expect(canSignIn(failed)).toBe(true) + expect(reduceAccess(failed, { type: "checkStarted" })).toEqual(initialAccess) + expect(reduceAccess(failed, { type: "signInStarted" })).toEqual({ status: "signingIn" }) + }) + + test("an unreachable runtime leaves no way forward but a restart", () => { + // Given + const failed = run([{ type: "runtimeUnavailable", message: "다시 실행해 주세요." }]) + + // When + const after = [ + { type: "checkStarted" }, + { type: "signInStarted" }, + { type: "loaded", access: signedIn }, + ].reduce((state, event) => reduceAccess(state, event as AccessEvent), failed) + + // Then + expect(canSignIn(failed)).toBe(false) + expect(after).toEqual({ status: "failed", message: "다시 실행해 주세요.", retryable: false }) + }) + + test("a sign-in ends signed in, signed out with its error, or signed out when cancelled", () => { + const signingIn = run([ + { type: "loaded", access: { state: "signedOut" } }, + { type: "signInStarted" }, + ]) + + expect(signingIn).toEqual({ status: "signingIn" }) + expect(canSignIn(signingIn)).toBe(false) + expect(reduceAccess(signingIn, { type: "signInSucceeded", access: signedIn }).status).toBe( + "signedIn", + ) + expect(reduceAccess(signingIn, { type: "signInFailed", message: "실패" })).toEqual({ + status: "signedOut", + error: "실패", + }) + expect(reduceAccess(signingIn, { type: "signInCancelled" })).toEqual({ + status: "signedOut", + error: null, + }) + }) + + test("a sign-out returns to the login screen even when it fails", () => { + const signingOut = run([{ type: "loaded", access: signedIn }, { type: "signOutStarted" }]) + + expect(signingOut).toEqual({ status: "signingOut" }) + expect(canSignIn(signingOut)).toBe(false) + expect(reduceAccess(signingOut, { type: "signOutSucceeded" })).toEqual({ + status: "signedOut", + error: null, + }) + expect(reduceAccess(signingOut, { type: "signOutFailed", message: "지우지 못했습니다." })).toEqual( + { status: "signedOut", error: "지우지 못했습니다." }, + ) + }) + + test("outcomes that answer no pending attempt are ignored", () => { + const open = run([{ type: "loaded", access: signedIn }]) + + expect(reduceAccess(open, { type: "signInFailed", message: "늦은 실패" })).toBe(open) + expect(reduceAccess(open, { type: "loaded", access: { state: "signedOut" } })).toBe(open) + expect(reduceAccess(open, { type: "signInStarted" })).toBe(open) + }) +}) diff --git a/src/application/access-machine.ts b/src/application/access-machine.ts new file mode 100644 index 0000000..1827baf --- /dev/null +++ b/src/application/access-machine.ts @@ -0,0 +1,70 @@ +import type { AppAccess } from "../domain/backend" + +/** + * Whether the window may show the workspace, as the login screen sees it. + * `failed` is a check that could not finish: a retryable one offers both a + * new check and a fresh sign-in (which overwrites whatever the host could not + * read), an unretryable one only asks for a restart. + */ +export type AccessState = + | { readonly status: "checking" } + | { readonly status: "signedOut"; readonly error: string | null } + | { readonly status: "signingIn" } + | { readonly status: "signedIn"; readonly email: string | null; readonly offline: boolean } + | { readonly status: "signingOut" } + | { readonly status: "failed"; readonly message: string; readonly retryable: boolean } + +export type AccessEvent = + | { readonly type: "checkStarted" } + | { readonly type: "loaded"; readonly access: AppAccess } + | { readonly type: "loadFailed"; readonly message: string } + | { readonly type: "runtimeUnavailable"; readonly message: string } + | { readonly type: "signInStarted" } + | { readonly type: "signInSucceeded"; readonly access: AppAccess } + | { readonly type: "signInFailed"; readonly message: string } + | { readonly type: "signInCancelled" } + | { readonly type: "signOutStarted" } + | { readonly type: "signOutSucceeded" } + | { readonly type: "signOutFailed"; readonly message: string } + +export const initialAccess: AccessState = { status: "checking" } + +/** The login button shows only where a sign-in may begin. */ +export function canSignIn(state: AccessState): boolean { + return state.status === "signedOut" || (state.status === "failed" && state.retryable) +} + +export function reduceAccess(state: AccessState, event: AccessEvent): AccessState { + switch (event.type) { + case "checkStarted": + return state.status === "failed" && state.retryable ? initialAccess : state + case "loaded": + case "signInSucceeded": { + // A load answers a check, a sign-in answers its own attempt; any other + // arrival is stale. + if (state.status !== (event.type === "loaded" ? "checking" : "signingIn")) return state + const access = event.access + return access.state === "signedIn" + ? { status: "signedIn", email: access.email, offline: access.offline } + : { status: "signedOut", error: null } + } + case "loadFailed": + return state.status === "checking" + ? { status: "failed", message: event.message, retryable: true } + : state + case "runtimeUnavailable": + return { status: "failed", message: event.message, retryable: false } + case "signInStarted": + return canSignIn(state) ? { status: "signingIn" } : state + case "signInFailed": + return state.status === "signingIn" ? { status: "signedOut", error: event.message } : state + case "signInCancelled": + return state.status === "signingIn" ? { status: "signedOut", error: null } : state + case "signOutStarted": + return state.status === "signedIn" ? { status: "signingOut" } : state + case "signOutSucceeded": + return state.status === "signingOut" ? { status: "signedOut", error: null } : state + case "signOutFailed": + return state.status === "signingOut" ? { status: "signedOut", error: event.message } : state + } +} diff --git a/src/domain/backend.ts b/src/domain/backend.ts index 1616ccd..3a92512 100644 --- a/src/domain/backend.ts +++ b/src/domain/backend.ts @@ -62,8 +62,24 @@ export interface BackendPort { signOutOfChatGpt(): Promise openChatGptUsagePage(): Promise listenToChatGptEvents(handler: (phase: ChatGptSignInPhase) => void): Promise<() => void> + /** Renews the stored Google sign-in once; every feature command needs `signedIn`. */ + loadAppAccess(): Promise + /** Resolves once the browser sign-in finished, failed, was cancelled, or timed out. */ + signInToGateway(): Promise + cancelGatewaySignIn(): Promise + signOutOfGateway(): Promise } +/** Whether the app may be used. Only the state and the e-mail cross the IPC border. */ +export type AppAccess = + | { readonly state: "signedOut" } + | { + readonly state: "signedIn" + readonly email: string | null + /** The gateway was unreachable at startup, so the stored sign-in was trusted as is. */ + readonly offline: boolean + } + export interface SetupResult { readonly jobId: string readonly status: EnvironmentStatus diff --git a/src/styles.css b/src/styles.css index 6660204..333dc2e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -303,6 +303,78 @@ summary:focus-visible { font-size: var(--seed-font-size-t1); } +/* The offline mode is spelled out in the chip's second line. */ +.account-chip span[data-state="pending"] { + color: var(--seed-color-fg-warning-contrast); +} + +.sign-out-button { + flex: 0 0 auto; + word-break: keep-all; +} + +/* In front of the inert shell until someone signs in. */ +.access-screen { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: var(--seed-dimension-x5); + background: var(--seed-color-bg-neutral-muted); +} + +.access-card { + display: grid; + justify-items: start; + gap: var(--seed-dimension-x3); + width: min(480px, calc(100vw - 40px)); + padding: var(--seed-dimension-x8); + background: var(--seed-color-bg-layer-default); + border: 1px solid var(--seed-color-stroke-neutral-weak); + border-radius: var(--seed-radius-r4); + box-shadow: var(--seed-shadow-s3); +} + +.access-card h1 { + margin: 0; + font-size: var(--seed-font-size-t9); + font-weight: var(--seed-font-weight-medium); + letter-spacing: -0.03em; +} + +.access-card p { + margin: 0; + color: var(--seed-color-fg-neutral-muted); + font-size: var(--seed-font-size-t3); +} + +/* Status pairs its color with text, so state never rides on color alone. */ +.access-card .access-status { + color: var(--seed-color-fg-warning-contrast); + font-size: var(--seed-font-size-t2); + font-weight: var(--seed-font-weight-bold); +} + +.access-card .access-status[data-state="ready"] { + color: var(--seed-color-fg-positive-contrast); +} + +.access-card .access-status[data-state="error"] { + color: var(--seed-color-fg-critical-contrast); +} + +.access-actions { + display: flex; + flex-wrap: wrap; + gap: var(--seed-dimension-x3); + margin-top: var(--seed-dimension-x2); +} + +.access-actions button { + word-break: keep-all; +} + .workspace-body { min-height: 0; padding: var(--seed-dimension-x7) var(--seed-dimension-x10) var(--seed-dimension-x14); diff --git a/src/ui/access-controller.ts b/src/ui/access-controller.ts new file mode 100644 index 0000000..a16ea05 --- /dev/null +++ b/src/ui/access-controller.ts @@ -0,0 +1,93 @@ +import { + type AccessEvent, + type AccessState, + canSignIn, + initialAccess, + reduceAccess, +} from "../application/access-machine" +import { type AppAccess, type BackendPort, errorCode, errorMessage } from "../domain/backend" +import type { AccessScreenView } from "./access-screen" + +/** + * The Google sign-in through auth-gateway that every feature requires. + * + * The host enforces the rule on every feature command; this controller only + * decides what the window shows. `onOpened` runs each time the app opens, + * `onClosed` each time it closes again after a sign-out. + */ +export class AccessController { + private state: AccessState = initialAccess + + constructor( + private readonly backend: BackendPort, + private readonly view: AccessScreenView, + private readonly onOpened: () => Promise, + private readonly onClosed: () => void, + ) { + this.view.render(this.state) + } + + /** Ask the host whether the stored sign-in still opens the app. */ + async check(): Promise { + if (this.dispatch({ type: "checkStarted" }).status !== "checking") return + let access: AppAccess + try { + access = await this.backend.loadAppAccess() + } catch (error) { + this.dispatch({ type: "loadFailed", message: errorMessage(error) }) + return + } + if (this.dispatch({ type: "loaded", access }).status === "signedIn") await this.onOpened() + } + + /** No IPC can succeed without the event channel, so only a restart helps. */ + runtimeUnavailable(message: string): void { + this.dispatch({ type: "runtimeUnavailable", message }) + } + + async signIn(): Promise { + if (!canSignIn(this.state)) return + this.dispatch({ type: "signInStarted" }) + let access: AppAccess + try { + access = await this.backend.signInToGateway() + } catch (error) { + this.dispatch( + errorCode(error) === "CANCELLED" + ? { type: "signInCancelled" } + : { type: "signInFailed", message: errorMessage(error) }, + ) + return + } + if (this.dispatch({ type: "signInSucceeded", access }).status === "signedIn") { + await this.onOpened() + } + } + + async cancelSignIn(): Promise { + if (this.state.status !== "signingIn") return + try { + await this.backend.cancelGatewaySignIn() + } catch { + // The sign-in keeps waiting; its own result still settles the screen. + } + } + + async signOut(): Promise { + if (this.state.status !== "signedIn") return + this.dispatch({ type: "signOutStarted" }) + this.onClosed() + try { + await this.backend.signOutOfGateway() + this.dispatch({ type: "signOutSucceeded" }) + } catch (error) { + this.dispatch({ type: "signOutFailed", message: errorMessage(error) }) + } + } + + private dispatch(event: AccessEvent): AccessState { + this.state = reduceAccess(this.state, event) + this.view.render(this.state) + return this.state + } +} diff --git a/src/ui/access-screen.ts b/src/ui/access-screen.ts new file mode 100644 index 0000000..e9ba4ff --- /dev/null +++ b/src/ui/access-screen.ts @@ -0,0 +1,74 @@ +import { type AccessState, canSignIn } from "../application/access-machine" +import { required } from "./dom" + +const SIGNING_IN = + "브라우저에서 Google 로그인을 마쳐 주세요. 로그인 창이 보이지 않으면 취소 후 다시 시도하세요." + +/** + * The login screen in front of the app shell, and the account chip in the + * top bar. The shell stays inert until someone is signed in, so nothing + * behind the screen can be reached by pointer, keyboard, or screen reader. + */ +export class AccessScreenView { + private readonly root: HTMLElement + + constructor(root: HTMLElement) { + this.root = root + } + + render(state: AccessState): void { + const open = state.status === "signedIn" + this.element("#access-screen").hidden = open + this.element(".app-shell").toggleAttribute("inert", !open) + + const [message, tone] = statusLine(state) + const status = this.element("#access-status") + status.textContent = message + status.dataset["state"] = tone + this.element("#access-sign-in-button").hidden = !canSignIn(state) + this.element("#access-retry-button").hidden = !(state.status === "failed" && state.retryable) + this.element("#access-cancel-button").hidden = state.status !== "signingIn" + + this.element("#account-chip").hidden = !open + this.element("#sign-out-button").hidden = !open + if (state.status === "signedIn") { + const email = this.element("#account-email") + email.textContent = state.email ?? "Google 계정" + email.dataset["state"] = state.offline ? "pending" : "ready" + this.element("#account-mode").textContent = state.offline + ? "오프라인 · 저장된 로그인 사용" + : "온라인" + } + } + + /** Signing out mid-recording or mid-job would strand the work, so it waits. */ + setSignOutBlocked(blocked: boolean): void { + this.element("#sign-out-button").disabled = blocked + } + + private element(selector: string): T { + return required(this.root, selector) + } +} + +function statusLine(state: AccessState): readonly [string, "pending" | "ready" | "error"] { + switch (state.status) { + case "checking": + return ["로그인 상태를 확인하는 중입니다.", "pending"] + case "signedOut": + return state.error === null + ? ["Google 계정으로 로그인하면 바로 사용할 수 있습니다.", "ready"] + : [state.error, "error"] + case "signingIn": + return [SIGNING_IN, "pending"] + case "signingOut": + return ["로그아웃하는 중입니다.", "pending"] + case "failed": + return [ + state.retryable ? `${state.message} 다시 확인하거나 다시 로그인해 주세요.` : state.message, + "error", + ] + case "signedIn": + return ["로그인했습니다.", "ready"] + } +} diff --git a/src/ui/access.dom.test.ts b/src/ui/access.dom.test.ts new file mode 100644 index 0000000..a7afbf9 --- /dev/null +++ b/src/ui/access.dom.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test" +import { Window } from "happy-dom" + +import type { AppAccess, BackendPort } from "../domain/backend" +import { AppView } from "./app-view" +import { AppController } from "./controller" + +const signedIn: AppAccess = { state: "signedIn", email: "dev@example.com", offline: false } + +// The controller continues in microtasks after a backend promise settles. +async function settle(): Promise { + for (let tick = 0; tick < 25; tick += 1) await Promise.resolve() +} + +/** A native runtime with a ready workspace; `calls` records gateway and workspace calls. */ +function backendWith(overrides: Partial, calls: string[]): BackendPort { + const unused = () => Promise.reject(new Error("unused")) + const record = + (name: string, value: T) => + async (): Promise => { + calls.push(name) + return value + } + return { + diagnose: record("diagnose", { + enginePreset: "qwen3" as const, + engineReady: true, + modelsReady: true, + ffmpegReady: true, + qwen3Ready: true, + whisperxReady: false, + dataDirectory: "/tmp/galpi", + defaultOutputDirectory: "/tmp/galpi/out", + engineVersion: "test", + computeDevice: "cpu" as const, + availablePresets: ["qwen3", "whisperx"] as const, + availableDevices: [] as const, + cudaDriverDetected: false, + }), + prepare: unused, + huggingFaceTokenStored: async () => false, + saveHuggingFaceToken: unused, + loadAssistantSettings: async () => ({ + apiKeyStored: false, + model: null, + baseUrl: null, + reasoningEffort: null, + background: null, + participants: [], + glossary: [], + }), + saveAssistantApiKey: unused, + saveAssistantSettings: unused, + saveEnginePreset: unused, + saveComputeDevice: unused, + refineTranscript: unused, + transcribe: unused, + importTranscript: unused, + cancel: unused, + openArtifact: unused, + revealOutput: unused, + startRecording: unused, + stopRecording: unused, + cancelRecording: unused, + listenToRecordingFailures: async () => () => undefined, + chooseAudio: unused, + chooseTranscript: unused, + chooseOutputDirectory: unused, + openModelAccessPage: unused, + listenToJobs: async () => () => undefined, + loadChatGptSettings: async () => ({ + authMode: "apiKey", + model: null, + welcomeAcknowledged: true, + account: { state: "signedOut", email: null }, + }), + saveChatGptPreferences: unused, + signInWithChatGpt: unused, + cancelChatGptSignIn: unused, + listChatGptModels: unused, + signOutOfChatGpt: unused, + openChatGptUsagePage: unused, + listenToChatGptEvents: async () => () => undefined, + loadAppAccess: record("loadAppAccess", { state: "signedOut" } as AppAccess), + signInToGateway: record("signInToGateway", signedIn), + cancelGatewaySignIn: record("cancelGatewaySignIn", undefined), + signOutOfGateway: record("signOutOfGateway", undefined), + ...overrides, + } +} + +async function start( + overrides: Partial, +): Promise<{ root: HTMLElement; view: AppView; controller: AppController; calls: string[] }> { + const calls: string[] = [] + const window = new Window() + const root = window.document.createElement("div") as unknown as HTMLElement + window.document.body.appendChild(root as unknown as never) + const view = new AppView(root) + const controller = new AppController(backendWith(overrides, calls), view) + await controller.start() + return { root, view, controller, calls } +} + +function pick(root: HTMLElement, selector: string): T { + const element = root.querySelector(selector) + if (element === null) throw new Error(`missing ${selector}`) + return element +} + +function click(root: HTMLElement, action: string): void { + pick(root, `[data-action="${action}"]`).click() +} + +function shellInert(root: HTMLElement): boolean { + return pick(root, ".app-shell").hasAttribute("inert") +} + +describe("Google sign-in gate (real DOM)", () => { + test("without a stored sign-in only the login screen is usable", async () => { + // Given / When + const { root, controller, calls } = await start({}) + + // Then + expect(pick(root, "#access-screen").hidden).toBe(false) + expect(shellInert(root)).toBe(true) + expect(pick(root, "#access-sign-in-button").hidden).toBe(false) + expect(pick(root, "#access-status").textContent).toContain("Google 계정으로 로그인") + expect(calls).not.toContain("diagnose") + controller.stop() + }) + + test("a check that fails shows the error with both a retry and a sign-in", async () => { + // Given + let attempts = 0 + const { root, controller } = await start({ + loadAppAccess: async () => { + attempts += 1 + if (attempts === 1) { + throw { code: "SECRET_READ_FAILED", message: "저장된 로그인 정보를 읽지 못했습니다." } + } + return signedIn + }, + }) + + // Then + const status = pick(root, "#access-status") + expect(status.dataset["state"]).toBe("error") + expect(status.textContent).toContain("저장된 로그인 정보를 읽지 못했습니다.") + expect(pick(root, "#access-sign-in-button").hidden).toBe(false) + expect(pick(root, "#access-retry-button").hidden).toBe(false) + + // When: the user checks again + click(root, "retry-app-access") + await settle() + + // Then + expect(pick(root, "#access-screen").hidden).toBe(true) + expect(shellInert(root)).toBe(false) + controller.stop() + }) + + test("a pending browser sign-in offers only cancel and starts no second attempt", async () => { + // Given: a sign-in the user has not finished in the browser + const pending = Promise.withResolvers() + let signIns = 0 + const { root, controller } = await start({ + signInToGateway: () => { + signIns += 1 + return pending.promise + }, + cancelGatewaySignIn: async () => { + pending.reject({ code: "CANCELLED", message: "사용자가 작업을 취소했습니다." }) + }, + }) + + // When + click(root, "sign-in-gateway") + await settle() + click(root, "sign-in-gateway") + await settle() + + // Then + expect(signIns).toBe(1) + expect(pick(root, "#access-sign-in-button").hidden).toBe(true) + expect(pick(root, "#access-cancel-button").hidden).toBe(false) + expect(pick(root, "#access-status").textContent).toContain("브라우저에서 Google 로그인") + + // When: the user cancels + click(root, "cancel-gateway-sign-in") + await settle() + + // Then: back to the start without an error + expect(pick(root, "#access-cancel-button").hidden).toBe(true) + expect(pick(root, "#access-sign-in-button").hidden).toBe(false) + expect(pick(root, "#access-status").dataset["state"]).toBe("ready") + controller.stop() + }) + + test("signing in opens the app and loads the workspace", async () => { + // Given + const { root, controller, calls } = await start({}) + + // When + click(root, "sign-in-gateway") + await settle() + + // Then + expect(calls.indexOf("signInToGateway")).toBeLessThan(calls.indexOf("diagnose")) + expect(pick(root, "#access-screen").hidden).toBe(true) + expect(shellInert(root)).toBe(false) + expect(pick(root, "#account-chip").hidden).toBe(false) + expect(pick(root, "#account-email").textContent).toBe("dev@example.com") + expect(pick(root, "#account-mode").textContent).toBe("온라인") + controller.stop() + }) + + test("a stored sign-in the gateway could not renew says the app is offline", async () => { + const { root, controller } = await start({ + loadAppAccess: async () => ({ state: "signedIn", email: null, offline: true }), + }) + + expect(pick(root, "#access-screen").hidden).toBe(true) + expect(pick(root, "#account-email").textContent).toBe("Google 계정") + expect(pick(root, "#account-mode").textContent).toBe("오프라인 · 저장된 로그인 사용") + controller.stop() + }) + + test("signing out returns to the login screen, with the error when it failed", async () => { + // Given: a signed-in app whose sign-out cannot clear the stored session + const { root, controller } = await start({ + loadAppAccess: async () => signedIn, + signOutOfGateway: async () => { + throw { code: "GATEWAY_SIGN_OUT_INCOMPLETE", message: "로그인 정보를 지우지 못했습니다." } + }, + }) + + // When + click(root, "sign-out-gateway") + await settle() + + // Then + expect(pick(root, "#access-screen").hidden).toBe(false) + expect(shellInert(root)).toBe(true) + expect(pick(root, "#account-chip").hidden).toBe(true) + expect(pick(root, "#access-status").textContent).toBe("로그인 정보를 지우지 못했습니다.") + expect(pick(root, "#access-sign-in-button").hidden).toBe(false) + controller.stop() + }) + + test("sign-out waits while a job runs", async () => { + const { root, view, controller } = await start({ loadAppAccess: async () => signedIn }) + + view.setBusy("transcription") + expect(pick(root, "#sign-out-button").disabled).toBe(true) + + view.setBusy(null) + expect(pick(root, "#sign-out-button").disabled).toBe(false) + controller.stop() + }) +}) diff --git a/src/ui/app-template.ts b/src/ui/app-template.ts index ea2ccaa..c65bff8 100644 --- a/src/ui/app-template.ts +++ b/src/ui/app-template.ts @@ -10,6 +10,20 @@ const recordAction = actionButtonClass("neutralOutline", "xsmall") const dismiss = actionButtonClass("ghost", "xsmall", "iconOnly") export const appTemplate = ` +
+
+ + GALPI ACCOUNT +

Google 계정으로 로그인

+

갈피를 사용하려면 Google 계정으로 로그인해야 합니다. 로그인은 시스템 브라우저에서 진행되며, 갈피는 비밀번호를 보지 않습니다.

+

로그인 상태를 확인하는 중입니다.

+
+ + + +
+
+