From 09c231b1c4779b4d89a136896ddb7728d92cebca Mon Sep 17 00:00:00 2001 From: m16khb Date: Fri, 9 Oct 2026 20:35:38 +0900 Subject: [PATCH 1/3] feat(app): require auth-gateway Google sign-in before any feature Why: galpi#10 requires a Google sign-in through auth-gateway before the app can be used. Sign-in follows RFC 8252 (system browser, loopback redirect, PKCE) against the gateway's /auth/native endpoints; any signed-in Google account is allowed, a stored session keeps the app usable offline, and only a 400 invalid_grant from the gateway or a sign-out discards it. Lore: - Intent: Gate every feature behind an auth-gateway session, enforced by the host. - Changes: - Move the browser opener, loopback listener, and PKCE out of chatgpt/ into adapters/outbound/browser_sign_in/; callers pass SignInCodes, so ChatGPT keeps its error codes. - Add domain/gateway.rs, GatewayAuthPort/GatewaySessionStore, application/gateway.rs AppAccessGate, and AUTH_REQUIRED on prepare, refine_transcript, transcribe, import_transcript, start_recording. - Add GatewayOAuthAdapter, Secret::GatewaySession with settings/gateway.rs, four Tauri commands, and composition wiring. - Add the login screen in front of the inert shell, the account chip with sign-out, access-machine, access-controller, and the strict Zod AppAccess schema. - Update README (ko/en), DESIGN.md, AGENTS.md files, ARCHITECTURE.md, OPEN_API_SPEC.md, and overviews to 28 commands and 15 ports. - Tested: cargo fmt --check; cargo clippy --all-targets -D warnings; cargo test --all-targets (224 passed); bun run check; bun test (201 pass); python3 scripts/verify-docs-platform.py; issueops gates check (11/11) - Not-tested: real sign-in against a running auth-gateway (needs Supabase/Redis credentials; deferred until auth-gateway#8 is deployed); Windows Credential Manager entry on real Windows hardware. - Risk: The first launch after updating needs an online Google sign-in. macOS keeps the gateway refresh token in the 0600 settings file until the app is signed. --- .issueops/OPEN_API_SPEC.md | 2 +- .issueops/architecture/overview.md | 8 +- .issueops/conventions/overview.md | 2 +- AGENTS.md | 10 +- DESIGN.md | 10 +- README.en.md | 22 +- README.md | 22 +- docs/ARCHITECTURE.md | 19 +- src-tauri/AGENTS.md | 2 +- src-tauri/src/adapters/inbound/tauri.rs | 26 + src-tauri/src/adapters/outbound/AGENTS.md | 5 +- .../{chatgpt => browser_sign_in}/browser.rs | 9 +- .../fake_server.rs} | 0 .../{chatgpt => browser_sign_in}/loopback.rs | 4 +- .../loopback/tests.rs | 13 +- .../adapters/outbound/browser_sign_in/mod.rs | 22 + .../{chatgpt => browser_sign_in}/pkce.rs | 17 +- .../adapters/outbound/chatgpt/authorize.rs | 13 +- .../src/adapters/outbound/chatgpt/mod.rs | 20 +- .../adapters/outbound/chatgpt/testing/mod.rs | 4 +- .../adapters/outbound/chatgpt/tests/models.rs | 2 +- .../outbound/chatgpt/tests/sign_in.rs | 2 +- .../outbound/chatgpt/tests/support.rs | 9 +- .../adapters/outbound/chatgpt/tests/tokens.rs | 2 +- .../src/adapters/outbound/gateway/mod.rs | 280 ++++++++++ .../src/adapters/outbound/gateway/tests.rs | 354 +++++++++++++ src-tauri/src/adapters/outbound/mod.rs | 2 + src-tauri/src/adapters/outbound/secrets.rs | 5 +- src-tauri/src/adapters/outbound/settings.rs | 22 + .../src/adapters/outbound/settings/gateway.rs | 187 +++++++ src-tauri/src/application/gateway.rs | 174 +++++++ src-tauri/src/application/mod.rs | 1 + src-tauri/src/application/ports.rs | 28 + src-tauri/src/application/tests.rs | 30 +- src-tauri/src/application/tests/gateway.rs | 483 ++++++++++++++++++ src-tauri/src/application/use_cases.rs | 28 + src-tauri/src/composition.rs | 24 +- src-tauri/src/domain/gateway.rs | 100 ++++ src-tauri/src/domain/mod.rs | 1 + src/adapters/tauri-backend.test.ts | 20 + src/adapters/tauri-backend.ts | 27 + src/application/access-machine.test.ts | 99 ++++ src/application/access-machine.ts | 70 +++ src/domain/backend.ts | 16 + src/styles.css | 72 +++ src/ui/access-controller.ts | 93 ++++ src/ui/access-screen.ts | 74 +++ src/ui/access.dom.test.ts | 261 ++++++++++ src/ui/app-template.ts | 16 + src/ui/app-view.ts | 4 + src/ui/chatgpt-settings.dom.test.ts | 4 + src/ui/controller.test.ts | 87 ++-- src/ui/controller.ts | 27 +- src/ui/settings-autosave.dom.test.ts | 6 + 54 files changed, 2711 insertions(+), 129 deletions(-) rename src-tauri/src/adapters/outbound/{chatgpt => browser_sign_in}/browser.rs (74%) rename src-tauri/src/adapters/outbound/{chatgpt/testing/server.rs => browser_sign_in/fake_server.rs} (100%) rename src-tauri/src/adapters/outbound/{chatgpt => browser_sign_in}/loopback.rs (98%) rename src-tauri/src/adapters/outbound/{chatgpt => browser_sign_in}/loopback/tests.rs (94%) create mode 100644 src-tauri/src/adapters/outbound/browser_sign_in/mod.rs rename src-tauri/src/adapters/outbound/{chatgpt => browser_sign_in}/pkce.rs (77%) create mode 100644 src-tauri/src/adapters/outbound/gateway/mod.rs create mode 100644 src-tauri/src/adapters/outbound/gateway/tests.rs create mode 100644 src-tauri/src/adapters/outbound/settings/gateway.rs create mode 100644 src-tauri/src/application/gateway.rs create mode 100644 src-tauri/src/application/tests/gateway.rs create mode 100644 src-tauri/src/domain/gateway.rs create mode 100644 src/application/access-machine.test.ts create mode 100644 src/application/access-machine.ts create mode 100644 src/ui/access-controller.ts create mode 100644 src/ui/access-screen.ts create mode 100644 src/ui/access.dom.test.ts 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/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/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/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 계정으로 로그인해야 합니다. 로그인은 시스템 브라우저에서 진행되며, 갈피는 비밀번호를 보지 않습니다.

+

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

+
+ + + +
+
+