From ba9ef9741e2095790450f634b1a4550398ff7a10 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 20:45:52 +0900 Subject: [PATCH] fix: red-team round 3 hardening across desktop, mobile, core and server Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows. --- apps/desktop/sidecar/main.py | 161 +++- .../tests/test_sidecar_redteam_r3_1.py | 134 +++ apps/desktop/src/main/index.ts | 6 + apps/desktop/src/main/ipc/caption-handlers.ts | 7 +- .../src/main/ipc/input-telemetry-handlers.ts | 5 +- apps/desktop/src/main/ipc/stt-handlers.ts | 7 +- .../main/ipc/voice-conversation-handlers.ts | 6 +- .../src/main/services/CaptionService.ts | 35 +- .../src/main/services/CloudSyncService.ts | 117 ++- .../main/services/DictationTemplateService.ts | 18 + .../src/main/services/DictionaryService.ts | 197 +--- .../src/main/services/HistoryService.ts | 7 +- .../main/services/InputTelemetryService.ts | 32 +- .../src/main/services/KeyBindingService.ts | 33 +- .../src/main/services/LocalLLMService.ts | 12 + .../src/main/services/LocalSTTService.ts | 23 +- .../src/main/services/MeetingModeService.ts | 195 +++- .../main/services/MeetingSummaryService.ts | 7 +- .../src/main/services/PremiumLLMService.ts | 22 +- apps/desktop/src/main/services/RAGService.ts | 49 +- .../src/main/services/RuntimeProvisioner.ts | 37 +- .../src/main/services/SuggestionRepository.ts | 40 +- .../src/main/services/SuggestionService.ts | 73 +- .../src/main/services/VoiceCommandService.ts | 212 ++--- .../services/caption/StreamingCaptionTrack.ts | 21 +- .../main/services/cloud/cloud-credentials.ts | 62 ++ .../dictionary/dictionary-file-codec.ts | 211 ++++ .../src/main/services/llm/model-residency.ts | 56 ++ .../services/meeting/diarization-context.ts | 56 ++ .../meeting/local-recording-ledger.ts | 38 + .../services/meeting/transcript-condenser.ts | 3 +- .../meeting/transcript-revision-store.ts | 69 ++ .../src/main/services/rag/remote-document.ts | 44 + .../services/recording/recording-store.ts | 55 ++ .../main/services/runtime/runtime-index.ts | 68 +- .../src/main/services/stt/audio-utils.ts | 17 + .../services/stt/drivers/D3ROCloudDriver.ts | 10 +- .../services/suggestion/ModelWarmTracker.ts | 19 +- .../src/main/services/sync/SyncEngine.ts | 57 +- .../sync/active-instruction-push-policy.ts | 52 + .../src/main/services/sync/audio-sync.ts | 20 +- .../main/services/sync/push-gate-policy.ts | 18 + .../src/main/services/sync/settings-sync.ts | 57 +- .../src/main/services/sync/sync-adapters.ts | 7 +- .../src/main/services/sync/sync-outbox.ts | 10 +- .../src/main/services/voice-command-policy.ts | 245 +++++ apps/desktop/src/main/update-policy.ts | 20 +- .../desktop/src/main/voice-session-persist.ts | 10 +- apps/desktop/src/main/windows/app-menu.ts | 23 + .../main/index-activate-redteam-r3-4.test.ts | 46 + .../tests/main/ipc/ipc-redteam-r3-1.test.ts | 138 +++ .../services/dictionary-file-codec.test.ts | 89 ++ .../services/dictionary-redteam-r3-26.test.ts | 180 ++++ .../keybinding-altgr-redteam-r3-8.test.ts | 104 ++ .../meeting-condense-redteam-r3-3.test.ts | 28 + .../runtime-provisioner-redteam-r3-5.test.ts | 195 ++++ .../services/stt-timeout-redteam-r3-1.test.ts | 21 + .../services/suggestion-redteam-r3-1.test.ts | 317 +++++++ .../voice-command-redteam-r3-9.test.ts | 168 ++++ ...voice-recording-store-redteam-r3-1.test.ts | 282 ++++++ .../main/sync/audio-sync-redteam-r3-1.test.ts | 81 ++ .../cloud-credentials-redteam-r3-1.test.ts | 264 +++++ .../knowledge-reindex-redteam-r3-2.test.ts | 159 ++++ .../tests/main/sync/sync-redteam-r3-0.test.ts | 269 ++++++ .../main/update-policy-redteam-r3-5.test.ts | 83 ++ .../meeting-recording-redteam-r2-1.test.ts | 6 + .../templates-field-ids-redteam-r3-7.test.ts | 90 ++ .../unit/error-recovery-redteam-r3-21.test.ts | 145 +++ ...nding-picker-capture-redteam-r3-23.test.ts | 147 +++ .../stt-model-download-redteam-r3-24.test.ts | 129 +++ ...system-audio-capture-redteam-r3-22.test.ts | 319 +++++++ .../account-deletion-redteam-r3-19.test.ts | 224 +++++ .../account-retention-redteam-r3-16.test.ts | 214 +++++ .../mobile-rn/__tests__/auth-redirect.test.ts | 15 +- .../__tests__/auth-redteam-r3-16.test.tsx | 298 ++++++ ...able-queue-retention-redteam-r3-16.test.ts | 139 +++ .../knowledge-realtime-redteam-r3-20.test.ts | 166 ++++ .../__tests__/knowledge-service.test.ts | 6 +- ...ing-rerecord-failure-redteam-r3-15.test.ts | 142 +++ .../preferences-resync-redteam-r3-18.test.tsx | 148 +++ .../__tests__/preferences-store.test.ts | 356 +++++++ .../stt-cloud-client-redteam-r3-17.test.ts | 314 ++++++ .../account/account-deletion-service.ts | 122 +++ .../import/audio-transcription-service.ts | 217 +---- .../src/features/import/linked-deadline.ts | 35 + .../src/features/import/stt-cloud-client.ts | 170 ++++ .../src/features/import/stt-engine.ts | 74 ++ .../src/features/import/stt-policy.ts | 45 + .../features/knowledge/knowledge-realtime.ts | 110 +++ .../features/knowledge/knowledge-service.ts | 50 +- .../meeting-recording-state-policy.ts | 39 + .../src/features/meetings/meetings-service.ts | 73 +- .../recording/durable-processing-queue.ts | 31 + .../talk/talk-transcription-service.ts | 203 +--- .../src/features/teams/team-service.ts | 48 +- apps/mobile-rn/src/lib/account-local-data.ts | 110 ++- apps/mobile-rn/src/lib/auth-context.tsx | 42 +- apps/mobile-rn/src/lib/auth-redirect.ts | 71 +- .../src/lib/auth-transition-policy.ts | 69 ++ apps/mobile-rn/src/lib/billing-context.tsx | 73 +- apps/mobile-rn/src/lib/edge-functions.ts | 129 +++ apps/mobile-rn/src/lib/logger.ts | 26 + .../mobile-rn/src/lib/preferences-adapters.ts | 81 ++ .../mobile-rn/src/lib/preferences-context.tsx | 858 ++--------------- apps/mobile-rn/src/lib/preferences-store.ts | 898 ++++++++++++++++++ apps/mobile-rn/src/lib/retain-live-capture.ts | 57 ++ .../src/lib/retained-account-work.ts | 77 ++ apps/mobile-rn/src/screens/AccountScreen.tsx | 78 +- .../__tests__/public-surface-r2-35.test.ts | 55 ++ packages/api-client/package.json | 16 - packages/api-client/src/auth.ts | 58 -- .../dictionary-limits-redteam-r3-26.test.ts | 113 +++ .../input-suggestion-redteam-r3-1.test.ts | 100 ++ .../keybinding-altgr-redteam-r3-8.test.ts | 286 ++++++ ...ting-markdown-inline-redteam-r3-27.test.ts | 95 ++ .../meeting-transcript-segments.test.ts | 8 +- .../__tests__/template-field-policy.test.ts | 59 ++ .../transcript-chunking-redteam-r3-3.test.ts | 107 +++ packages/core/package.json | 4 + packages/core/src/caption-streaming.ts | 34 +- packages/core/src/dictionary-policy.ts | 88 +- packages/core/src/input-intelligence.ts | 42 +- packages/core/src/keybinding-runtime.ts | 104 +- packages/core/src/meeting-llm-input.ts | 2 +- packages/core/src/meeting-transcript.ts | 34 +- packages/core/src/suggestion-text.ts | 23 +- packages/core/src/template-field-policy.ts | 60 ++ packages/core/src/text-chunking.ts | 80 ++ packages/core/src/utils/markdown-to-docx.ts | 25 +- packages/core/src/utils/meeting-markdown.ts | 36 +- packages/core/src/voice-error-message.ts | 67 ++ scripts/ci/build-portable.mjs | 26 +- .../ci/lib/forgejo-generic-file-delete.mjs | 27 + scripts/ci/lib/portable-alias-plan.mjs | 110 +++ .../ci/lib/portable-publish-alias.test.mjs | 423 +++++++++ scripts/ci/lib/portable-publish-policy.mjs | 297 ++++-- scripts/ci/lib/runtime-feed-gate.mjs | 121 +++ scripts/ci/lib/runtime-feed-gate.test.mjs | 129 +++ scripts/ci/lib/runtime-index-builder.mjs | 52 + scripts/ci/lib/runtime-index-builder.test.mjs | 48 + scripts/ci/lib/update-policy-schema.mjs | 97 ++ scripts/ci/lib/update-policy-schema.test.mjs | 55 ++ scripts/ci/publish-forgejo-release.mjs | 30 +- scripts/ci/publish-portable-release.mjs | 52 +- scripts/ci/publish-updater-release.mjs | 32 + .../generate-meeting-document/generation.ts | 13 +- .../functions/payple-webhook/handler.test.ts | 118 +++ .../functions/payple-webhook/index.ts | 401 ++++---- .../payple-webhook/webhook-policy.test.ts | 160 ++++ .../payple-webhook/webhook-policy.ts | 242 +++++ ...0260929010000_team_rpc_null_role_guard.sql | 358 +++++++ ...0260929020000_unify_llm_quota_inflight.sql | 830 ++++++++++++++++ ...29030000_knowledge_chunks_vector_index.sql | 102 ++ ...9040000_meeting_rerecord_failure_paths.sql | 345 +++++++ ...100011_payple_cancellation_order_scope.sql | 380 ++++++++ ...ledge-chunks-vector-search.integration.sql | 307 ++++++ ...-document-generation-quota.integration.sql | 206 +++- ...g-document-llm-quota-lease.integration.sql | 341 +++++++ ...ile-rerecord-failure-paths.integration.sql | 341 +++++++ ...e-cancellation-order-scope.integration.sql | 207 ++++ .../team-rpc-null-role-guard.integration.sql | 338 +++++++ 161 files changed, 17056 insertions(+), 2379 deletions(-) create mode 100644 apps/desktop/sidecar/tests/test_sidecar_redteam_r3_1.py create mode 100644 apps/desktop/src/main/services/cloud/cloud-credentials.ts create mode 100644 apps/desktop/src/main/services/dictionary/dictionary-file-codec.ts create mode 100644 apps/desktop/src/main/services/llm/model-residency.ts create mode 100644 apps/desktop/src/main/services/meeting/diarization-context.ts create mode 100644 apps/desktop/src/main/services/meeting/local-recording-ledger.ts create mode 100644 apps/desktop/src/main/services/meeting/transcript-revision-store.ts create mode 100644 apps/desktop/src/main/services/rag/remote-document.ts create mode 100644 apps/desktop/src/main/services/recording/recording-store.ts create mode 100644 apps/desktop/src/main/services/sync/active-instruction-push-policy.ts create mode 100644 apps/desktop/src/main/services/sync/push-gate-policy.ts create mode 100644 apps/desktop/src/main/services/voice-command-policy.ts create mode 100644 apps/desktop/src/main/windows/app-menu.ts create mode 100644 apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts create mode 100644 apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts create mode 100644 apps/desktop/tests/main/services/dictionary-file-codec.test.ts create mode 100644 apps/desktop/tests/main/services/dictionary-redteam-r3-26.test.ts create mode 100644 apps/desktop/tests/main/services/keybinding-altgr-redteam-r3-8.test.ts create mode 100644 apps/desktop/tests/main/services/meeting-condense-redteam-r3-3.test.ts create mode 100644 apps/desktop/tests/main/services/runtime-provisioner-redteam-r3-5.test.ts create mode 100644 apps/desktop/tests/main/services/stt-timeout-redteam-r3-1.test.ts create mode 100644 apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts create mode 100644 apps/desktop/tests/main/services/voice-command-redteam-r3-9.test.ts create mode 100644 apps/desktop/tests/main/services/voice-recording-store-redteam-r3-1.test.ts create mode 100644 apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts create mode 100644 apps/desktop/tests/main/sync/cloud-credentials-redteam-r3-1.test.ts create mode 100644 apps/desktop/tests/main/sync/knowledge-reindex-redteam-r3-2.test.ts create mode 100644 apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts create mode 100644 apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts create mode 100644 apps/desktop/tests/red/templates-field-ids-redteam-r3-7.test.ts create mode 100644 apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts create mode 100644 apps/desktop/tests/unit/keybinding-picker-capture-redteam-r3-23.test.ts create mode 100644 apps/desktop/tests/unit/stt-model-download-redteam-r3-24.test.ts create mode 100644 apps/desktop/tests/unit/system-audio-capture-redteam-r3-22.test.ts create mode 100644 apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts create mode 100644 apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts create mode 100644 apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx create mode 100644 apps/mobile-rn/__tests__/durable-queue-retention-redteam-r3-16.test.ts create mode 100644 apps/mobile-rn/__tests__/knowledge-realtime-redteam-r3-20.test.ts create mode 100644 apps/mobile-rn/__tests__/meeting-rerecord-failure-redteam-r3-15.test.ts create mode 100644 apps/mobile-rn/__tests__/preferences-resync-redteam-r3-18.test.tsx create mode 100644 apps/mobile-rn/__tests__/preferences-store.test.ts create mode 100644 apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts create mode 100644 apps/mobile-rn/src/features/account/account-deletion-service.ts create mode 100644 apps/mobile-rn/src/features/import/linked-deadline.ts create mode 100644 apps/mobile-rn/src/features/import/stt-cloud-client.ts create mode 100644 apps/mobile-rn/src/features/import/stt-engine.ts create mode 100644 apps/mobile-rn/src/features/import/stt-policy.ts create mode 100644 apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts create mode 100644 apps/mobile-rn/src/features/meetings/meeting-recording-state-policy.ts create mode 100644 apps/mobile-rn/src/lib/auth-transition-policy.ts create mode 100644 apps/mobile-rn/src/lib/edge-functions.ts create mode 100644 apps/mobile-rn/src/lib/logger.ts create mode 100644 apps/mobile-rn/src/lib/preferences-adapters.ts create mode 100644 apps/mobile-rn/src/lib/preferences-store.ts create mode 100644 apps/mobile-rn/src/lib/retain-live-capture.ts create mode 100644 apps/mobile-rn/src/lib/retained-account-work.ts delete mode 100644 packages/api-client/src/auth.ts create mode 100644 packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts create mode 100644 packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts create mode 100644 packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts create mode 100644 packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts create mode 100644 packages/core/__tests__/template-field-policy.test.ts create mode 100644 packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts create mode 100644 packages/core/src/template-field-policy.ts create mode 100644 packages/core/src/text-chunking.ts create mode 100644 packages/core/src/voice-error-message.ts create mode 100644 scripts/ci/lib/forgejo-generic-file-delete.mjs create mode 100644 scripts/ci/lib/portable-alias-plan.mjs create mode 100644 scripts/ci/lib/portable-publish-alias.test.mjs create mode 100644 scripts/ci/lib/runtime-feed-gate.mjs create mode 100644 scripts/ci/lib/runtime-feed-gate.test.mjs create mode 100644 scripts/ci/lib/runtime-index-builder.mjs create mode 100644 scripts/ci/lib/runtime-index-builder.test.mjs create mode 100644 scripts/ci/lib/update-policy-schema.mjs create mode 100644 scripts/ci/lib/update-policy-schema.test.mjs create mode 100644 server/supabase/functions/payple-webhook/handler.test.ts create mode 100644 server/supabase/functions/payple-webhook/webhook-policy.test.ts create mode 100644 server/supabase/functions/payple-webhook/webhook-policy.ts create mode 100644 server/supabase/migrations/20260929010000_team_rpc_null_role_guard.sql create mode 100644 server/supabase/migrations/20260929020000_unify_llm_quota_inflight.sql create mode 100644 server/supabase/migrations/20260929030000_knowledge_chunks_vector_index.sql create mode 100644 server/supabase/migrations/20260929040000_meeting_rerecord_failure_paths.sql create mode 100644 server/supabase/migrations/20260929100011_payple_cancellation_order_scope.sql create mode 100644 server/supabase/tests/knowledge-chunks-vector-search.integration.sql create mode 100644 server/supabase/tests/meeting-document-llm-quota-lease.integration.sql create mode 100644 server/supabase/tests/mobile-rerecord-failure-paths.integration.sql create mode 100644 server/supabase/tests/payple-cancellation-order-scope.integration.sql create mode 100644 server/supabase/tests/team-rpc-null-role-guard.integration.sql diff --git a/apps/desktop/sidecar/main.py b/apps/desktop/sidecar/main.py index 4241fa8..c211606 100644 --- a/apps/desktop/sidecar/main.py +++ b/apps/desktop/sidecar/main.py @@ -36,7 +36,7 @@ from typing import AsyncGenerator import numpy as np import uvicorn -from fastapi import FastAPI, File, Form, UploadFile +from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import JSONResponse from device_policy import ( @@ -79,6 +79,21 @@ _model_devices: dict[str, DeviceChoice] = {} _server: uvicorn.Server | None = None _models_dir: Path | None = None +# 모델 추론·로딩은 한 번에 하나만 돈다(모델 교체와 전사가 겹치지 않게). 무거운 작업은 +# asyncio.to_thread 로 돌려, 긴 전사·로딩 중에도 /health · /uia/focus · /download/status 가 응답한다. +# (예전엔 async 핸들러 안에서 동기로 돌아 이벤트 루프 전체가 막혔다.) +_inference_lock = asyncio.Lock() +# 클라이언트가 끊긴 전사를 확인하는 주기(초) +_DISCONNECT_POLL_SECONDS = 0.5 + + +class TranscriptionCancelled(Exception): + """클라이언트가 연결을 끊어 전사를 중단했다 (타임아웃·취소).""" + + +class ModelNotInstalled(Exception): + """models-dir 에도 HF 캐시에도 없는 모델 — 암묵적으로 내려받지 않는다.""" + # ── 다운로드 상태 (스레드 공유) ──────────────────────────── _download_lock = threading.Lock() @@ -352,6 +367,7 @@ def _run_transcription( model: "WhisperModel", audio_array: np.ndarray, transcribe_kwargs: dict, + cancel: threading.Event | None = None, ) -> tuple[list[dict], str, str]: """모델로 전사하고 세그먼트를 끝까지 소비한다. @@ -374,6 +390,9 @@ def _run_transcription( full_text_parts: list[str] = [] for segment in segments_iter: + # 클라이언트가 떠났으면(타임아웃·취소) 남은 디코딩을 버린다 — 끝까지 돌면 다음 받아쓰기가 막힌다. + if cancel is not None and cancel.is_set(): + raise TranscriptionCancelled("client disconnected") seg_dict = { "text": segment.text.strip(), "start": round(segment.start, 3), @@ -486,6 +505,22 @@ def _reload_on_cpu(model_id: str, is_primary: bool) -> "WhisperModel": return model +def _ensure_model_available(model_id: str) -> None: + """models-dir 을 쓰는 설치본에서, 받지 않은 모델을 /load 가 HF 에서 몰래 내려받지 않게 한다. + + models-dir 에 있거나 HF 캐시에 이미 있으면 통과한다. models-dir 을 지정하지 않은 실행(개발·테스트)은 + 예전처럼 faster-whisper 에 맡긴다(HF 캐시만 사용). + """ + if _models_dir is None or _local_model_dir(model_id) is not None: + return + try: + from faster_whisper.utils import download_model + + download_model(model_id, local_files_only=True) + except Exception as exc: # noqa: BLE001 — 캐시에 없으면 여러 종류의 예외가 난다 + raise ModelNotInstalled(f"모델이 설치되어 있지 않습니다: {model_id}") from exc + + @app.post("/load") async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 """Whisper 모델을 로딩한다. @@ -518,17 +553,28 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 ) try: - if slot == "aux": - # 보조 자리는 하나만 둔다 — 다른 보조 모델은 내려 VRAM 을 돌려받는다. - _aux_models.clear() - _aux_models[model_id] = _load_on_best_device(model_id) - else: - # 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다. - _model = None - _model = _load_on_best_device(model_id) - _model_id = model_id - # 기본 모델이 된 모델은 보조 자리에 중복으로 들고 있지 않는다. - _aux_models.pop(model_id, None) + # 설치하지 않은 모델은 올리지 않는다 — 쓰던 모델을 내리기 전에 확인한다. + await asyncio.to_thread(_ensure_model_available, model_id) + except ModelNotInstalled as exc: + logger.warning("모델 로딩 거부 (미설치): %s", exc) + return JSONResponse( + status_code=404, + content={"status": "error", "code": "model_not_installed", "message": str(exc)}, + ) + + try: + async with _inference_lock: + if slot == "aux": + # 보조 자리는 하나만 둔다 — 다른 보조 모델은 내려 VRAM 을 돌려받는다. + _aux_models.clear() + _aux_models[model_id] = await asyncio.to_thread(_load_on_best_device, model_id) + else: + # 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다. + _model = None + _model = await asyncio.to_thread(_load_on_best_device, model_id) + _model_id = model_id + # 기본 모델이 된 모델은 보조 자리에 중복으로 들고 있지 않는다. + _aux_models.pop(model_id, None) load_time_ms = int((time.monotonic() - start_time) * 1000) logger.info("모델 로딩 완료: %s (slot=%s, %dms)", model_id, slot, load_time_ms) @@ -549,8 +595,52 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 ) +async def _watch_disconnect(request: Request, cancel: threading.Event) -> None: + """클라이언트가 연결을 끊으면(타임아웃 abort 등) cancel 을 세운다.""" + while not cancel.is_set(): + if await request.is_disconnected(): + cancel.set() + return + await asyncio.sleep(_DISCONNECT_POLL_SECONDS) + + +def _transcribe_blocking( + model: "WhisperModel", + audio_array: np.ndarray, + transcribe_kwargs: dict, + resolved_model_id: str | None, + is_primary: bool, + cancel: threading.Event, +) -> tuple[list[dict], str, str]: + """워커 스레드에서 전사한다 (GPU 런타임 오류면 CPU 로 다시 올려 한 번만 재시도).""" + try: + return _run_transcription(model, audio_array, dict(transcribe_kwargs), cancel) + except TranscriptionCancelled: + raise + except Exception as exc: + # cuBLAS 미설치 PC 등: 검증을 통과했더라도 실제 전사에서 GPU 런타임 오류가 + # 나면 이 모델을 CPU 로 다시 올려 한 번만 재시도한다. + device = _model_devices.get(resolved_model_id or "") + if not ( + resolved_model_id + and device is not None + and device.is_gpu + and is_gpu_runtime_error(exc) + ): + raise + logger.warning( + "전사 중 GPU 런타임 오류 → CPU 로 재로딩 후 재시도 (%s): %s", + resolved_model_id, + exc, + ) + _disable_gpu(exc) + cpu_model = _reload_on_cpu(resolved_model_id, is_primary) + return _run_transcription(cpu_model, audio_array, dict(transcribe_kwargs), cancel) + + @app.post("/transcribe") async def transcribe( + request: Request, audio: UploadFile = File(...), language: str = Form("auto"), vad_filter: str = Form("true"), @@ -619,31 +709,24 @@ async def transcribe( is_partial=is_partial, ) + cancel = threading.Event() + watcher = asyncio.create_task(_watch_disconnect(request, cancel)) try: - segments_list, full_text, detected_language = _run_transcription( - model, audio_array, dict(transcribe_kwargs) - ) - except Exception as exc: - # cuBLAS 미설치 PC 등: 검증을 통과했더라도 실제 전사에서 GPU 런타임 오류가 - # 나면 이 모델을 CPU 로 다시 올려 한 번만 재시도한다. - device = _model_devices.get(resolved_model_id or "") - if not ( - resolved_model_id - and device is not None - and device.is_gpu - and is_gpu_runtime_error(exc) - ): - raise - logger.warning( - "전사 중 GPU 런타임 오류 → CPU 로 재로딩 후 재시도 (%s): %s", - resolved_model_id, - exc, - ) - _disable_gpu(exc) - model = _reload_on_cpu(resolved_model_id, is_primary) - segments_list, full_text, detected_language = _run_transcription( - model, audio_array, dict(transcribe_kwargs) - ) + async with _inference_lock: + if cancel.is_set(): + raise TranscriptionCancelled("client disconnected while queued") + segments_list, full_text, detected_language = await asyncio.to_thread( + _transcribe_blocking, + model, + audio_array, + transcribe_kwargs, + resolved_model_id, + is_primary, + cancel, + ) + finally: + cancel.set() + watcher.cancel() processing_time = int((time.monotonic() - start_time) * 1000) @@ -665,6 +748,12 @@ async def transcribe( } ) + except TranscriptionCancelled as exc: + logger.info("전사 중단: %s", exc) + return JSONResponse( + status_code=499, + content={"status": "error", "code": "cancelled", "message": str(exc)}, + ) except Exception as exc: logger.error("전사 실패: %s", exc, exc_info=True) return JSONResponse( diff --git a/apps/desktop/sidecar/tests/test_sidecar_redteam_r3_1.py b/apps/desktop/sidecar/tests/test_sidecar_redteam_r3_1.py new file mode 100644 index 0000000..1b2e255 --- /dev/null +++ b/apps/desktop/sidecar/tests/test_sidecar_redteam_r3_1.py @@ -0,0 +1,134 @@ +""" +사이드카 회귀 테스트 (r3-1). + +- models-dir 을 쓰는 설치본에서 받지 않은 모델을 /load 하면 HF 에서 몰래 내려받지 않고 404 로 거부하며, + 쓰던 기본 모델을 내리지 않는다. +- 클라이언트가 떠난 전사(cancel)는 세그먼트 반복을 멈춘다 — 끝까지 디코딩하며 다음 요청을 막지 않는다. + +실행 (apps/desktop/sidecar 에서): + .venv/Scripts/python.exe -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import sys +import tempfile +import threading +import types +import unittest +from pathlib import Path +from typing import Iterator +from unittest import mock + +SIDECAR_DIR = Path(__file__).resolve().parent.parent +if str(SIDECAR_DIR) not in sys.path: + sys.path.insert(0, str(SIDECAR_DIR)) + +import numpy as np # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +import main # noqa: E402 + + +class _Segment: + def __init__(self, text: str) -> None: + self.text = text + self.start = 0.0 + self.end = 1.0 + self.avg_logprob = -0.1 + + +class _Info: + language = "ko" + + +class _FakeModel: + created: list["_FakeModel"] = [] + + def __init__(self, source: str, **_kwargs: object) -> None: + self.source = source + _FakeModel.created.append(self) + + def transcribe(self, _audio: np.ndarray, **_kwargs: object) -> tuple[Iterator[_Segment], _Info]: + def gen() -> Iterator[_Segment]: + for index in range(5): + yield _Segment(f"segment {index}") + + return gen(), _Info() + + +class ImplicitDownloadGuardTest(unittest.TestCase): + def setUp(self) -> None: + _FakeModel.created = [] + self._tmp = tempfile.TemporaryDirectory() + main._models_dir = Path(self._tmp.name) + main._gpu_available = False + main._gpu_choice = None + main._aux_models.clear() + main._model_devices.clear() + self.previous = _FakeModel("turbo") + main._model = self.previous # type: ignore[assignment] + main._model_id = "large-v3-turbo" + + fake_utils = types.ModuleType("faster_whisper.utils") + + def download_model(model_id: str, local_files_only: bool = False, **_kwargs: object) -> str: + if local_files_only: + raise FileNotFoundError(f"{model_id} not in cache") + raise AssertionError("must never download implicitly") + + fake_utils.download_model = download_model # type: ignore[attr-defined] + fake_module = types.ModuleType("faster_whisper") + fake_module.WhisperModel = _FakeModel # type: ignore[attr-defined] + fake_module.utils = fake_utils # type: ignore[attr-defined] + self._patch = mock.patch.dict( + sys.modules, {"faster_whisper": fake_module, "faster_whisper.utils": fake_utils} + ) + self._patch.start() + self.client = TestClient(main.app) + + def tearDown(self) -> None: + self._patch.stop() + self._tmp.cleanup() + main._models_dir = None + main._model = None + main._model_id = None + main._aux_models.clear() + main._model_devices.clear() + + def test_uninstalled_model_is_rejected_without_unloading_primary(self) -> None: + res = self.client.post("/load", json={"model_id": "large-v3"}) + self.assertEqual(res.status_code, 404) + self.assertEqual(res.json()["code"], "model_not_installed") + self.assertIs(main._model, self.previous) + self.assertEqual(main._model_id, "large-v3-turbo") + self.assertEqual(len(_FakeModel.created), 1) + + def test_installed_model_in_models_dir_loads(self) -> None: + model_dir = Path(self._tmp.name) / "small" + model_dir.mkdir() + (model_dir / "model.bin").write_bytes(b"x") + with mock.patch.object(main, "_probe_model"): + res = self.client.post("/load", json={"model_id": "small"}) + self.assertEqual(res.status_code, 200) + self.assertEqual(main._model_id, "small") + + +class TranscriptionCancelTest(unittest.TestCase): + def test_cancelled_transcription_stops_iterating_segments(self) -> None: + cancel = threading.Event() + cancel.set() + with self.assertRaises(main.TranscriptionCancelled): + main._run_transcription(_FakeModel("x"), np.zeros(16000, dtype=np.float32), {}, cancel) + + def test_uncancelled_transcription_consumes_all_segments(self) -> None: + segments, text, language = main._run_transcription( + _FakeModel("x"), np.zeros(16000, dtype=np.float32), {}, threading.Event() + ) + self.assertEqual(len(segments), 5) + self.assertEqual(language, "ko") + self.assertTrue(text.startswith("segment 0")) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c69dc89..a9f83cc 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -163,6 +163,12 @@ if (!gotTheLock) { void handleDeepLink(url) }) + // macOS: Dock 아이콘 클릭 → 트레이로 숨긴(closeToTray) 또는 닫힌 메인 창을 다시 띄운다. + // 부트스트랩 전(launch 직후 첫 activate)에는 showMainWindow 가 창을 만들지 않는다. + app.on('activate', () => { + showMainWindow() + }) + app.whenReady().then(async () => { await bootstrap() setupLifecycle() diff --git a/apps/desktop/src/main/ipc/caption-handlers.ts b/apps/desktop/src/main/ipc/caption-handlers.ts index d3cbe24..c4c71e0 100644 --- a/apps/desktop/src/main/ipc/caption-handlers.ts +++ b/apps/desktop/src/main/ipc/caption-handlers.ts @@ -55,7 +55,12 @@ export function registerCaptionHandlers(): void { ipcMain.handle(IPC_CHANNELS.CAPTION.STOP, async () => { try { - await getCaptionService().stop() + // 사용자 자막만 멈춘다. 소유자 없이 부르면 강제 정지라, 대시보드의 '정지'가 회의 전사·마이크를 + // 끄고도 회의는 '녹음 중'으로 남았다(강제 정지는 종료·로그아웃·회의 초기화 전용). + const result = await getCaptionService().stop('user') + if (result === false) { + return ipcError(ErrorCode.CaptionAlreadyActive, 'Caption is in use by meeting recording') + } return ipcSuccess(undefined) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/apps/desktop/src/main/ipc/input-telemetry-handlers.ts b/apps/desktop/src/main/ipc/input-telemetry-handlers.ts index 63b31cb..6695255 100644 --- a/apps/desktop/src/main/ipc/input-telemetry-handlers.ts +++ b/apps/desktop/src/main/ipc/input-telemetry-handlers.ts @@ -45,8 +45,8 @@ export function registerInputTelemetryHandlers(): void { async (_event, params: SetInputTelemetryEnabledParams) => { try { getInputTelemetryService().setEnabled(params.enabled === true) - // 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다. - if (params.enabled !== true) getSuggestionService().applyConfig() + // 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다 — 떠 있는 세션·채우기·보관 문맥을 정리한다. + getSuggestionService().handleInputConsentChanged() return ipcSuccess(getInputTelemetryService().getState()) } catch (error) { return ipcError( @@ -62,6 +62,7 @@ export function registerInputTelemetryHandlers(): void { async (_event, params: SetInputTelemetryPausedParams) => { try { getInputTelemetryService().setPaused(params.paused === true) + getSuggestionService().handleInputConsentChanged() return ipcSuccess(getInputTelemetryService().getState()) } catch (error) { return ipcError( diff --git a/apps/desktop/src/main/ipc/stt-handlers.ts b/apps/desktop/src/main/ipc/stt-handlers.ts index de11584..443f277 100644 --- a/apps/desktop/src/main/ipc/stt-handlers.ts +++ b/apps/desktop/src/main/ipc/stt-handlers.ts @@ -98,8 +98,11 @@ export function registerSTTHandlers(): void { ipcMain.handle(IPC_CHANNELS.STT.SET_MODEL, async (_event, params: SetSTTModelParams) => { try { configSet('sttModelId', params.modelId) - if (getSTTManager().getActiveProvider() === 'local') { - await getLocalSTTService().initialize(params.modelId) + // 설치된 모델만 바로 올린다. 미설치 모델을 initialize 하면 사이드카 /load 가 쓰던 모델을 내리고 + // HF 캐시로 몇 GB 를 몰래 받는다 — 설정만 저장하고, 사용자가 '다운로드' 로 받게 둔다. + const stt = getLocalSTTService() + if (getSTTManager().getActiveProvider() === 'local' && stt.isModelInstalled(params.modelId)) { + await stt.initialize(params.modelId) } return ipcSuccess(undefined) } catch (error) { diff --git a/apps/desktop/src/main/ipc/voice-conversation-handlers.ts b/apps/desktop/src/main/ipc/voice-conversation-handlers.ts index 23100ad..2f2fb6b 100644 --- a/apps/desktop/src/main/ipc/voice-conversation-handlers.ts +++ b/apps/desktop/src/main/ipc/voice-conversation-handlers.ts @@ -5,7 +5,7 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors' import { getVoiceConversationService } from '../services/VoiceConversationService' -import { getCloudSyncService } from '../services/CloudSyncService' +import { cloudSyncCredentials, type CloudCredentials } from '../services/cloud/cloud-credentials' import { getLogger } from '../services/LoggerService' import type { ConversationSendParams, @@ -15,7 +15,7 @@ import type { const logger = getLogger('voice-conversation-handlers') -export function registerVoiceConversationHandlers(): void { +export function registerVoiceConversationHandlers(credentials: CloudCredentials = cloudSyncCredentials): void { ipcMain.handle(IPC_CHANNELS.VOICE_CONVERSATION.START_SESSION, async () => { try { await getVoiceConversationService().startSession() @@ -94,7 +94,7 @@ export function registerVoiceConversationHandlers(): void { IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, async (_event, params: RealtimeTokenParams) => { try { - const { data, error } = await getCloudSyncService().invokeFunction( + const { data, error } = await credentials.invoke( 'realtime-token', { ...params }, ) diff --git a/apps/desktop/src/main/services/CaptionService.ts b/apps/desktop/src/main/services/CaptionService.ts index f24d865..88575c8 100644 --- a/apps/desktop/src/main/services/CaptionService.ts +++ b/apps/desktop/src/main/services/CaptionService.ts @@ -34,6 +34,14 @@ import { getMainWindow } from '../windows/WindowManager' const logger = getLogger('CaptionService') +/** 로딩을 기다리던 start() 가 stop()·새 start 로 무효화됐다 (내부 신호) */ +class CaptionStartCancelledError extends Error { + constructor() { + super('caption start cancelled') + this.name = 'CaptionStartCancelledError' + } +} + /** 트랙 판단 주기 (ms) — 인식 주기는 트랙이 정한다(중간 결과 1초) */ const TICK_INTERVAL_MS = 250 @@ -123,6 +131,11 @@ class CaptionService extends EventEmitter { private _audio: CaptionAudioSources = this._createAudioSources() /** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */ private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS } + /** + * 시작 세대. start() 마다 올리고, 'starting' 중 stop() 도 올린다 — 모델 로딩을 기다리던 start 가 + * 깨어났을 때 세대가 바뀌었으면 마이크를 열지 않고 물러난다(취소한 자막이 오버레이 없이 켜지던 문제). + */ + private _startEpoch = 0 // ── 공개 접근자 ── @@ -169,6 +182,11 @@ class CaptionService extends EventEmitter { showOverlay: options.showOverlay ?? DEFAULT_START_OPTIONS.showOverlay, persistHistory: options.persistHistory ?? DEFAULT_START_OPTIONS.persistHistory, } + const epoch = ++this._startEpoch + const assertCurrent = (): void => { + if (epoch !== this._startEpoch || this._disposed) throw new CaptionStartCancelledError() + } + let ownAudio: CaptionAudioSources | null = null this._setState('starting') // 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다 @@ -182,9 +200,11 @@ class CaptionService extends EventEmitter { // STT 초기화 (모델 로딩 — 시간 소요) const sttService = getLocalSTTService() await sttService.initialize() + assertCurrent() // 자막 전용 모델을 골랐으면 받아쓰기 모델과 별도로 올려 둔다(보조 자리). this._captionModelId = configGet('captionSttModelId') ?? null if (this._captionModelId) await sttService.ensureAuxModel(this._captionModelId) + assertCurrent() // 세션 초기화 this._sessionId = crypto.randomUUID() @@ -218,8 +238,10 @@ class CaptionService extends EventEmitter { this._systemTrack = this._createTrack('system', initialContext) } // 마이크 캡처(mic/both) + 시스템 오디오 캡처 요청(system/both — 렌더러에 시작 요청) - this._audio = this._createAudioSources() - await this._audio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) }) + ownAudio = this._createAudioSources() + this._audio = ownAudio + await ownAudio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) }) + assertCurrent() // 트랙 판단 타이머 — 각 트랙은 한 번에 인식 한 건만 돌린다 this._tickTimer = setInterval(() => { @@ -231,6 +253,13 @@ class CaptionService extends EventEmitter { getSoundEffectService().play('recording-start') logger.info(`Live Caption 시작: sessionId=${this._sessionId}`) } catch (err) { + if (err instanceof CaptionStartCancelledError) { + // 기다리는 사이 stop()(또는 새 start)이 세션을 가져갔다 — 공유 상태는 건드리지 않고 + // 이 시도가 연 자원만 돌려준다(release 는 멱등). + await ownAudio?.release() + logger.info('캡션 시작 취소: 로딩 중 정지됨') + throw new D3ROError(ErrorCode.CaptionStartFailed, '캡션 시작이 취소되었습니다') + } if (this._tickTimer) { clearInterval(this._tickTimer) this._tickTimer = null @@ -272,6 +301,8 @@ class CaptionService extends EventEmitter { return false } + // 로딩 중(start 가 모델을 기다리는 중)이면 그 start 를 무효화한다 — 깨어나도 마이크를 열지 않는다. + if (this._state === 'starting') this._startEpoch += 1 this._setState('stopping') // 타이머 정리 diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index eb28229..58bea39 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -143,32 +143,11 @@ class CloudSyncService extends EventEmitter { if (this._initialized) return this._initialized = true - this._client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - auth: { - persistSession: false, // 직접 관리 - autoRefreshToken: true, - detectSessionInUrl: false - }, - // Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이 - // 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이 - // 실시간으로 오지 않던 원인. - realtime: { transport: nodeRealtimeTransport } - }) + this._client = this._createClient() this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null - // supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을 - // 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다. - const client = this._client - this._unbindAuthEvents = bindAuthEvents( - (callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription, - this._tokens(), - () => this._session?.user.id ?? null, - (session) => { - this._session = session - }, - () => this._onRemoteSignedOut() - ) + this._bindClientAuthEvents(this._client) // 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외). onConfigChanged((event) => { @@ -199,6 +178,62 @@ class CloudSyncService extends EventEmitter { logger.info('CloudSyncService initialized') } + private _createClient(): SupabaseClient { + return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + persistSession: false, // 직접 관리 + autoRefreshToken: true, + detectSessionInUrl: false + }, + // Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이 + // 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이 + // 실시간으로 오지 않던 원인. + realtime: { transport: nodeRealtimeTransport } + }) + } + + /** + * supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을 + * 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다. + */ + private _bindClientAuthEvents(client: SupabaseClient): void { + this._unbindAuthEvents?.() + this._unbindAuthEvents = bindAuthEvents( + (callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription, + this._tokens(), + () => this._session?.user.id ?? null, + (session) => { + this._session = session + }, + () => this._onRemoteSignedOut() + ) + } + + /** + * 이 기기의 Supabase 세션을 서버 결과와 무관하게 버린다. + * auth-js signOut 은 오프라인·5xx 면 { error } 를 돌려주고 _removeSession 을 건너뛰어, 클라이언트가 + * 옛 계정 세션을 계속 들고 자동 갱신한다(Cloud STT·realtime-token 이 로그아웃 뒤에도 그 계정으로 돌던 원인). + * 공개 API 로는 메모리 세션만 지울 수 없으므로, 실패하면 자동 갱신을 멈추고 클라이언트를 새로 만든다. + */ + private async _revokeLocalSession(): Promise { + const client = this._client + if (!client) return + let failure: string | null = null + try { + const { error } = await client.auth.signOut({ scope: 'local' }) + if (error) failure = error.message + } catch (err) { + failure = err instanceof Error ? err.message : String(err) + } + if (failure === null) return + logger.warn(`signOut did not clear the client session (${failure}) — discarding the auth client`) + await client.auth.stopAutoRefresh().catch(() => undefined) + if (this._client !== client) return + const fresh = this._createClient() + this._client = fresh + this._bindClientAuthEvents(fresh) + } + /** * 저장된 계정 id (없거나 구버전 토큰 파일이면 null). bootstrap 이 세션 복원을 기다리지 않고 * 그 계정 DB 를 먼저 열어, 복원 중·오프라인 동안의 기록이 익명 DB 로 새지 않게 한다. @@ -542,12 +577,9 @@ class CloudSyncService extends EventEmitter { // 4) Supabase 세션 무효화 — 이 기기의 세션만(scope:'local'). // 기본값 'global' 은 계정의 모든 refresh token 을 폐기해 휴대폰·다른 PC 까지 로그아웃시켰다. + // 서버가 실패를 돌려줘도 클라이언트의 세션은 반드시 버린다(_revokeLocalSession). if (this._client && this._session) { - try { - await this._client.auth.signOut({ scope: 'local' }) - } catch (err) { - logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`) - } + await this._revokeLocalSession() } // 4) in-memory 상태 + 저장된 토큰 clear @@ -611,9 +643,22 @@ class CloudSyncService extends EventEmitter { * refresh된 토큰은 _session에 반영되지 않아 stale JWT가 될 수 있음. */ async getAccessToken(): Promise { - if (!this._client) return null - const { data } = await this._client.auth.getSession() - return data.session?.access_token ?? null + return this._currentAccessToken() + } + + /** + * 자격 증명의 단일 관문 — 이 서비스가 로그아웃 상태(_session=null)면 클라이언트에 무엇이 남아 있든 null. + * 기다리는 동안 계정이 바뀌거나 로그아웃되면 그 토큰도 버린다. + */ + private async _currentAccessToken(): Promise { + const client = this._client + const session = this._session + if (!client || !session) return null + const { data } = await client.auth.getSession() + if (this._client !== client || this._session?.user.id !== session.user.id) return null + const current = data.session + if (!current || current.user?.id !== session.user.id) return null + return current.access_token ?? null } /** @@ -646,10 +691,9 @@ class CloudSyncService extends EventEmitter { return { data: null, error: { message: 'Supabase client not initialized' } } } - // 최신 세션 확보 (auto-refresh 보장) - const { data: sessionData } = await this._client.auth.getSession() - const token = sessionData.session?.access_token - if (!token) { + // 최신 세션 확보 (auto-refresh 보장) — 로그아웃 상태면 클라이언트에 남은 세션을 쓰지 않는다 + const token = await this._currentAccessToken() + if (!token || !this._client) { return { data: null, error: { message: 'No active session — 로그인 필요' } } } @@ -689,8 +733,7 @@ class CloudSyncService extends EventEmitter { return { stream: null, error: { message: 'Supabase client not initialized' } } } - const { data: sessionData } = await this._client.auth.getSession() - const token = sessionData.session?.access_token + const token = await this._currentAccessToken() if (!token) { return { stream: null, error: { message: 'No active session — 로그인 필요' } } } diff --git a/apps/desktop/src/main/services/DictationTemplateService.ts b/apps/desktop/src/main/services/DictationTemplateService.ts index 3267c13..ad546df 100644 --- a/apps/desktop/src/main/services/DictationTemplateService.ts +++ b/apps/desktop/src/main/services/DictationTemplateService.ts @@ -17,6 +17,7 @@ import { getMainWindow } from '../windows/WindowManager' import { registerVoiceTextSink } from '../voice-text-routing' import { dictatedFieldValue, nextTemplateField, renderTemplateOutput } from './dictation-template-policy' import { textInsertTemplateOutputPort, type TemplateOutputPort } from './dictation-template-output' +import { findTemplateFieldIdProblem } from '@d3ro/core/template-field-policy' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { @@ -115,6 +116,7 @@ class DictationTemplateService extends EventEmitter { } create(params: CreateTemplateParams): DictationTemplate { + assertValidFieldIds(params.fields) const template: DictationTemplate = { id: crypto.randomUUID(), name: params.name, @@ -135,6 +137,7 @@ class DictationTemplateService extends EventEmitter { } update(params: UpdateTemplateParams): DictationTemplate { + if (params.fields !== undefined) assertValidFieldIds(params.fields) const templates = this.getAll() const idx = templates.findIndex((t) => t.id === params.id) if (idx === -1) { @@ -360,6 +363,21 @@ class DictationTemplateService extends EventEmitter { } } +/** + * 필드 id는 받아쓴 값의 키이자 {{id}} 자리표시자 키다. 비었거나 겹치면 나중 필드 값이 앞 필드 값을 덮어써 + * 출력에서 앞 값이 사라지므로 저장 전에 거부한다. + */ +function assertValidFieldIds(fields: DictationTemplate['fields']): void { + const problem = findTemplateFieldIdProblem(fields) + if (!problem) return + throw new D3ROError( + ErrorCode.TemplateInvalidFormat, + problem.problem === 'empty' + ? `Template field ${problem.index + 1} has an empty id` + : `Template field id is not unique: ${problem.id}`, + ) +} + /** 계정 범위가 생기기 전의 기계 전역 저장소(dictation-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */ function readLegacyTemplates(): DictationTemplate[] | null { try { diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index 837ec6c..cc1c1bc 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -16,11 +16,16 @@ import { containsPattern } from '../db/like-pattern' import { buildDictionaryUpdatePatch, dictionaryIdentityKey, + dictionaryWriteProblemError, findDictionaryIdentityConflict, isUniqueConstraintViolation, - normalizeDictionaryPronunciation, - normalizeDictionaryWord + validateDictionaryDraft } from '@d3ro/core/dictionary-policy' +import { + parseDictionaryFile, + serializeDictionary, + toDictionaryImportRow +} from './dictionary/dictionary-file-codec' import type { DictionaryEntry, DictionaryQueryParams, @@ -37,90 +42,6 @@ const logger = getLogger('DictionaryService') let isShowingDictionarySaveDialog = false -const DICTIONARY_CSV_HEADER = [ - 'word', - 'pronunciation', - 'category', - 'usageCount', - 'createdAt', - 'updatedAt' -] as const - -function csvCell(value: unknown): string { - const text = value === null || value === undefined ? '' : String(value) - const escaped = text.replace(/"/g, '""') - const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped) - return needsQuotes ? `"${escaped}"` : escaped -} - -function parseCsvRows(input: string): string[][] { - const rows: string[][] = [] - let row: string[] = [] - let field = '' - let inQuotes = false - for (let i = 0; i < input.length; i += 1) { - const char = input[i] - if (inQuotes) { - if (char === '"') { - if (input[i + 1] === '"') { - field += '"' - i += 1 - } else { - inQuotes = false - } - } else { - field += char - } - continue - } - if (char === '"') { - inQuotes = true - } else if (char === ',') { - row.push(field) - field = '' - } else if (char === '\n') { - row.push(field) - rows.push(row) - row = [] - field = '' - } else if (char === '\r') { - // skip CR; LF terminates the row - } else { - field += char - } - } - if (field.length > 0 || row.length > 0) { - row.push(field) - rows.push(row) - } - return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0)) -} - -function pickString(source: Record, keys: string[]): string | null { - for (const key of keys) { - const value = source[key] - if (typeof value === 'string' && value.trim().length > 0) return value.trim() - } - return null -} - -function pickNumber(source: Record, keys: string[]): number | null { - for (const key of keys) { - const value = source[key] - if (typeof value === 'number' && Number.isFinite(value)) return value - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value) - if (Number.isFinite(parsed)) return parsed - } - } - return null -} - -function normalizeCategory(value: string | null): DictionaryEntry['category'] { - if (value === 'auto' || value === 'technical' || value === 'user') return value - return 'user' -} - type DictionaryDb = Pick, 'select'> /** @@ -160,10 +81,11 @@ function findImportDuplicate( class DictionaryService { add(params: DictionaryAddParams): DictionaryEntry { - const word = normalizeDictionaryWord(params.word) - if (!word) { - throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty') - } + // 빈 단어·서버 길이 제한(단어 120·발음 200자) 초과는 저장 전에 거부한다 — 받아들이면 로컬에만 남고 + // 서버가 22023 으로 거부해 아웃박스에 영구 보류된다(다른 기기로 동기화되지 않음). + const validated = validateDictionaryDraft(params) + if (!validated.ok) throw dictionaryWriteProblemError(validated) + const { word, pronunciation } = validated.draft const db = getDatabase() const now = Date.now() @@ -177,7 +99,7 @@ class DictionaryService { const entry: NewDictionary = { id, word, - pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null, + pronunciation, category, usageCount: 0, lastUsedAt: null, @@ -201,9 +123,7 @@ class DictionaryService { update(params: DictionaryUpdateParams): DictionaryEntry | null { const result = buildDictionaryUpdatePatch(params) - if (!result.ok) { - throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty') - } + if (!result.ok) throw dictionaryWriteProblemError(result) const db = getDatabase() const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get() @@ -334,8 +254,7 @@ class DictionaryService { .all() .map((row) => this._toEntry(row)) - const content = - params.format === 'csv' ? this._serializeCsv(entries) : this._serializeJson(entries) + const content = serializeDictionary(entries, params.format) const defaultName = `d3ro-dictionary-${new Date().toISOString().slice(0, 10)}.${params.format}` if (isShowingDictionarySaveDialog) { @@ -398,7 +317,7 @@ class DictionaryService { let parsed: Array> try { - parsed = params.format === 'json' ? this._parseJson(raw) : this._parseCsv(raw) + parsed = parseDictionaryFile(raw, params.format) } catch (err) { if (err instanceof D3ROError) throw err const message = err instanceof Error ? err.message : String(err) @@ -413,37 +332,25 @@ class DictionaryService { const outcome: DictionaryImportResult = { imported: 0, skipped: 0, errors: 0 } db.transaction((tx) => { - for (const row of parsed) { - const word = pickString(row, ['word']) - if (!word) { + for (const record of parsed) { + // 빈 단어·서버 길이 제한 초과 행은 오류로 센다 — 로컬에만 남고 동기화되지 않는 항목을 만들지 않는다 + const candidate = toDictionaryImportRow(record, now) + if (!candidate.ok) { outcome.errors += 1 continue } - const category = normalizeCategory(pickString(row, ['category'])) + const { row } = candidate // 대소문자만 다른 단어도 같은 단어로 본다(서버 유일성과 같은 기준) - const existing = findImportDuplicate(tx, word, category) + const existing = findImportDuplicate(tx, row.word, row.category) if (existing) { outcome.skipped += 1 continue } - const createdAt = pickNumber(row, ['createdAt', 'created_at']) - const updatedAt = pickNumber(row, ['updatedAt', 'updated_at']) - const usageCount = pickNumber(row, ['usageCount', 'usage_count']) - const lastUsedAt = pickNumber(row, ['lastUsedAt', 'last_used_at']) const id = crypto.randomUUID() try { tx.insert(dictionary) - .values({ - id, - word, - pronunciation: pickString(row, ['pronunciation']), - category, - usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0, - lastUsedAt, - createdAt: createdAt ?? now, - updatedAt: updatedAt ?? createdAt ?? now - }) + .values({ id, ...row }) .run() outcome.imported += 1 void getCloudSyncService().pushOne('dictionary', id) @@ -459,64 +366,6 @@ class DictionaryService { return outcome } - private _serializeJson(entries: DictionaryEntry[]): string { - return JSON.stringify({ entries }, null, 2) - } - - private _serializeCsv(entries: DictionaryEntry[]): string { - const lines = [DICTIONARY_CSV_HEADER.join(',')] - for (const entry of entries) { - lines.push( - [ - csvCell(entry.word), - csvCell(entry.pronunciation ?? ''), - csvCell(entry.category), - csvCell(entry.usageCount), - csvCell(entry.createdAt), - csvCell(entry.updatedAt) - ].join(',') - ) - } - return `\uFEFF${lines.join('\r\n')}\r\n` - } - - private _parseJson(raw: string): Array> { - const data: unknown = JSON.parse(raw) - let list: unknown - if (Array.isArray(data)) { - list = data - } else if ( - data && - typeof data === 'object' && - Array.isArray((data as { entries?: unknown }).entries) - ) { - list = (data as { entries: unknown[] }).entries - } else { - throw new Error('expected an array or an object with an "entries" array') - } - return (list as unknown[]).filter( - (item): item is Record => !!item && typeof item === 'object' - ) - } - - private _parseCsv(raw: string): Array> { - const rows = parseCsvRows(raw.replace(/^\uFEFF/, '')) - if (rows.length === 0) { - throw new Error('empty CSV') - } - const header = rows[0].map((cell) => cell.trim()) - if (!header.includes('word')) { - throw new Error('missing "word" column') - } - return rows.slice(1).map((cells) => { - const record: Record = {} - header.forEach((key, index) => { - record[key] = cells[index] ?? '' - }) - return record - }) - } - dispose(): void { logger.info('DictionaryService disposed') } diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index f258b0a..5f9dd27 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -272,7 +272,12 @@ class HistoryService { const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80) if (title) { const db = getDatabase() - db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run() + const result = db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run() + // 제목을 만드는 사이 기록이 지워졌다 — 올리면 대기 중인 원격 삭제를 upsert 로 덮는다. + if (result.changes === 0) { + logger.info(`Auto title discarded: ${id} was deleted while the title was generating`) + return null + } logger.info(`Auto title generated: ${id} → "${title}"`) // Phase 3.3: 타이틀 업데이트 후 자동 push void getCloudSyncService().pushOne('history', id) diff --git a/apps/desktop/src/main/services/InputTelemetryService.ts b/apps/desktop/src/main/services/InputTelemetryService.ts index 03b7f91..5613668 100644 --- a/apps/desktop/src/main/services/InputTelemetryService.ts +++ b/apps/desktop/src/main/services/InputTelemetryService.ts @@ -26,12 +26,12 @@ import { calculateFrictionInsight, classifyKeyStroke, computeTypedDelta, + isEditAtTextEnd, + shouldLearnFromApp, countWords, emptyActivityBucket, extractPhrases, - isAppExcluded, isLearnablePhrase, - LEARNING_EXCLUDED_APPS, manhattanDistance, mergeActivityBucket, rankFlowWindows, @@ -99,6 +99,11 @@ export interface TypingContext { editedSinceFocus: boolean /** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */ typedRecently: boolean + /** + * prefix 가 실제 커서 앞 텍스트라고 믿을 수 있는가 — 케어렛 오프셋을 알면 true, 모르면 + * 마지막 편집이 문서 끝에서 일어났을 때만 true. 생략하면 true 로 본다(호환). + */ + caretReliable?: boolean } interface InputTelemetryEvents { @@ -181,6 +186,10 @@ class InputTelemetryService extends EventEmitter { private _lastFocusKey = '' /** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */ private _textAtFocus = '' + /** 직전 스냅샷의 (포커스 키, 텍스트) — 케어렛을 모를 때 편집 위치 판정에 쓴다 */ + private _editProbe: { key: string; text: string } | null = null + /** 마지막 편집이 문서 끝에서 일어났는가 (케어렛을 모를 때만 의미가 있다) */ + private _lastEditAtEnd = true /** 클릭 뒤 첫 스냅샷에서 기준선을 다시 잡는다 */ private _rebaseFocusText = false private _lastActiveTickAt = 0 @@ -644,6 +653,16 @@ class InputTelemetryService extends EventEmitter { } context.editedSinceFocus = textForFocus !== this._textAtFocus + // 케어렛을 모르면 "문서 끝 = 커서" 가정이 맞는지 마지막 편집 위치로 확인한다. + // 문서 중간을 고쳤다면 접두(문서 끝)는 커서와 무관하다 — 제안·수락을 막는다. + if (this._editProbe === null || this._editProbe.key !== focusKey) { + this._lastEditAtEnd = true + } else if (this._editProbe.text !== textForFocus) { + this._lastEditAtEnd = isEditAtTextEnd(this._editProbe.text, textForFocus) + } + this._editProbe = { key: focusKey, text: textForFocus } + context.caretReliable = caretKnown || this._lastEditAtEnd + if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) { this._applyTypedDelta(snapshot) } @@ -817,9 +836,8 @@ class InputTelemetryService extends EventEmitter { raw: string, meta: { appName: string | null; windowTitle: string | null; source: PhraseSource } ): void { - if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) { - return - } + // 학습 제외 앱은 출처와 무관하다 — 터미널에서 수락한 제안(개발 지시)도 코퍼스에 넣지 않는다. + if (!shouldLearnFromApp(meta.appName)) return const text = sanitizeSample( raw .split(/\r?\n/u) @@ -1374,8 +1392,8 @@ class InputTelemetryService extends EventEmitter { * 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다. */ private _pruneUnlearnableCorpus(): void { - const excluded = (source: string, appName: string | null): boolean => - source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS) + // 학습과 같은 규칙(출처 무관) — 규칙 이전에 쌓인 제외 앱의 수락 제안도 걷어낸다. + const excluded = (_source: string, appName: string | null): boolean => !shouldLearnFromApp(appName) try { const db = getDatabase() diff --git a/apps/desktop/src/main/services/KeyBindingService.ts b/apps/desktop/src/main/services/KeyBindingService.ts index 7a0b298..493c25d 100644 --- a/apps/desktop/src/main/services/KeyBindingService.ts +++ b/apps/desktop/src/main/services/KeyBindingService.ts @@ -26,7 +26,7 @@ import { bindingKey, normalizeBinding } from '@d3ro/core/keybinding' -import { ChordStateMachine } from '@d3ro/core/keybinding-runtime' +import { AltGrPressDetector, ChordStateMachine } from '@d3ro/core/keybinding-runtime' import type { ChordBindingEntry, ChordClock, @@ -266,6 +266,13 @@ class KeyBindingService extends EventEmitter { debug: (message) => logger.debug(message) }) + /** + * Windows AltGr(가짜 LCtrl + RAlt) 판별. 가짜 LCtrl 이 켠 Ctrl 을 걷어 RightAlt 단독 + * 바인딩이 매칭되게 하고, AltGr 눌림은 문자 입력일 수 있으므로 상태 머신에 보류를 요청한다. + * 다른 플랫폼에는 가짜 LCtrl 이 없으므로 판정 결과를 쓰지 않는다(_handleKeyDown). + */ + private readonly _altGr = new AltGrPressDetector() + /** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */ private _ownedAccelerators: Set = new Set() @@ -491,6 +498,7 @@ class KeyBindingService extends EventEmitter { /** 눌림 · 더블프레스 · AltGr 보류 타이머를 모두 비운다. */ private _clearRuntimeState(): void { this._machine.reset() + this._altGr.reset() } // ── uiohook 이벤트 → 정본 좌표계 ────────────────────── @@ -499,13 +507,19 @@ class KeyBindingService extends EventEmitter { * uiohook 키보드 이벤트를 정본 좌표계 bindingKey 로 옮긴다. * normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로 * Right Alt 단독 바인딩도 그대로 매칭된다. + * + * isAltGr 이면 Ctrl 플래그는 AltGr 의 가짜 LCtrl 이 켠 것이므로 걷어낸다. */ - private _keyboardEventKey(e: UiohookKeyboardEvent, vk: number | null): string | null { + private _keyboardEventKey( + e: UiohookKeyboardEvent, + vk: number | null, + isAltGr = false + ): string | null { if (vk === null) return null return bindingKey({ device: 'keyboard', code: vk, - ctrl: e.ctrlKey, + ctrl: e.ctrlKey && !isAltGr, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey @@ -525,10 +539,18 @@ class KeyBindingService extends EventEmitter { }) } + /** + * 키 다운. 바인딩되지 않은 키도 상태 머신에 알린다(noteKeyDown) — AltGr+Q('@') 처럼 + * 문자 키가 이어지면 보류 중인 AltGr 트리거를 취소해야 하기 때문이다. + */ private _handleKeyDown(e: UiohookKeyboardEvent): void { - const key = this._keyboardEventKey(e, uiohookCodeToVk(e.keycode)) + const vk = uiohookCodeToVk(e.keycode) + // 판별기는 플랫폼과 무관하게 모든 눌림을 관측해 상태를 일관되게 유지한다. + const isAltGr = this._altGr.keyDown(vk, e.time) && process.platform === 'win32' + this._machine.noteKeyDown(vk) + const key = this._keyboardEventKey(e, vk, isAltGr) if (key === null) return - this._machine.keyDown(key) + this._machine.keyDown(key, { altGr: isAltGr }) } private _handleMouseDown(e: UiohookMouseEvent): void { @@ -543,6 +565,7 @@ class KeyBindingService extends EventEmitter { */ private _handleKeyUp(e: UiohookKeyboardEvent): void { const vk = uiohookCodeToVk(e.keycode) + this._altGr.keyUp(vk) this._machine.keyUp(this._keyboardEventKey(e, vk), vk) } diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index 8293728..79bbcf0 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -14,6 +14,7 @@ import { resolveSystemPrompt } from './llm-prompts' import { getBundledOllamaPath } from '../utils/paths' import { normalizeLoopbackUrl } from '../utils/loopback' import { readNdjsonLines } from '../utils/ndjson-reader' +import { ModelResidencyLedger, type ModelResidencyPort } from './llm/model-residency' import { toChatRequest, toRoleMessages, @@ -160,6 +161,8 @@ function stripReasoningBlocks(text: string): string { class LocalLLMService extends EventEmitter { private _state = LLMState.Unavailable + /** 모든 로컬 요청의 keep_alive 로 추정한 모델 상주 시각 — 제안 온기 판정이 읽는다 */ + private readonly _residency = new ModelResidencyLedger() /** 모델별 진행 중 pull — 중복 요청은 기존 promise에 합류 */ private _pullInFlight = new Map>() private _pollInterval: ReturnType | null = null @@ -447,6 +450,7 @@ class LocalLLMService extends EventEmitter { } const data = (await response.json()) as OllamaGenerateResponse + this._residency.noteRequest(model, options?.keepAlive, Date.now()) const result: GenerateResult = { text: data.response, @@ -558,6 +562,8 @@ class LocalLLMService extends EventEmitter { request.abortCause ??= 'cancelled' request.controller.abort() } + // 서버가 응답을 시작했다면 모델이 올라왔고 이 요청의 keep_alive 가 만료 시각을 정했다. + if (reader) this._residency.noteRequest(model, options?.keepAlive, Date.now()) request.close() } } @@ -900,10 +906,16 @@ class LocalLLMService extends EventEmitter { request.abortCause ??= 'cancelled' request.controller.abort() } + if (reader) this._residency.noteRequest(model, options?.keepAlive, Date.now()) request.close() } } + /** 모델 상주 추정 포트 (Ollama 는 가장 최근 요청의 keep_alive 로 만료를 다시 정한다). */ + get residency(): ModelResidencyPort { + return this._residency + } + dispose(): void { this._disposed = true this.stopPolling() diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index fdf0c09..b257ade 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -12,6 +12,7 @@ import { getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths' import { getRuntimeProvisioner } from './RuntimeProvisioner' import { SidecarSupervisor, type SidecarSupervisorEvents } from './stt/SidecarSupervisor' import { isSidecarLaunchAvailableOffline, resolveSidecarLaunch } from './stt/sidecarLaunch' +import { transcriptionTimeoutMs } from './stt/audio-utils' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { STTModel, @@ -120,6 +121,11 @@ export interface LocalSTTEvents { // ── 상수 ────────────────────────────────────────────────── const SIDECAR_REQUEST_TIMEOUT_MS = 120000 +/** + * 최종 전사는 오디오 1초당 이만큼 더 기다린다 — CPU int8 large-v3(beam 5 + 온도 폴백)는 실시간보다 + * 느릴 수 있다. 짧은 녹음은 SIDECAR_REQUEST_TIMEOUT_MS 를 하한으로 쓴다. + */ +const SIDECAR_TRANSCRIBE_MS_PER_AUDIO_SECOND = 3000 /** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */ const SIDECAR_PARTIAL_TIMEOUT_MS = 15000 @@ -378,6 +384,16 @@ class LocalSTTService extends EventEmitter { } } + /** + * 이 모델을 내려받기 없이 올릴 수 있는가 (models-dir 에 있거나 이미 올라가 있다). + * 설치하지 않은 모델을 /load 하면 사이드카가 HF 에서 몇 GB 를 내려받으며 기존 모델을 먼저 내린다 — + * 모델 선택(SET_MODEL) 같은 경로는 이 검사를 통과한 모델만 initialize 해야 한다. + */ + isModelInstalled(modelId: string): boolean { + if (!modelId) return false + return modelId === this._currentModelId || existsSync(join(getWhisperModelsDir(), modelId, 'model.bin')) + } + /** * 설정된 모델과 엔진을 내려받기 없이 쓸 수 있는지 확인한다 (requireInstalled 경로). * 모델이 없으면 사이드카 /load가 HF에서 암묵적으로 내려받고, 엔진이 없으면 런타임을 내려받는다 — @@ -735,7 +751,12 @@ class LocalSTTService extends EventEmitter { method: 'POST', body: formData, signal: AbortSignal.timeout( - isPartial ? SIDECAR_PARTIAL_TIMEOUT_MS : SIDECAR_REQUEST_TIMEOUT_MS, + isPartial + ? SIDECAR_PARTIAL_TIMEOUT_MS + : transcriptionTimeoutMs(audioBuffer.byteLength, { + floorMs: SIDECAR_REQUEST_TIMEOUT_MS, + perAudioSecondMs: SIDECAR_TRANSCRIBE_MS_PER_AUDIO_SECOND, + }), ), }) diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index 3c9d10a..627a80d 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -15,6 +15,24 @@ import { getDatabase } from '../db' import { onAccountScopeChanged } from './account-scope' import { getLlmGateway } from './llm/LlmGateway' import { MeetingRecordingSession, type RecordingSnapshot } from './meeting/MeetingRecordingSession' +import { + isRecoverableMeeting, + syncStateRecordingLedger, + type LocalRecordingLedger, +} from './meeting/local-recording-ledger' +import { + createTranscriptStore, + transcriptBase, + type MeetingTranscriptStore, + type TranscriptRevision, +} from './meeting/transcript-revision-store' +import { + buildDiarizationChunkInput, + diarizationContinuityHint, + extractSpeakerLabels, + lastLines, + stripEchoedContext, +} from './meeting/diarization-context' import { condenseTranscriptToBudget } from './meeting/transcript-condenser' import { selectTranscriptExcerpts } from '@d3ro/core/meeting-llm-input' import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat' @@ -61,12 +79,18 @@ const logger = getLogger('MeetingModeService') const REWRITE_CHUNK_CHARS = 1500 /** 조각 하나의 출력 토큰 상한 — 로컬(Ollama)·Premium 모두 4096 이하만 허용한다 */ const REWRITE_CHUNK_MAX_TOKENS = 2048 +/** 화자 추정에서 다음 조각에 넘기는 앞 조각의 라벨된 마지막 줄 수 */ +const DIARIZATION_CONTEXT_LINES = 4 /** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */ const MAX_CHAT_HISTORY_MESSAGES = 20 /** 녹음 중 전사를 DB 에 남기는 주기 — 비정상 종료 시 잃는 양의 상한 */ const TRANSCRIPT_CHECKPOINT_MS = 15_000 /** 중단된 녹음(전사 없음)의 오류 메시지 — 저장용(UI 는 status 로 표시) */ const INTERRUPTED_MESSAGE = 'Recording was interrupted before it was saved' +/** 녹음 동안 인식이 계속 실패해 전사가 하나도 없다 */ +const TRANSCRIPTION_FAILED_MESSAGE = 'Transcription failed during recording' +/** 자막 인식 실패를 렌더러에 다시 알리는 최소 간격 */ +const CAPTION_FAILURE_NOTICE_INTERVAL_MS = 30_000 /** * 회의 기능이 쓰는 LLM 포트 — 백엔드 선택(local/online, 폴백)은 LlmGateway 한 곳이 정한다. @@ -116,8 +140,16 @@ class MeetingModeService extends EventEmitter { /** 회의마다 진행 중인 채팅 — 한 회의의 취소가 다른 회의 채팅을 끊지 않게 */ private _chatAbortControllers = new Map() private readonly _unsubscribeScope: () => void + /** 회의별 자막 인식 실패 수 — 전사가 비었을 때 'completed' 대신 'error' 로 닫는 근거 */ + private readonly _captionFailures = new Map() + private _unsubscribeCaptionFailure: (() => void) | null = null + private _lastCaptionFailureNoticeAt = 0 - constructor() { + constructor( + private readonly _recordingLedger: LocalRecordingLedger = syncStateRecordingLedger, + /** 전사 수정본 쓰기의 단일 관문 — 기준본 확인(compare-and-set)과 동기화 예약을 맡는다 */ + private readonly _transcripts: MeetingTranscriptStore = createSqliteMeetingTranscriptStore(), + ) { super() // 이 서비스가 내는 'error' 는 알림용이다 — 듣는 쪽이 없어도 throw 되지 않게 기본 수신자를 둔다 // (EventEmitter 는 리스너 없는 'error' 를 던져 후처리 catch 를 깨뜨렸다). @@ -255,6 +287,8 @@ class MeetingModeService extends EventEmitter { createdAt: now, updatedAt: now, }).run() + // 이 기기에서 시작한 녹음 — 앱이 끊기면 다음 시작 때 이것만 복구한다(다른 기기 회의는 건드리지 않음). + this._markLocalRecording(sessionId) const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService') const audioCaptureService = getAudioCaptureService() @@ -306,6 +340,7 @@ class MeetingModeService extends EventEmitter { session.detach() if (this._current === session) this._current = null this._meetingModeActive = false + this._clearLocalRecording(sessionId) db.update(meetingSessions).set({ status: 'error', @@ -318,6 +353,9 @@ class MeetingModeService extends EventEmitter { throw err } + // 인식이 계속 실패하면(사이드카 다운·모델 로드 실패) 회의가 조용히 빈 전사로 끝나지 않게 알린다. + this._watchCaptionFailures(captionService, sessionId) + // 오디오 레벨 모니터링 + WAV 저장용 버퍼 (Phase 15.5-2) session.attachAudio() this._audioLevelTimer = setInterval(() => { @@ -439,6 +477,7 @@ class MeetingModeService extends EventEmitter { /** * 이 DB 에 'recording'/'processing' 으로 남은 회의(앱 강제 종료·정전 등)를 닫는다. * 저장된 전사가 있으면 'completed', 없으면 'error'. 진행 중인 녹음은 건드리지 않는다. + * 이 기기에서 시작한 녹음만 닫는다 — 동기화로 들어온 폰의 진행 중인 회의는 폰이 끝낸다. * @returns 정리한 회의 수 */ recoverInterruptedSessions(): number { @@ -453,6 +492,7 @@ class MeetingModeService extends EventEmitter { let recovered = 0 for (const row of rows) { if (row.id === liveId) continue + if (!isRecoverableMeeting(row, this._recordingLedger)) continue const now = Date.now() const endedAt = row.endedAt ?? row.updatedAt ?? now const hasTranscript = !!row.rawTranscript && row.rawTranscript.trim().length > 0 @@ -464,6 +504,7 @@ class MeetingModeService extends EventEmitter { updatedAt: now, }).where(eq(meetingSessions.id, row.id)).run() getCloudSyncService().pushOne('meetings', row.id) + this._clearLocalRecording(row.id) recovered++ } if (recovered > 0) logger.warn(`중단된 회의 ${recovered}건을 정리했다`) @@ -550,17 +591,22 @@ class MeetingModeService extends EventEmitter { const db = getDatabase() const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt) const hasTranscript = rawTranscript.trim().length > 0 - const status = options.interrupted && !hasTranscript ? 'error' : 'completed' + // 전사가 비었는데 녹음 동안 인식이 실패했다면 '완료'가 아니다 — 사용자가 끝난 뒤에야 빈 회의를 발견했다. + const transcriptionFailed = !hasTranscript && (this._captionFailures.get(snapshot.id) ?? 0) > 0 + const status = !hasTranscript && (options.interrupted || transcriptionFailed) ? 'error' : 'completed' + const errorMessage = options.interrupted ? INTERRUPTED_MESSAGE : TRANSCRIPTION_FAILED_MESSAGE db.update(meetingSessions).set({ status, rawTranscript, endedAt: snapshot.endedAt, durationMs: snapshot.durationMs, sttModel: configGet('sttModelId') as string | undefined, - ...(status === 'error' ? { errorMessage: INTERRUPTED_MESSAGE } : {}), + ...(status === 'error' ? { errorMessage } : {}), updatedAt: Date.now(), }).where(eq(meetingSessions.id, snapshot.id)).run() getCloudSyncService().pushOne('meetings', snapshot.id) + this._captionFailures.delete(snapshot.id) + this._clearLocalRecording(snapshot.id) if (snapshot.pcm.length > 0) { try { @@ -593,7 +639,49 @@ class MeetingModeService extends EventEmitter { } } + private _watchCaptionFailures( + captionService: { on(event: 'failure', listener: (error: D3ROError) => void): unknown; off(event: 'failure', listener: (error: D3ROError) => void): unknown }, + sessionId: string, + ): void { + this._unsubscribeCaptionFailure?.() + this._captionFailures.set(sessionId, 0) + this._lastCaptionFailureNoticeAt = 0 + const listener = (error: D3ROError): void => { + if (this._current?.id !== sessionId) return + this._captionFailures.set(sessionId, (this._captionFailures.get(sessionId) ?? 0) + 1) + const now = Date.now() + if (now - this._lastCaptionFailureNoticeAt < CAPTION_FAILURE_NOTICE_INTERVAL_MS) return + this._lastCaptionFailureNoticeAt = now + this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, { + code: error.code ?? ErrorCode.MeetingProcessingFailed, + message: error.message, + }) + } + captionService.on('failure', listener) + this._unsubscribeCaptionFailure = () => { + captionService.off('failure', listener) + } + } + + private _markLocalRecording(sessionId: string): void { + try { + this._recordingLedger.mark(sessionId) + } catch (err) { + logger.warn(`로컬 녹음 표식 기록 실패: ${err instanceof Error ? err.message : String(err)}`) + } + } + + private _clearLocalRecording(sessionId: string): void { + try { + this._recordingLedger.clear(sessionId) + } catch (err) { + logger.warn(`로컬 녹음 표식 정리 실패: ${err instanceof Error ? err.message : String(err)}`) + } + } + private _stopTimers(): void { + this._unsubscribeCaptionFailure?.() + this._unsubscribeCaptionFailure = null if (this._audioLevelTimer) { clearInterval(this._audioLevelTimer) this._audioLevelTimer = null @@ -831,19 +919,27 @@ class MeetingModeService extends EventEmitter { // ── Phase 14.5: 전사 수정 ── updateTranscript(sessionId: string, editedTranscript: string): void { - const db = getDatabase() - const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() - if (!row) { + // 사용자의 수동 저장(실시간 세그먼트 반영 포함)은 기준본 확인 없이 쓴다 — 렌더러 계약은 그대로다. + if (this._transcripts.writeEdited(sessionId, editedTranscript) === 'not-found') { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } - db.update(meetingSessions).set({ - editedTranscript, - updatedAt: Date.now(), - }).where(eq(meetingSessions.id, sessionId)).run() - getCloudSyncService().pushOne('meetings', sessionId) logger.info(`전사 수정 저장: sessionId=${sessionId}`) } + /** + * LLM 결과(다듬기·화자 구분)를 읽었던 기준본 위에서만 쓴다. 그 사이 사용자가 전사를 고쳤으면 + * 덮어쓰지 않고 실패시킨다 — 예전엔 마지막 쓰기가 이겨 수동 편집이 조용히 사라졌다. + */ + private _writeRewrite(sessionId: string, text: string, base: TranscriptRevision, failureCode: ErrorCode): void { + const result = this._transcripts.writeEdited(sessionId, text, base.revision) + if (result === 'not-found') { + throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) + } + if (result === 'conflict') { + throw new D3ROError(failureCode, '작업하는 동안 전사가 수정되어 결과를 저장하지 않았습니다. 다시 시도해 주세요.') + } + } + // ── Phase 14.5: 문서 생성 ── async generateDocument(params: MeetingGenerateDocParams): Promise { @@ -1181,26 +1277,22 @@ class MeetingModeService extends EventEmitter { // ── Phase 15: Auto Polish ── async polishTranscript(sessionId: string): Promise { - const db = getDatabase() - const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() - if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) + const base = this._transcripts.read(sessionId) + if (!base) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) - const transcript = row.rawTranscript + // 기준본은 수정본(사용자 편집·화자 라벨) 우선 — 원문으로 다듬으면 편집과 라벨이 통째로 사라졌다. + const transcript = transcriptBase(base) if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다') const llm = await this._resolveMeetingLlm() const polished = await this._rewriteTranscriptInChunks( llm, transcript, - '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.', + '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]과 화자 표기([화자 N] 등)는 그대로 유지하세요.', ErrorCode.MeetingPolishFailed, ) - db.update(meetingSessions).set({ - editedTranscript: polished, - updatedAt: Date.now(), - }).where(eq(meetingSessions.id, sessionId)).run() - getCloudSyncService().pushOne('meetings', sessionId) + this._writeRewrite(sessionId, polished, base, ErrorCode.MeetingPolishFailed) logger.info(`Auto Polish 완료: sessionId=${sessionId}`) return polished @@ -1217,26 +1309,34 @@ class MeetingModeService extends EventEmitter { transcript: string, systemPrompt: string, failureCode: ErrorCode, + options: { carrySpeakerContext?: boolean } = {}, ): Promise { const chunks = chunkTranscriptByLines(transcript, REWRITE_CHUNK_CHARS) if (chunks.length === 0) { throw new D3ROError(failureCode, '전사 텍스트가 없습니다') } const outputs: string[] = [] + let previousTail: string[] = [] for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i] - const result = await llm.generate(chunk, { - systemPrompt, + // 화자 추정은 조각 사이에 화자 번호를 이어 준다 — 앞 조각의 라벨된 끝부분과 지금까지의 화자 목록. + const carry = options.carrySpeakerContext === true + const input = carry ? buildDiarizationChunkInput(chunk, previousTail) : chunk + const prompt = carry ? systemPrompt + diarizationContinuityHint(extractSpeakerLabels(outputs.join('\n'))) : systemPrompt + const result = await llm.generate(input, { + systemPrompt: prompt, temperature: 0.3, maxTokens: REWRITE_CHUNK_MAX_TOKENS, }) - if (looksTruncatedRewrite(chunk, result.text)) { + const text = carry ? stripEchoedContext(result.text, previousTail) : result.text.trim() + if (looksTruncatedRewrite(chunk, text)) { throw new D3ROError( failureCode, `LLM 결과가 잘린 것으로 보여 저장하지 않았습니다 (조각 ${i + 1}/${chunks.length})`, ) } - outputs.push(result.text.trim()) + outputs.push(text.trim()) + if (carry) previousTail = lastLines(text, DIARIZATION_CONTEXT_LINES) } return outputs.join('\n') } @@ -1360,10 +1460,9 @@ ${excerpt.text}` // ── Phase 15.5: 화자 구분 ── async diarizeSession(sessionId: string, numSpeakers?: number): Promise { - const db = getDatabase() - const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + const row = this._transcripts.read(sessionId) if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) - if (!row.rawTranscript) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다') + if (!row.raw) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다') this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 }) @@ -1410,7 +1509,7 @@ ${excerpt.text}` // 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다. // 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다. - const transcript = row.editedTranscript ?? row.rawTranscript ?? '' + const transcript = transcriptBase(row) ?? '' const labeledLines = applySpeakerLabels( parseTranscriptLines(transcript), diarResult.segments, @@ -1419,11 +1518,7 @@ ${excerpt.text}` this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 }) - db.update(meetingSessions).set({ - editedTranscript: formatTranscriptLines(labeledLines), - updatedAt: Date.now(), - }).where(eq(meetingSessions.id, sessionId)).run() - getCloudSyncService().pushOne('meetings', sessionId) + this._writeRewrite(sessionId, formatTranscriptLines(labeledLines), row, ErrorCode.DiarizationFailed) logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`) } catch (err) { @@ -1442,14 +1537,14 @@ ${excerpt.text}` private async _diarizeLLMFallback( sessionId: string, - row: { rawTranscript: string | null; editedTranscript: string | null }, + row: TranscriptRevision, numSpeakers?: number, ): Promise { this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 30 }) const llm = await this._resolveMeetingLlm() - const transcript = row.editedTranscript ?? row.rawTranscript ?? '' + const transcript = transcriptBase(row) ?? '' const speakerHint = numSpeakers && numSpeakers > 0 ? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.` : '화자 수는 문맥에서 추정하세요.' @@ -1464,16 +1559,12 @@ ${speakerHint} 발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요. 원문의 타임스탬프와 내용은 변경하지 마세요.`, ErrorCode.DiarizationFailed, + { carrySpeakerContext: true }, ) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 }) - const db = getDatabase() - db.update(meetingSessions).set({ - editedTranscript: labeled, - updatedAt: Date.now(), - }).where(eq(meetingSessions.id, sessionId)).run() - getCloudSyncService().pushOne('meetings', sessionId) + this._writeRewrite(sessionId, labeled, row, ErrorCode.DiarizationFailed) } private _buildPdfHtml(session: MeetingSessionDetail): string { @@ -1488,6 +1579,26 @@ ${speakerHint} } // ── 싱글톤 ── +/** 기본 전사 저장소 — meeting_sessions 행을 읽고 쓰며, 쓴 뒤 동기화를 예약한다. */ +function createSqliteMeetingTranscriptStore(): MeetingTranscriptStore { + return createTranscriptStore({ + readRow: (sessionId) => + getDatabase() + .select({ rawTranscript: meetingSessions.rawTranscript, editedTranscript: meetingSessions.editedTranscript }) + .from(meetingSessions) + .where(eq(meetingSessions.id, sessionId)) + .get(), + writeEdited: (sessionId, editedTranscript, now) => { + getDatabase() + .update(meetingSessions) + .set({ editedTranscript, updatedAt: now }) + .where(eq(meetingSessions.id, sessionId)) + .run() + }, + onWritten: (sessionId) => getCloudSyncService().pushOne('meetings', sessionId), + }) +} + let instance: MeetingModeService | null = null export function getMeetingModeService(): MeetingModeService { diff --git a/apps/desktop/src/main/services/MeetingSummaryService.ts b/apps/desktop/src/main/services/MeetingSummaryService.ts index 23671bf..aaa8e26 100644 --- a/apps/desktop/src/main/services/MeetingSummaryService.ts +++ b/apps/desktop/src/main/services/MeetingSummaryService.ts @@ -111,13 +111,14 @@ class MeetingSummaryService extends EventEmitter { } // DB에 요약 저장 - db.update(history) + const saved = db.update(history) .set({ summaryText: rawMarkdown, updatedAt: Date.now() }) .where(eq(history.id, historyId)) .run() - // Phase 3.3: 요약 저장 후 자동 push (fire-and-forget) - void getCloudSyncService().pushOne('history', historyId) + // Phase 3.3: 요약 저장 후 자동 push (fire-and-forget). + // 요약하는 사이 기록이 지워졌으면 올리지 않는다 — 대기 중인 원격 삭제를 upsert 로 덮는다. + if (saved.changes > 0) void getCloudSyncService().pushOne('history', historyId) this._sendProgress(historyId, 'done') this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_READY, summaryResult) diff --git a/apps/desktop/src/main/services/PremiumLLMService.ts b/apps/desktop/src/main/services/PremiumLLMService.ts index ae74f06..7affc79 100644 --- a/apps/desktop/src/main/services/PremiumLLMService.ts +++ b/apps/desktop/src/main/services/PremiumLLMService.ts @@ -11,7 +11,7 @@ import { EventEmitter } from 'events' import { getLogger } from './LoggerService' -import { getCloudSyncService } from './CloudSyncService' +import { cloudSyncCredentials, type CloudCredentials } from './cloud/cloud-credentials' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LLMAction } from '@d3ro/core/types' import { resolveSystemPrompt } from './llm-prompts' @@ -191,13 +191,17 @@ class PremiumLLMService extends EventEmitter { private _disposed = false private _lastQuota: QuotaUsageSnapshot | null = null + /** 계정 자격 증명 포트 — CloudSyncService 전체가 아닌 필요한 면만 의존한다(ISP/DIP). */ + constructor(private readonly _credentials: CloudCredentials = cloudSyncCredentials) { + super() + } + /** * 사용 가능 여부 — Supabase URL + access token 모두 있어야 true. * (LLMRouter가 local/premium 분기 시 호출) */ isAvailable(): boolean { - const cloud = getCloudSyncService() - return cloud.isEnabled() && cloud.isAuthenticated() + return this._credentials.hasCredentials() } /** @@ -216,8 +220,7 @@ class PremiumLLMService extends EventEmitter { if (this._disposed) { throw new D3ROError(ErrorCode.LLMProcessingFailed, 'PremiumLLMService disposed') } - const cloud = getCloudSyncService() - if (!cloud.isEnabled() || !cloud.isAuthenticated()) { + if (!this._credentials.hasCredentials()) { this.emit('upgrade-required', { reason: 'auth_required' }) throw new D3ROError( ErrorCode.LLMServerUnreachable, @@ -292,8 +295,7 @@ class PremiumLLMService extends EventEmitter { let completed = false try { - const cloud = getCloudSyncService() - const { stream, error } = await cloud.invokeFunctionStream( + const { stream, error } = await this._credentials.invokeStream( 'llm-proxy', body as unknown as Record, call.controller.signal, @@ -403,13 +405,11 @@ class PremiumLLMService extends EventEmitter { } private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise { - const cloud = getCloudSyncService() - // Supabase JS 클라이언트의 functions.invoke() 사용 — auth 헤더를 올바르게 처리. // raw fetch + Authorization: Bearer 방식은 Supabase gateway가 401로 거부. const { data, error } = signal - ? await cloud.invokeFunction('llm-proxy', body as unknown as Record, { signal }) - : await cloud.invokeFunction('llm-proxy', body as unknown as Record) + ? await this._credentials.invoke('llm-proxy', body as unknown as Record, { signal }) + : await this._credentials.invoke('llm-proxy', body as unknown as Record) if (error) { const msg = error.message ?? 'Edge Function error' diff --git a/apps/desktop/src/main/services/RAGService.ts b/apps/desktop/src/main/services/RAGService.ts index 9910463..8ae3a77 100644 --- a/apps/desktop/src/main/services/RAGService.ts +++ b/apps/desktop/src/main/services/RAGService.ts @@ -6,6 +6,7 @@ // - rag/embedding-port.ts: 임베딩 포트 + Ollama 어댑터(모델 존재 확인 포함) // - rag/chunk-store.ts : 문서·청크 저장소 포트 + SQLite 구현 // - rag/retrieval.ts : 유사도 순위·답변 프롬프트 (순수 함수) +// - rag/remote-document.ts: 동기화로 받은 문서의 삽입/교체/무시 판정 (순수 함수) import { EventEmitter } from 'events' import path from 'path' @@ -31,6 +32,7 @@ import { import { OllamaEmbeddingAdapter, type EmbeddingPort } from './rag/embedding-port' import { SqliteChunkStore, type ChunkStore } from './rag/chunk-store' import { buildAnswerSystemPrompt, rankChunks } from './rag/retrieval' +import { planRemoteDocument, type RemoteKnowledgeDocument } from './rag/remote-document' const logger = getLogger('RAGService') @@ -219,31 +221,32 @@ export class RAGService extends EventEmitter { } /** - * 동기화: 다른 기기(모바일·웹)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다. - * 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 filePath는 비워 둔다. + * 동기화: 다른 기기(모바일·웹·데스크톱)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다. + * 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 새 문서의 filePath는 비워 둔다. + * 이미 있는 문서라도 원문이 달라졌으면(다른 기기에서 재색인) 청크를 바꾸고 다시 임베딩한다. + * 반영했으면 true, 바뀐 게 없으면 false. */ - applyRemoteDocument(doc: { - id: string - fileName: string - fileType: RAGDocument['fileType'] - chunks: string[] - addedAt: number - }): boolean { + applyRemoteDocument(doc: RemoteKnowledgeDocument): boolean { const { store } = this.deps - if (store.hasDocument(doc.id)) return false - const chunks = doc.chunks.filter((c) => c.trim().length > 0) - if (chunks.length === 0) return false - store.insertDocument({ - id: doc.id, - fileName: doc.fileName, - filePath: '', - fileType: doc.fileType, - chunkCount: chunks.length, - indexed: false, - indexedAt: null, - addedAt: doc.addedAt, - }) - store.replaceChunks(doc.id, chunks) + const local = store.hasDocument(doc.id) ? this.getStoredChunks(doc.id) : null + const plan = planRemoteDocument(local, doc.chunks) + if (plan.kind === 'skip') return false + if (plan.kind === 'insert') { + store.insertDocument({ + id: doc.id, + fileName: doc.fileName, + filePath: '', + fileType: doc.fileType, + chunkCount: plan.chunks.length, + indexed: false, + indexedAt: null, + addedAt: doc.addedAt, + }) + } else { + // 옛 임베딩이 남아 검색에 걸리지 않도록 색인 안 됨으로 되돌린 뒤 청크를 바꾼다. + store.updateDocument(doc.id, { chunkCount: plan.chunks.length, indexed: false, indexedAt: null }) + } + store.replaceChunks(doc.id, plan.chunks) this._embedStoredChunks(doc.id, doc.fileName).catch((err) => { logger.warn(`Synced document ${doc.fileName} is stored but not embedded yet:`, err) }) diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts index 0ca65f7..b6c53a4 100644 --- a/apps/desktop/src/main/services/RuntimeProvisioner.ts +++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts @@ -29,6 +29,7 @@ import { RUNTIME_FEED_URL, RUNTIME_INDEX_FILENAME } from '../update-feed' import { RUNTIME_COMPONENTS, RUNTIME_MIN_VERSION, + isRuntimeIndexRejected, isRuntimeVersionSatisfied, parseRuntimeIndex, type RuntimeComponent, @@ -77,6 +78,8 @@ export interface RuntimeProvisionerDeps { fetchImpl: RuntimeFetch feedUrl: string platform: NodeJS.Platform + /** 런타임 번들의 아키텍처 확인용 (인덱스의 arch 와 비교) */ + arch: string stallTimeoutMs: number partAttempts: number } @@ -94,6 +97,7 @@ function defaultDeps(): RuntimeProvisionerDeps { fetchImpl: defaultFetch, feedUrl: RUNTIME_FEED_URL, platform: process.platform, + arch: process.arch, stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS, partAttempts: DEFAULT_PART_DOWNLOAD_ATTEMPTS, } @@ -197,13 +201,35 @@ export class RuntimeProvisioner extends EventEmitter { return existing } - const task = this._install(component).finally(() => { - this._inFlight.delete(component) - }) + const task = this._install(component) + .catch((err: unknown) => this._fallBackToInstalled(component, err, options)) + .finally(() => { + this._inFlight.delete(component) + }) this._inFlight.set(component, task) return task } + /** + * feed 가 이 앱의 최소 버전보다 낮으면(런타임 게시가 앱 게시보다 늦은 경우) 이미 설치된 + * 엔진을 계속 쓴다. 예전에는 여기서 예외를 올려, 멀쩡히 돌던 엔진까지 버리고 받아쓰기와 + * 로컬 전사가 feed 가 따라올 때까지 전부 멈췄다. 새 API 가 필요한 기능만 실패하는 편이 낫다. + * 사용자가 명시적으로 "다시 설치"(force)한 경우에는 실패를 그대로 알린다. + */ + private _fallBackToInstalled( + component: RuntimeComponent, + err: unknown, + options: EnsureRuntimeOptions, + ): string { + if (!options.force && isRuntimeIndexRejected(err, 'feed-outdated') && this.isInstalled(component)) { + logger.warn( + `런타임 feed가 최소 버전보다 낮아 설치된 ${component} 엔진을 계속 사용합니다 — ${err.message}`, + ) + return this.binaryPath(component) + } + throw err + } + private async _install(component: RuntimeComponent): Promise { const started = Date.now() logger.info(`런타임 설치 시작: ${component}`) @@ -261,7 +287,10 @@ export class RuntimeProvisioner extends EventEmitter { `런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`, ) } - return parseRuntimeIndex(await response.json(), component) + return parseRuntimeIndex(await response.json(), component, RUNTIME_MIN_VERSION[component], { + platform: this._deps.platform, + arch: this._deps.arch, + }) } private async _downloadParts( diff --git a/apps/desktop/src/main/services/SuggestionRepository.ts b/apps/desktop/src/main/services/SuggestionRepository.ts index e8812b9..a30a4dd 100644 --- a/apps/desktop/src/main/services/SuggestionRepository.ts +++ b/apps/desktop/src/main/services/SuggestionRepository.ts @@ -40,22 +40,30 @@ export interface SuggestionRecordInput { } export interface SuggestionRepository { - /** 제안 후보 1건을 기록한다. */ - record(input: SuggestionRecordInput): void - /** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다. */ + /** + * 제안 세션 1건을 기록한다(첫 후보 · 첫 요청 지연 · 후보 수). 행 id 를 돌려준다(실패하면 null). + * 한 세션은 한 행이다 — 채우기 후보를 따로 넣으면 평균 지연이 0 으로 희석되고 수락률이 1/N 로 깎인다. + */ + record(input: SuggestionRecordInput): string | null | void + /** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다 (세션 id 를 모를 때의 호환 경로). */ markAccepted(text: string): void + /** 세션 행의 후보 수를 갱신한다 (채우기 루프가 후보를 더할 때). */ + updateSessionCandidates?(id: string, candidateCount: number): void + /** 이 세션 행을 수락으로 표시하고, 실제로 고른 후보 원문을 남긴다. */ + markSessionAccepted?(id: string, acceptedText: string): void /** 최근 이력 (limit 는 1~200 으로 클램프). */ list(limit: number): SuggestionHistoryEntry[] } export function createSqliteSuggestionRepository(): SuggestionRepository { return { - record(input: SuggestionRecordInput): void { + record(input: SuggestionRecordInput): string | null { + const id = crypto.randomUUID() try { getDatabase() .insert(suggestions) .values({ - id: crypto.randomUUID(), + id, appName: input.appName, prefixText: input.prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS), suggestionText: input.text, @@ -66,8 +74,30 @@ export function createSqliteSuggestionRepository(): SuggestionRepository { createdAt: Date.now() }) .run() + return id } catch (error) { logger.warn(`제안 기록 실패: ${error instanceof Error ? error.message : String(error)}`) + return null + } + }, + + updateSessionCandidates(id: string, candidateCount: number): void { + try { + getDatabase().update(suggestions).set({ candidateCount }).where(eq(suggestions.id, id)).run() + } catch (error) { + logger.warn(`제안 후보 수 갱신 실패: ${error instanceof Error ? error.message : String(error)}`) + } + }, + + markSessionAccepted(id: string, acceptedText: string): void { + try { + getDatabase() + .update(suggestions) + .set({ accepted: true, suggestionText: acceptedText }) + .where(eq(suggestions.id, id)) + .run() + } catch (error) { + logger.warn(`수락 기록 실패: ${error instanceof Error ? error.message : String(error)}`) } }, diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index ab89c3b..530e7ef 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -86,7 +86,8 @@ export function createDefaultSuggestionServiceDeps(): SuggestionServiceDeps { return { repository: createSqliteSuggestionRepository(), budget: new SuggestionBudget(), - warmth: new ModelWarmTracker(), + // 상주 추정은 로컬 LLM 의 모든 요청(받아쓰기·음성 대화 포함)을 본다. + warmth: new ModelWarmTracker({ residentUntil: (model) => getLocalLLMService().residency?.residentUntil(model) ?? null }), foreground: { currentWindowHandle: () => getForegroundWindowInfo()?.hwnd ?? null }, @@ -186,6 +187,8 @@ export class SuggestionService extends EventEmitter { private _provenance: SuggestionProvenance | null = null /** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */ private _windowHandle: number | null = null + /** 이 세션의 이력 행 id — 한 세션은 한 행이다(채우기 후보·수락은 이 행을 갱신한다). */ + private _sessionRecordId: string | null = null /** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */ private readonly _budget: SuggestionBudget @@ -350,7 +353,24 @@ export class SuggestionService extends EventEmitter { setEnabled(enabled: boolean): void { configSet('suggestionEnabled', enabled) if (!enabled) this.dismiss('disabled') - else void this.warmUp() + // 입력 맥락(수집 동의)이 없으면 제안이 뜰 수 없다 — 모델만 데우지 않는다. + else if (hasInputContextConsent()) void this.warmUp() + this.emit('state-changed', this.getState()) + } + + /** + * 입력 수집 동의·일시정지가 바뀌었다. 입력 맥락이 끊기면 떠 있는 세션·채우기 루프를 닫고 + * 붙잡고 있던 마지막 문맥을 버린다(동의를 철회한 뒤 수동 요청이 옛 접두로 생성하지 않게). + * 다시 켜지면 제안이 켜져 있을 때만 모델을 데운다. + */ + handleInputConsentChanged(): void { + if (!hasInputContextConsent()) { + this.dismiss('disabled') + this._lastContext = null + this._lastRequestedPrefix = '' + } else if (this.isEnabled()) { + void this.warmUp() + } this.emit('state-changed', this.getState()) } @@ -579,6 +599,7 @@ export class SuggestionService extends EventEmitter { if (this.isPresentationActive) this.dismiss(reason) return { ok: false, reason } } + if (context.caretReliable === false) return { ok: false, reason: 'caret-unknown' } const prefix = normalizeRequestPrefix(context.prefix) if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' } @@ -619,6 +640,7 @@ export class SuggestionService extends EventEmitter { editedSinceFocus: context.editedSinceFocus, typedRecently: context.typedRecently, prefix: context.prefix, + caretReliable: context.caretReliable !== false, idleMs: context.idleMs, triggerDelayMs: config.triggerDelayMs, minPrefixChars: config.minPrefixChars, @@ -696,7 +718,11 @@ export class SuggestionService extends EventEmitter { if (!this._isCurrentFallbackContext(prefix, context, token)) return false const trimmedPrefix = normalizeSessionPrefix(prefix) const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName) - const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars) + // 원문 뒤 공백(단어를 끝냈는가)은 세션 접두 정규화가 지운다 — 최신 원문에서 읽어 넘긴다. + const rawPrefix = this._lastContext?.prefix ?? context.prefix + const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars, { + wordFinished: /\s$/u.test(rawPrefix) + }) if (entries.length === 0) return false this._partialText = '' @@ -795,6 +821,7 @@ export class SuggestionService extends EventEmitter { // 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고 // "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다. this._candidates = [] + this._sessionRecordId = null this._activeIndex = 0 this._anchor = context.anchor this._anchorKind = context.anchorKind @@ -904,6 +931,9 @@ export class SuggestionService extends EventEmitter { if (!timedOut) return this._lastSkipReason = 'generation-failed' + // warm 이라 믿고 보낸 요청이 시간 초과했다면 모델은 이미 내려갔다(다른 기능의 짧은 keep_alive 등) + // — 다음 멈춤은 같은 콜드 요청을 되풀이하지 말고 워밍업("준비 중")부터 한다. + this._warmth.noteCold(model) this._noteFailure(`timeout ${timeoutMs}ms`) if ( this._publishLocalMemory( @@ -1102,13 +1132,10 @@ export class SuggestionService extends EventEmitter { logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`) this._armVisibleTtl() this.emit('updated', this.getState()) - this._recordSuggestion({ - prefix, - text: candidateText, - model, - latencyMs: 0, - candidateCount: this._candidates.length - }) + // 채우기 후보는 새 행이 아니다 — 세션 행의 후보 수만 늘린다(지연 0 행이 평균·수락률을 망쳤다). + if (this._sessionRecordId) { + this._repository.updateSessionCandidates?.(this._sessionRecordId, this._candidates.length) + } return true } catch (error) { if (!abort.signal.aborted) { @@ -1320,6 +1347,7 @@ export class SuggestionService extends EventEmitter { this._generatedForPrefix = '' this._targetTotal = 0 this._provenance = null + this._sessionRecordId = null this._lastSkipReason = reason this._abort?.abort() this._abort = null @@ -1350,12 +1378,21 @@ export class SuggestionService extends EventEmitter { logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`) return { ok: false, reason: 'already-visible' } } + // 케어렛을 모르는 필드에서 문서 중간을 고친 뒤다 — 후보는 문서 끝의 이어 쓰기라 + // 실제 커서(문서 중간)에 붙이면 엉뚱한 곳에 문장이 들어간다. + if (this._lastContext?.caretReliable === false) { + logger.info('제안 수락 거부: 케어렛 위치를 알 수 없고 마지막 편집이 문서 끝이 아니다') + this.dismiss('caret-unknown') + this.emit('state-changed', this.getState()) + return { ok: false, reason: 'caret-unknown' } + } // 후보는 정제 과정에서 앞 공백을 잃는다 — 커서 앞 원문의 마지막 글자를 보고 구분 공백을 // 붙인다(joinSuggestion). 세션을 닫기 전에(dismiss 가 세션 접두를 지운다) 계산한다. const text = joinSuggestion(this._acceptPrefix(), candidate.text, candidate.joint ?? 'separate') const appName = this._appName const windowTitle = this._windowTitle const targetWindow = this._windowHandle + const sessionRecordId = this._sessionRecordId // 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다. this.dismiss('accepted') @@ -1401,7 +1438,13 @@ export class SuggestionService extends EventEmitter { logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`) // 이력은 후보 원문으로 기록돼 있다 — 구분 공백이 붙은 삽입 문자열로는 찾지 못한다. - this._repository.markAccepted(candidate.text) + // 세션 행을 알면 그 행을 수락으로 표시한다 — 텍스트로 찾으면 로컬 기억의 2번째 이후 후보는 + // 행이 없어 사라지거나 같은 문구의 옛 행(다른 앱·다른 날)이 수락으로 잡혔다. + if (sessionRecordId && this._repository.markSessionAccepted) { + this._repository.markSessionAccepted(sessionRecordId, candidate.text) + } else { + this._repository.markAccepted(candidate.text) + } // 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨). this._learning.recordAccepted(candidate.text, { appName, windowTitle }) return { ok: true } @@ -1455,7 +1498,8 @@ export class SuggestionService extends EventEmitter { learnTypedText: configGet('inputLearnTypedText') === true, appName: this._appName }) - this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName }) + const id = this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName }) + this._sessionRecordId = typeof id === 'string' ? id : null } /** 테스트/진단 — 현재 앱이 제외 대상인지. */ @@ -1483,6 +1527,11 @@ function sanitizePartial(raw: string): string { return lines.slice(0, 2).join(' ').slice(0, SUGGESTION_MAX_OUTPUT_CHARS) } +/** 입력 텔레메트리가 맥락을 보낼 수 있는 상태인가 (수집 동의 + 일시정지 아님). */ +export function hasInputContextConsent(): boolean { + return configGet('inputTelemetryEnabled') === true && configGet('inputTelemetryPaused') !== true +} + let instance: SuggestionService | null = null export function getSuggestionService(): SuggestionService { diff --git a/apps/desktop/src/main/services/VoiceCommandService.ts b/apps/desktop/src/main/services/VoiceCommandService.ts index 58a5a56..24a3fe9 100644 --- a/apps/desktop/src/main/services/VoiceCommandService.ts +++ b/apps/desktop/src/main/services/VoiceCommandService.ts @@ -1,140 +1,38 @@ // src/main/services/VoiceCommandService.ts // Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택. -// electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공. +// 규칙 저장/활성 상태는 VoiceCommandStorePort로, 매칭·기본값 정책은 voice-command-policy.ts로 분리. import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import type { VoiceCommandRule, VoiceCommandKeyword, - VoiceCommandMatch, - KeywordMatchMode + VoiceCommandMatch } from '@d3ro/core/types' +import { + buildDefaultRules, + findRuleMatch, + migrateLegacyDefaultRules +} from './voice-command-policy' const logger = getLogger('voice-command') // ============================================================ -// 기본 키워드 (프리셋 명령어용) +// 저장소 포트 (기본 어댑터 = electron-store/ConfigService) // ============================================================ -interface DefaultKeywordEntry { - instructionId: string - keywords: VoiceCommandKeyword[] - priority: number +export interface VoiceCommandStorePort { + loadRules(): VoiceCommandRule[] | undefined + saveRules(rules: VoiceCommandRule[]): void + loadEnabled(): boolean | undefined + saveEnabled(enabled: boolean): void } -const DEFAULT_KEYWORDS: ReadonlyArray = [ - { - instructionId: 'builtin-translate', - keywords: [ - { keyword: '번역해줘', matchMode: 'prefix' }, - { keyword: '번역', matchMode: 'prefix' }, - { keyword: '영어로', matchMode: 'prefix' }, - { keyword: 'translate', matchMode: 'prefix' } - ], - priority: 0 - }, - { - instructionId: 'builtin-summarize', - keywords: [ - { keyword: '요약해줘', matchMode: 'prefix' }, - { keyword: '요약', matchMode: 'prefix' }, - { keyword: 'summarize', matchMode: 'prefix' } - ], - priority: 1 - }, - { - instructionId: 'builtin-formal', - keywords: [ - { keyword: '다듬어줘', matchMode: 'prefix' }, - { keyword: '다듬기', matchMode: 'prefix' }, - { keyword: 'polish', matchMode: 'prefix' } - ], - priority: 2 - }, - { - instructionId: 'builtin-explain-code', - keywords: [ - { keyword: '설명해줘', matchMode: 'prefix' }, - { keyword: '설명', matchMode: 'prefix' }, - { keyword: 'explain', matchMode: 'prefix' } - ], - priority: 3 - } -] - -// ============================================================ -// electron-store 키 (ConfigService와 별도 네임스페이스) -// ============================================================ - -const STORE_KEY_RULES: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandRules' -const STORE_KEY_ENABLED: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandsEnabled' - -// ============================================================ -// 키워드 매칭 엔진 -// ============================================================ - -/** - * 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다. - * 키워드 앞뒤의 공백/구두점 경계를 존중한다. - */ -function matchKeyword( - text: string, - keyword: string, - mode: KeywordMatchMode -): { matched: boolean; cleanedText: string } { - const trimmed = text.trim() - const lowerText = trimmed.toLowerCase() - const lowerKeyword = keyword.toLowerCase() - - if (lowerKeyword.length === 0) { - return { matched: false, cleanedText: trimmed } - } - - switch (mode) { - case 'prefix': { - if (!lowerText.startsWith(lowerKeyword)) { - return { matched: false, cleanedText: trimmed } - } - // 키워드 뒤가 끝이거나 공백/구두점이어야 정확한 prefix 매칭 - const afterKeyword = trimmed.charAt(keyword.length) - if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) { - return { matched: false, cleanedText: trimmed } - } - const cleaned = trimmed.slice(keyword.length).trimStart() - return { matched: true, cleanedText: cleaned } - } - - case 'suffix': { - if (!lowerText.endsWith(lowerKeyword)) { - return { matched: false, cleanedText: trimmed } - } - // 키워드 앞이 시작이거나 공백/구두점이어야 정확한 suffix 매칭 - const beforeKeyword = trimmed.charAt(trimmed.length - keyword.length - 1) - if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) { - return { matched: false, cleanedText: trimmed } - } - const cleaned = trimmed.slice(0, trimmed.length - keyword.length).trimEnd() - return { matched: true, cleanedText: cleaned } - } - - case 'contains': { - const index = lowerText.indexOf(lowerKeyword) - if (index === -1) { - return { matched: false, cleanedText: trimmed } - } - // contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거 - const before = trimmed.slice(0, index) - const after = trimmed.slice(index + keyword.length) - const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim() - return { matched: true, cleanedText: cleaned } - } - } -} - -function isWordBoundary(char: string): boolean { - // 공백, 구두점, 한국어 조사/어미 앞의 경계 - return /[\s,.!?;:'"()[\]{}\-/]/.test(char) +const configVoiceCommandStore: VoiceCommandStorePort = { + loadRules: () => configGet('voiceCommandRules'), + saveRules: (rules) => configSet('voiceCommandRules', rules), + loadEnabled: () => configGet('voiceCommandsEnabled'), + saveEnabled: (enabled) => configSet('voiceCommandsEnabled', enabled) } // ============================================================ @@ -146,10 +44,13 @@ class VoiceCommandService { private enabled = false private initialized = false + constructor(private readonly store: VoiceCommandStorePort = configVoiceCommandStore) {} + initialize(): void { if (this.initialized) return this.loadRules() + this.migrateLegacyDefaults() this.loadEnabled() this.initialized = true logger.info( @@ -174,35 +75,21 @@ class VoiceCommandService { return noMatch } - const trimmed = text.trim() - if (trimmed.length === 0) { + const found = findRuleMatch(text, this.rules) + if (!found) { return noMatch } - // priority 오름차순 정렬 (낮은 값 = 높은 우선순위) - const sortedRules = [...this.rules] - .filter((r) => r.enabled && r.keywords.length > 0) - .sort((a, b) => a.priority - b.priority) - - for (const rule of sortedRules) { - for (const kw of rule.keywords) { - const result = matchKeyword(trimmed, kw.keyword, kw.matchMode) - if (result.matched) { - logger.info( - `Voice command matched: rule="${rule.id}", keyword="${kw.keyword}", instruction="${rule.instructionId}"` - ) - return { - matched: true, - ruleId: rule.id, - instructionId: rule.instructionId, - cleanedText: result.cleanedText, - matchedKeyword: kw.keyword - } - } - } + logger.info( + `Voice command matched: rule="${found.rule.id}", keyword="${found.keyword}", instruction="${found.rule.instructionId}"` + ) + return { + matched: true, + ruleId: found.rule.id, + instructionId: found.rule.instructionId, + cleanedText: found.cleanedText, + matchedKeyword: found.keyword } - - return noMatch } getAllRules(): VoiceCommandRule[] { @@ -256,15 +143,7 @@ class VoiceCommandService { return } - for (const entry of DEFAULT_KEYWORDS) { - this.rules.push({ - id: crypto.randomUUID(), - instructionId: entry.instructionId, - keywords: [...entry.keywords], - enabled: true, - priority: entry.priority - }) - } + this.rules = buildDefaultRules(() => crypto.randomUUID()) this.saveRules() logger.info(`Default voice command keywords initialized (${this.rules.length} rules)`) @@ -276,9 +155,21 @@ class VoiceCommandService { // ── Private ────────────────────────────────────────── + /** + * 이전 버전이 저장한 맨 명사 기본 키워드('요약', '번역', 'explain' …)는 일반 받아쓰기 + * 첫 단어를 삼키므로, 사용자가 손대지 않은 기본값이면 명령형 기본값으로 교체한다. + */ + private migrateLegacyDefaults(): void { + const { rules, migrated } = migrateLegacyDefaultRules(this.rules) + if (migrated === 0) return + this.rules = rules + this.saveRules() + logger.info(`Migrated ${migrated} legacy default voice command rule(s) to imperative keywords`) + } + private loadRules(): void { try { - const stored = configGet(STORE_KEY_RULES) as VoiceCommandRule[] | undefined + const stored = this.store.loadRules() if (Array.isArray(stored) && stored.length > 0) { this.rules = stored return @@ -291,7 +182,7 @@ class VoiceCommandService { private saveRules(): void { try { - configSet(STORE_KEY_RULES, this.rules) + this.store.saveRules(this.rules) } catch (error) { logger.warn( `Failed to save voice command rules: ${error instanceof Error ? error.message : String(error)}` @@ -301,7 +192,7 @@ class VoiceCommandService { private loadEnabled(): void { try { - const stored = configGet(STORE_KEY_ENABLED) as boolean | undefined + const stored = this.store.loadEnabled() this.enabled = stored === true } catch { this.enabled = false @@ -310,7 +201,7 @@ class VoiceCommandService { private saveEnabled(): void { try { - configSet(STORE_KEY_ENABLED, this.enabled) + this.store.saveEnabled(this.enabled) } catch (error) { logger.warn( `Failed to save voice command enabled state: ${error instanceof Error ? error.message : String(error)}` @@ -332,6 +223,11 @@ export function getVoiceCommandService(): VoiceCommandService { return instance } +/** 테스트/조립용: 저장소 포트를 주입한 새 인스턴스를 만든다 (싱글턴과 무관). */ +export function createVoiceCommandService(store?: VoiceCommandStorePort): VoiceCommandService { + return new VoiceCommandService(store) +} + export function resetVoiceCommandServiceForTests(): void { instance = null } diff --git a/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts b/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts index 7ae530d..2011691 100644 --- a/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts +++ b/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts @@ -40,6 +40,9 @@ export class StreamingCaptionTrack { private _context = '' /** 버퍼를 자를 때마다 바뀐다 — 자르기 전에 시작한 인식 결과는 버린다 */ private _epoch = 0 + /** 연속 인식 실패 수 — 성공하면 0. core 가 백오프·버퍼 상한을 정한다 */ + private _consecutiveFailures = 0 + private _lastFailureAt = 0 private readonly _now: () => number constructor(private readonly _deps: StreamingCaptionTrackDeps, initialContext = '') { @@ -51,6 +54,11 @@ export class StreamingCaptionTrack { return this._buffer.length / BYTES_PER_MS } + /** 연속 인식 실패 수 (진단·테스트) */ + get consecutiveFailures(): number { + return this._consecutiveFailures + } + push(chunk: Buffer): void { this._buffer = this._buffer.length === 0 ? chunk : Buffer.concat([this._buffer, chunk]) if (this._deps.isVoiced(chunk)) this._lastVoiceAt = this._now() @@ -64,13 +72,21 @@ export class StreamingCaptionTrack { hasVoice: this._lastVoiceAt > 0, sinceVoiceMs: this._lastVoiceAt > 0 ? now - this._lastVoiceAt : Number.MAX_SAFE_INTEGER, sincePartialMs: now - this._lastPartialAt, - busy: this._busy + busy: this._busy, + consecutiveFailures: this._consecutiveFailures, + sinceFailureMs: this._consecutiveFailures > 0 ? now - this._lastFailureAt : Number.MAX_SAFE_INTEGER }) if (action === 'trim-idle') { this._keepTail(CAPTION_STREAMING_DEFAULTS.idleKeepMs) return } + if (action === 'drop-overflow') { + // 인식이 계속 실패하는 동안 쌓인 앞부분은 버린다 — 그 구간은 자막으로 남지 않는다. + this._keepTail(CAPTION_STREAMING_DEFAULTS.failureRetainMs) + this._epoch += 1 + return + } if (action === 'partial') return this._run(() => this._partial()) if (action === 'finalize') return this._run(() => this._finalize()) if (action === 'force-commit') { @@ -92,7 +108,10 @@ export class StreamingCaptionTrack { this._busy = true try { await job() + this._consecutiveFailures = 0 } catch (error) { + this._consecutiveFailures += 1 + this._lastFailureAt = this._now() // 보고 경로가 던져도 트랙(과 이를 기다리는 flush/stop)은 멈추지 않는다 try { this._deps.onError(error) diff --git a/apps/desktop/src/main/services/cloud/cloud-credentials.ts b/apps/desktop/src/main/services/cloud/cloud-credentials.ts new file mode 100644 index 0000000..2237072 --- /dev/null +++ b/apps/desktop/src/main/services/cloud/cloud-credentials.ts @@ -0,0 +1,62 @@ +// src/main/services/cloud/cloud-credentials.ts +// 계정 자격 증명 포트 — Premium LLM·Cloud STT·음성 대화가 CloudSyncService 전체가 아니라 +// "지금 로그인한 계정의 자격 증명"만 보게 한다(ISP/DIP). +// +// 정본은 CloudSyncService 의 세션 상태다. 로그아웃(서버 응답과 무관)하면 이 포트의 모든 접근자가 +// null / 'no session' 을 돌려준다 — Supabase 클라이언트에 남은 세션으로 옛 계정의 쿼터를 쓰지 않게. + +import { getCloudSyncService } from '../CloudSyncService' + +export interface CloudFunctionResult { + data: unknown + error: { message: string } | null +} + +export interface CloudFunctionStreamResult { + stream: ReadableStream | null + error: { message: string } | null +} + +export interface CloudInvokeOptions { + signal?: AbortSignal + timeoutMs?: number +} + +export interface CloudCredentials { + /** 클라우드가 설정됐고 로그인한 계정이 있으면 true */ + hasCredentials(): boolean + /** 현재 계정의 access token(필요하면 갱신). 로그아웃 상태면 null */ + accessToken(): Promise + /** Edge Function 호출. 로그아웃 상태면 error 를 돌려준다 */ + invoke(name: string, body: Record | FormData, options?: CloudInvokeOptions): Promise + /** Edge Function SSE 호출. 로그아웃 상태면 error 를 돌려준다 */ + invokeStream(name: string, body: Record, signal?: AbortSignal): Promise +} + +/** Cloud STT 드라이버가 쓰는 자격 증명 + 엔드포인트 설정 */ +export interface CloudSttGateway { + getAccessToken(): Promise + getSupabaseUrl(): string | null + getAnonKey(): string | null +} + +/** CloudSyncService 싱글톤을 자격 증명 포트로 감싼 기본 어댑터(호출 시점에 해석 — 테스트 모킹과 순환 import 안전). */ +export const cloudSyncCredentials: CloudCredentials = { + hasCredentials: () => { + const cloud = getCloudSyncService() + return cloud.isEnabled() && cloud.isAuthenticated() + }, + accessToken: () => getCloudSyncService().getAccessToken(), + invoke: (name, body, options) => + options ? getCloudSyncService().invokeFunction(name, body, options) : getCloudSyncService().invokeFunction(name, body), + invokeStream: (name, body, signal) => getCloudSyncService().invokeFunctionStream(name, body, signal), +} + +/** 기본 Cloud STT 게이트웨이 — 토큰은 자격 증명 포트에서, 엔드포인트는 설정에서. */ +export function createCloudSttGateway(credentials: CloudCredentials = cloudSyncCredentials): CloudSttGateway { + return { + getAccessToken: () => credentials.accessToken(), + getSupabaseUrl: () => getCloudSyncService().getSupabaseUrl(), + getAnonKey: () => getCloudSyncService().getAnonKey(), + } +} diff --git a/apps/desktop/src/main/services/dictionary/dictionary-file-codec.ts b/apps/desktop/src/main/services/dictionary/dictionary-file-codec.ts new file mode 100644 index 0000000..b338e29 --- /dev/null +++ b/apps/desktop/src/main/services/dictionary/dictionary-file-codec.ts @@ -0,0 +1,211 @@ +// src/main/services/dictionary/dictionary-file-codec.ts +// 사전 내보내기/가져오기 파일 형식(CSV·JSON) — 순수 함수만 둔다 (fs/dialog/DB/동기화 의존 없음). +// DictionaryService 는 파일 IO·DB 쓰기·동기화 push 만 맡고, 형식 변환과 행 검증은 여기서 한다. + +import { validateDictionaryDraft, type DictionaryWriteProblem } from '@d3ro/core/dictionary-policy' +import type { DictionaryEntry } from '@d3ro/core/types' + +export type DictionaryFileFormat = 'csv' | 'json' + +export const DICTIONARY_CSV_HEADER = [ + 'word', + 'pronunciation', + 'category', + 'usageCount', + 'createdAt', + 'updatedAt' +] as const + +function csvCell(value: unknown): string { + const text = value === null || value === undefined ? '' : String(value) + const escaped = text.replace(/"/g, '""') + const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped) + return needsQuotes ? `"${escaped}"` : escaped +} + +function parseCsvRows(input: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let inQuotes = false + for (let i = 0; i < input.length; i += 1) { + const char = input[i] + if (inQuotes) { + if (char === '"') { + if (input[i + 1] === '"') { + field += '"' + i += 1 + } else { + inQuotes = false + } + } else { + field += char + } + continue + } + if (char === '"') { + inQuotes = true + } else if (char === ',') { + row.push(field) + field = '' + } else if (char === '\n') { + row.push(field) + rows.push(row) + row = [] + field = '' + } else if (char === '\r') { + // skip CR; LF terminates the row + } else { + field += char + } + } + if (field.length > 0 || row.length > 0) { + row.push(field) + rows.push(row) + } + return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0)) +} + +function pickString(source: Record, keys: string[]): string | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'string' && value.trim().length > 0) return value.trim() + } + return null +} + +function pickNumber(source: Record, keys: string[]): number | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + } + return null +} + +function normalizeCategory(value: string | null): DictionaryEntry['category'] { + if (value === 'auto' || value === 'technical' || value === 'user') return value + return 'user' +} + +function serializeJson(entries: readonly DictionaryEntry[]): string { + return JSON.stringify({ entries }, null, 2) +} + +function serializeCsv(entries: readonly DictionaryEntry[]): string { + const lines = [DICTIONARY_CSV_HEADER.join(',')] + for (const entry of entries) { + lines.push( + [ + csvCell(entry.word), + csvCell(entry.pronunciation ?? ''), + csvCell(entry.category), + csvCell(entry.usageCount), + csvCell(entry.createdAt), + csvCell(entry.updatedAt) + ].join(',') + ) + } + return `\uFEFF${lines.join('\r\n')}\r\n` +} + +/** 사전 항목을 파일 내용으로 직렬화한다. */ +export function serializeDictionary( + entries: readonly DictionaryEntry[], + format: DictionaryFileFormat +): string { + return format === 'csv' ? serializeCsv(entries) : serializeJson(entries) +} + +function parseJson(raw: string): Array> { + const data: unknown = JSON.parse(raw) + let list: unknown + if (Array.isArray(data)) { + list = data + } else if ( + data && + typeof data === 'object' && + Array.isArray((data as { entries?: unknown }).entries) + ) { + list = (data as { entries: unknown[] }).entries + } else { + throw new Error('expected an array or an object with an "entries" array') + } + return (list as unknown[]).filter( + (item): item is Record => !!item && typeof item === 'object' + ) +} + +function parseCsv(raw: string): Array> { + const rows = parseCsvRows(raw.replace(/^\uFEFF/, '')) + if (rows.length === 0) { + throw new Error('empty CSV') + } + const header = rows[0].map((cell) => cell.trim()) + if (!header.includes('word')) { + throw new Error('missing "word" column') + } + return rows.slice(1).map((cells) => { + const record: Record = {} + header.forEach((key, index) => { + record[key] = cells[index] ?? '' + }) + return record + }) +} + +/** 파일 내용을 레코드 목록으로 파싱한다. 형식이 맞지 않으면 Error 를 던진다. */ +export function parseDictionaryFile( + raw: string, + format: DictionaryFileFormat +): Array> { + return format === 'json' ? parseJson(raw) : parseCsv(raw) +} + +/** 가져올 한 행의 정규화된 쓰기 값 (id 는 서비스가 붙인다) */ +export interface DictionaryImportRow { + word: string + pronunciation: string | null + category: DictionaryEntry['category'] + usageCount: number + lastUsedAt: number | null + createdAt: number + updatedAt: number +} + +export type DictionaryImportRowResult = + | { ok: true; row: DictionaryImportRow } + | { ok: false; problem: DictionaryWriteProblem } + +/** + * 파싱한 레코드 하나를 저장할 행으로 바꾼다. 빈 단어나 서버 길이 제한을 넘는 단어·발음은 거부한다 + * (받아들이면 로컬에만 남고 서버가 22023 으로 거부해 다른 기기로 동기화되지 않는다). + */ +export function toDictionaryImportRow( + record: Record, + now: number +): DictionaryImportRowResult { + const draft = validateDictionaryDraft({ + word: pickString(record, ['word']) ?? '', + pronunciation: pickString(record, ['pronunciation']) + }) + if (!draft.ok) return { ok: false, problem: draft } + const createdAt = pickNumber(record, ['createdAt', 'created_at']) + const updatedAt = pickNumber(record, ['updatedAt', 'updated_at']) + const usageCount = pickNumber(record, ['usageCount', 'usage_count']) + return { + ok: true, + row: { + word: draft.draft.word, + pronunciation: draft.draft.pronunciation, + category: normalizeCategory(pickString(record, ['category'])), + usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0, + lastUsedAt: pickNumber(record, ['lastUsedAt', 'last_used_at']), + createdAt: createdAt ?? now, + updatedAt: updatedAt ?? createdAt ?? now + } + } +} diff --git a/apps/desktop/src/main/services/llm/model-residency.ts b/apps/desktop/src/main/services/llm/model-residency.ts new file mode 100644 index 0000000..3e6879b --- /dev/null +++ b/apps/desktop/src/main/services/llm/model-residency.ts @@ -0,0 +1,56 @@ +// src/main/services/llm/model-residency.ts +// +// Ollama 모델 상주(keep_alive) 추정 — 로컬 LLM 의 "모든" 요청을 기록한다. +// +// Ollama 는 모델의 만료 시각을 그 모델에 온 가장 최근 요청의 keep_alive 로 다시 정한다. +// 제안 서비스가 자기 요청(10m)만 보고 warm 을 판정하면, 받아쓰기(기본 5m)·음성 대화(2m)가 +// 같은 모델을 더 짧게 덮어쓴 뒤에도 warm 으로 믿어 콜드 모델로 요청해 타임아웃됐다. + +/** keep_alive 를 생략하면 Ollama 서버 기본값(5분)이 적용된다. */ +export const OLLAMA_DEFAULT_KEEP_ALIVE_MS = 5 * 60_000 + +/** + * Ollama keep_alive 값을 ms 로 푼다. 음수는 "무기한"(Infinity), 0 은 즉시 내림, 해석할 수 없으면 기본값. + * 문자열은 Go duration 형식('10m', '30s', '1h30m')과 초 단위 숫자 문자열을 받는다. + */ +export function parseKeepAliveMs(value: string | number | undefined | null): number { + if (value === undefined || value === null || value === '') return OLLAMA_DEFAULT_KEEP_ALIVE_MS + if (typeof value === 'number') { + if (!Number.isFinite(value)) return OLLAMA_DEFAULT_KEEP_ALIVE_MS + return value < 0 ? Number.POSITIVE_INFINITY : value * 1000 + } + const trimmed = value.trim() + if (/^-?\d+(?:\.\d+)?$/u.test(trimmed)) return parseKeepAliveMs(Number(trimmed)) + if (trimmed.startsWith('-')) return Number.POSITIVE_INFINITY + const unitMs: Record = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 } + let total = 0 + let matched = '' + for (const part of trimmed.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/gu)) { + total += Number(part[1]) * unitMs[part[2]] + matched += part[0] + } + return matched.length > 0 && matched === trimmed ? total : OLLAMA_DEFAULT_KEEP_ALIVE_MS +} + +/** 모델이 언제까지 메모리에 남아 있을지 알려 주는 포트 (제안 온기 판정이 읽는다). */ +export interface ModelResidencyPort { + /** 알려진 만료 시각(ms). 이 앱에서 그 모델로 요청한 적이 없으면 null */ + residentUntil(model: string): number | null +} + +/** 요청 완료마다 (모델, keep_alive, 완료 시각)을 기록한다. 가장 최근 요청이 만료 시각을 정한다. */ +export class ModelResidencyLedger implements ModelResidencyPort { + private readonly _expiresAt = new Map() + + noteRequest(model: string, keepAlive: string | number | undefined, completedAt: number): void { + this._expiresAt.set(model, completedAt + parseKeepAliveMs(keepAlive)) + } + + residentUntil(model: string): number | null { + return this._expiresAt.get(model) ?? null + } + + reset(): void { + this._expiresAt.clear() + } +} diff --git a/apps/desktop/src/main/services/meeting/diarization-context.ts b/apps/desktop/src/main/services/meeting/diarization-context.ts new file mode 100644 index 0000000..48c21a6 --- /dev/null +++ b/apps/desktop/src/main/services/meeting/diarization-context.ts @@ -0,0 +1,56 @@ +// src/main/services/meeting/diarization-context.ts +// +// LLM 화자 추정을 조각(1500자)마다 나눠 돌릴 때 조각 사이에 화자 번호를 이어 주는 순수 규칙. +// +// 조각마다 독립적으로 물으면 모델이 조각마다 처음 말한 사람을 '화자 1' 로 다시 매겨, 긴 회의에서 +// '화자 1' 이 3~5분마다 다른 사람을 가리켰다. 앞 조각의 라벨된 마지막 몇 줄과 지금까지의 화자 목록을 +// 다음 조각에 참고로 넘긴다. 참고 블록은 사용자 메시지 안에 경계를 두어 넣고(전사 원문을 시스템 권한으로 +// 올리지 않는다), 모델이 그 줄을 다시 출력하면 걷어낸다. + +const SPEAKER_LABEL = /화자\s*(\d+)/gu + +/** 텍스트에 나온 화자 라벨 ('화자 1' …) — 번호 순, 중복 없음 */ +export function extractSpeakerLabels(text: string): string[] { + const numbers = new Set() + for (const match of text.matchAll(SPEAKER_LABEL)) numbers.add(Number(match[1])) + return [...numbers].sort((a, b) => a - b).map((n) => `화자 ${n}`) +} + +/** 마지막 n 줄 (빈 줄 제외) */ +export function lastLines(text: string, count: number): string[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(-count) +} + +export const DIARIZATION_CONTEXT_OPEN = '[이전 조각 끝부분 — 화자 번호를 이어 쓰기 위한 참고입니다. 다시 출력하지 마세요]' +export const DIARIZATION_CONTEXT_CLOSE = '[/이전 조각 끝부분]' + +/** 다음 조각의 사용자 메시지 — 앞 조각의 라벨된 꼬리를 참고 블록으로 앞에 붙인다. 첫 조각은 그대로. */ +export function buildDiarizationChunkInput(chunk: string, previousTail: readonly string[]): string { + if (previousTail.length === 0) return chunk + return `${DIARIZATION_CONTEXT_OPEN}\n${previousTail.join('\n')}\n${DIARIZATION_CONTEXT_CLOSE}\n\n${chunk}` +} + +/** 시스템 프롬프트에 덧붙일 화자 연속성 지시 — 화자 목록만 담는다(전사 원문은 넣지 않는다). */ +export function diarizationContinuityHint(knownSpeakers: readonly string[]): string { + if (knownSpeakers.length === 0) return '' + return `\n앞부분에서 이미 ${knownSpeakers.join(', ')} 로 표시했습니다. 같은 사람에게는 같은 번호를 이어 쓰고, 새 사람에게만 다음 번호를 주세요.` +} + +/** 모델이 참고 블록(경계 표시·앞 조각 줄)을 다시 출력했으면 걷어낸다. */ +export function stripEchoedContext(output: string, previousTail: readonly string[]): string { + if (previousTail.length === 0) return output.trim() + const echoed = new Set(previousTail.map((line) => line.trim())) + return output + .split('\n') + .filter((line) => { + const trimmed = line.trim() + if (trimmed === DIARIZATION_CONTEXT_OPEN || trimmed === DIARIZATION_CONTEXT_CLOSE) return false + return !echoed.has(trimmed) + }) + .join('\n') + .trim() +} diff --git a/apps/desktop/src/main/services/meeting/local-recording-ledger.ts b/apps/desktop/src/main/services/meeting/local-recording-ledger.ts new file mode 100644 index 0000000..82e40a9 --- /dev/null +++ b/apps/desktop/src/main/services/meeting/local-recording-ledger.ts @@ -0,0 +1,38 @@ +// src/main/services/meeting/local-recording-ledger.ts +// +// "이 기기에서 시작한 회의 녹음" 기록 (로컬 전용 sync_state, 서버로 가지 않는다). +// +// 중단 복구(recoverInterruptedSessions)는 이 기기가 녹음하다 끊긴 회의만 닫아야 한다. 동기화로 들어온 +// 폰의 녹음 중·처리 중 회의까지 '중단됨' 오류로 닫고 push 하면, 폰의 녹음 처리가 깨지고 서버의 완료된 +// 전사가 오래된 로컬 사본(NULL)으로 덮였다. + +import { deleteSyncState, getSyncState, setSyncState } from '../sync/sync-outbox' + +const KEY_PREFIX = 'meeting:local-recording:' + +export interface LocalRecordingLedger { + /** 이 기기에서 녹음을 시작했다 */ + mark(meetingId: string): void + /** 녹음이 저장·종료됐다 (더 복구할 것이 없다) */ + clear(meetingId: string): void + /** 이 기기에서 시작해 아직 끝나지 않은 녹음인가 */ + isLocal(meetingId: string): boolean +} + +export const syncStateRecordingLedger: LocalRecordingLedger = { + mark: (meetingId) => setSyncState(`${KEY_PREFIX}${meetingId}`, '1'), + clear: (meetingId) => deleteSyncState(`${KEY_PREFIX}${meetingId}`), + isLocal: (meetingId) => getSyncState(`${KEY_PREFIX}${meetingId}`) === '1', +} + +/** + * 중단 복구 대상인가 — 이 기기에서 시작한 녹음이거나, 기록이 없던 구버전 녹음 중 이 기기에 전사 + * 체크포인트가 남은 것. 전사도 표식도 없는 행은 다른 기기(폰)의 진행 중인 회의일 수 있어 건드리지 않는다. + */ +export function isRecoverableMeeting( + row: { id: string; rawTranscript: string | null }, + ledger: LocalRecordingLedger +): boolean { + if (ledger.isLocal(row.id)) return true + return !!row.rawTranscript && row.rawTranscript.trim().length > 0 +} diff --git a/apps/desktop/src/main/services/meeting/transcript-condenser.ts b/apps/desktop/src/main/services/meeting/transcript-condenser.ts index b2261b3..f0d9c82 100644 --- a/apps/desktop/src/main/services/meeting/transcript-condenser.ts +++ b/apps/desktop/src/main/services/meeting/transcript-condenser.ts @@ -2,7 +2,8 @@ // 긴 회의 전사를 LLM 한 번의 입력 한도 안으로 줄이는 map-reduce 단계. // // 문서 생성·요약은 전사 전체를 메시지 하나로 보냈고, 8,000자를 넘는 회의(대략 25분 이상)는 llm-proxy 가 -// 400 으로 거부했다. 한도를 넘으면 전사를 줄 경계 조각으로 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을 +// 400 으로 거부했다. 한도를 넘으면 전사를 한도 이하 조각(줄 경계, 줄바꿈 없는 긴 줄은 문장·공백·글자 수 경계)으로 +// 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을 // 이어 붙여(reduce 입력) 호출자가 한 번에 보낸다. 몇 번 줄여도 넘으면 조용히 자르지 않고 실패시킨다. // // LLM 은 포트(generate)로만 받는다 — 게이트웨이든 가짜든 된다. diff --git a/apps/desktop/src/main/services/meeting/transcript-revision-store.ts b/apps/desktop/src/main/services/meeting/transcript-revision-store.ts new file mode 100644 index 0000000..c36e569 --- /dev/null +++ b/apps/desktop/src/main/services/meeting/transcript-revision-store.ts @@ -0,0 +1,69 @@ +// src/main/services/meeting/transcript-revision-store.ts +// +// 회의 전사 수정본(editedTranscript) 쓰기의 단일 관문 (SRP · DIP). +// +// 수동 편집·실시간 세그먼트 수정·AI 다듬기·화자 구분이 각자 editedTranscript 를 덮어쓰면서 기준본 규칙이 +// 제각각이었다(다듬기는 원문, 화자 구분은 수정본). 그래서 다듬기가 화자 라벨과 직접 수정을 지웠고, +// LLM 이 도는 동안 한 수동 편집이 마지막 쓰기에 덮였다. 이 저장소가 "어떤 기준본 위의 수정인가" 를 +// revision(원문·수정본 내용 해시)으로 확인한다(compare-and-set). + +import { createHash } from 'crypto' + +export interface TranscriptRevision { + raw: string | null + edited: string | null + /** raw·edited 내용 해시 — 제목 변경·상태 전이·동기화처럼 전사와 무관한 updatedAt 변화에는 흔들리지 않는다 */ + revision: string +} + +export type TranscriptWriteResult = 'ok' | 'conflict' | 'not-found' + +export interface MeetingTranscriptStore { + read(sessionId: string): TranscriptRevision | null + /** + * 수정본을 쓴다. baseRevision 을 주면 그 사이 전사가 바뀌었을 때 쓰지 않고 'conflict' 를 돌려준다 + * (LLM 작업 경로). 생략하면 무조건 쓴다(사용자의 수동 저장). + */ + writeEdited(sessionId: string, editedTranscript: string, baseRevision?: string): TranscriptWriteResult +} + +export function transcriptRevision(raw: string | null, edited: string | null): string { + return createHash('sha256').update(JSON.stringify([raw, edited])).digest('hex') +} + +/** 다시 쓰기(다듬기·화자 구분)의 기준본 — 사용자의 수정·화자 라벨이 담긴 수정본이 우선이다. */ +export function transcriptBase(revision: Pick): string | null { + return revision.edited ?? revision.raw +} + +/** SQLite 어댑터가 쓰는 최소 행 접근 — better-sqlite3 는 동기라 read→compare→write 가 원자적이다. */ +export interface TranscriptRowAccess { + readRow(sessionId: string): { rawTranscript: string | null; editedTranscript: string | null } | undefined + writeEdited(sessionId: string, editedTranscript: string, now: number): void + /** 쓰기 뒤 동기화 예약 */ + onWritten(sessionId: string): void +} + +export function createTranscriptStore(access: TranscriptRowAccess): MeetingTranscriptStore { + return { + read(sessionId) { + const row = access.readRow(sessionId) + if (!row) return null + return { + raw: row.rawTranscript, + edited: row.editedTranscript, + revision: transcriptRevision(row.rawTranscript, row.editedTranscript), + } + }, + writeEdited(sessionId, editedTranscript, baseRevision) { + const row = access.readRow(sessionId) + if (!row) return 'not-found' + if (baseRevision !== undefined && transcriptRevision(row.rawTranscript, row.editedTranscript) !== baseRevision) { + return 'conflict' + } + access.writeEdited(sessionId, editedTranscript, Date.now()) + access.onWritten(sessionId) + return 'ok' + }, + } +} diff --git a/apps/desktop/src/main/services/rag/remote-document.ts b/apps/desktop/src/main/services/rag/remote-document.ts new file mode 100644 index 0000000..46d1bf5 --- /dev/null +++ b/apps/desktop/src/main/services/rag/remote-document.ts @@ -0,0 +1,44 @@ +// src/main/services/rag/remote-document.ts +// 다른 기기에서 받은 지식 문서를 로컬에 어떻게 반영할지 정하는 순수 정책. +// 원문 청크 자체를 버전으로 쓴다 — 서버에 별도 리비전 컬럼이 없어도 재색인(원문 변경)을 알아챈다. + +import type { RAGDocument } from '@d3ro/core/types' + +/** 동기화로 받은 지식 문서(원문 청크만, 임베딩 없음) */ +export interface RemoteKnowledgeDocument { + id: string + fileName: string + fileType: RAGDocument['fileType'] + chunks: readonly string[] + addedAt: number +} + +export type RemoteDocumentPlan = + | { kind: 'skip' } + | { kind: 'insert'; chunks: string[] } + | { kind: 'replace'; chunks: string[] } + +/** 빈 청크는 저장·임베딩 대상이 아니다 */ +export function usableChunks(chunks: readonly string[]): string[] { + return chunks.filter((c) => c.trim().length > 0) +} + +export function sameChunks(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((content, i) => content === b[i]) +} + +/** + * @param localChunks 로컬에 저장된 원문 청크(chunkIndex 순). 문서가 로컬에 없으면 null. + * @param remoteChunks 서버에서 받은 완전한 원문 청크. + * - 로컬에 없으면 insert + * - 로컬에 있고 원문이 같으면 skip(자기 push의 에코, 제목만 바뀐 행 등) + * - 로컬에 있고 원문이 다르면 replace(다른 기기에서 재색인됨) + * 받은 청크가 전부 비어 있으면 로컬을 비우지 않고 skip한다. + */ +export function planRemoteDocument(localChunks: readonly string[] | null, remoteChunks: readonly string[]): RemoteDocumentPlan { + const chunks = usableChunks(remoteChunks) + if (chunks.length === 0) return { kind: 'skip' } + if (localChunks === null) return { kind: 'insert', chunks } + if (sameChunks(usableChunks(localChunks), chunks)) return { kind: 'skip' } + return { kind: 'replace', chunks } +} diff --git a/apps/desktop/src/main/services/recording/recording-store.ts b/apps/desktop/src/main/services/recording/recording-store.ts new file mode 100644 index 0000000..e682405 --- /dev/null +++ b/apps/desktop/src/main/services/recording/recording-store.ts @@ -0,0 +1,55 @@ +// src/main/services/recording/recording-store.ts +// +// 받아쓰기 녹음 저장 포트 (DIP) — 오케스트레이터(VoiceModeService)가 electron 경로·fs·WAV 헤더를 +// 직접 들지 않게 한다. 경로 규칙의 정본은 history-deletion.recordingsDir(), WAV 인코딩의 정본은 +// stt/audio-utils.pcmToWav 다. +// +// 저장은 완료 이벤트보다 먼저 끝나야 한다: 히스토리 행(audioLocalPath)과 history_audio 동기화가 +// 파일이 생기기 전에 돌면, flush 가 "파일 없음"을 성공으로 처리해 녹음이 영영 올라가지 않았다. + +import path from 'path' +import { mkdir, writeFile } from 'fs/promises' +import { getLogger } from '../LoggerService' +import { pcmToWav } from '../stt/audio-utils' + +const logger = getLogger('RecordingStore') + +export interface RecordingStore { + /** + * 16kHz 16-bit mono PCM 을 WAV 로 저장하고 실제 경로를 돌려준다. + * 오디오가 없거나 저장에 실패하면 null — 호출자는 이 경로만 히스토리에 기록한다. 예외를 던지지 않는다. + */ + save(sessionId: string, pcm: Buffer | null): Promise +} + +export interface FsRecordingStoreDeps { + /** 녹음 디렉터리 (기본: history-deletion.recordingsDir — 삭제 경로와 같은 정본) */ + resolveDir(): Promise + mkdir(dir: string): Promise + writeFile(filePath: string, data: Buffer): Promise +} + +const defaultDeps: FsRecordingStoreDeps = { + resolveDir: async () => (await import('../history-deletion')).recordingsDir(), + mkdir: (dir) => mkdir(dir, { recursive: true }), + writeFile: (filePath, data) => writeFile(filePath, data), +} + +export function createFsRecordingStore(deps: FsRecordingStoreDeps = defaultDeps): RecordingStore { + return { + async save(sessionId: string, pcm: Buffer | null): Promise { + if (!pcm || pcm.length === 0) return null + try { + const dir = await deps.resolveDir() + await deps.mkdir(dir) + const wavPath = path.join(dir, `${sessionId}.wav`) + await deps.writeFile(wavPath, pcmToWav(pcm, 16000, 1, 16)) + logger.info(`Audio saved: ${wavPath} (${Math.round(pcm.length / 1024)}KB)`) + return wavPath + } catch (error) { + logger.warn(`Audio save failed: ${error instanceof Error ? error.message : String(error)}`) + return null + } + }, + } +} diff --git a/apps/desktop/src/main/services/runtime/runtime-index.ts b/apps/desktop/src/main/services/runtime/runtime-index.ts index 2d767de..1c6fd46 100644 --- a/apps/desktop/src/main/services/runtime/runtime-index.ts +++ b/apps/desktop/src/main/services/runtime/runtime-index.ts @@ -58,6 +58,53 @@ export interface RuntimeComponentIndex { parts: RuntimePart[] } +/** + * 런타임 번들이 만들어진 플랫폼·아키텍처. 사이드카(PyInstaller)와 ffmpeg 는 네이티브 + * 실행 파일이라 빌드한 호스트에서만 돈다. 인덱스가 이 값을 밝히면 다른 플랫폼 앱은 + * 부품(~160MB)을 받기 전에 거부한다. + */ +export interface RuntimeTarget { + platform: string + arch: string +} + +/** 인덱스를 거부한 이유 — 호출 측이 "기존 엔진으로 버틸지" 판단할 때 쓴다 */ +export type RuntimeIndexRejection = 'feed-outdated' | 'platform-mismatch' + +/** + * 형식은 맞지만 이 앱이 쓸 수 없는 인덱스. 형식 오류(invalid)와 달리 이유를 싣는다. + * - feed-outdated : feed 버전이 이 앱의 최소 요구 버전보다 낮다 (런타임 게시가 앱보다 늦음) + * - platform-mismatch : 다른 플랫폼·아키텍처용 번들이다 (예: macOS 앱이 Windows 엔진을 받음) + */ +export class RuntimeIndexRejectedError extends D3ROError { + readonly reason: RuntimeIndexRejection + + constructor(reason: RuntimeIndexRejection, message: string) { + super(ErrorCode.ConfigReadFailed, message, { reason }) + this.reason = reason + } +} + +export function isRuntimeIndexRejected( + err: unknown, + reason?: RuntimeIndexRejection, +): err is RuntimeIndexRejectedError { + return err instanceof RuntimeIndexRejectedError && (reason === undefined || err.reason === reason) +} + +/** + * 인덱스가 밝힌 대상 플랫폼. platform/arch 가 없는 예전 인덱스는 null(미지정)로 본다 — + * 그런 인덱스는 게시 게이트(scripts/ci/lib/runtime-feed-gate.mjs)가 더는 통과시키지 않는다. + */ +function parseIndexTarget(raw: Record): RuntimeTarget | null { + const { platform, arch } = raw + if (platform === undefined && arch === undefined) return null + if (typeof platform !== 'string' || platform.length === 0 || typeof arch !== 'string' || arch.length === 0) { + throw invalid('platform/arch 가 비어 있거나 문자열이 아닙니다') + } + return { platform, arch } +} + /** parseRuntimeIndex 의 결과 — 검증된 구성 요소 항목과 인덱스 버전 */ export interface ResolvedRuntimeEntry { version: string @@ -131,11 +178,15 @@ function parsePart(raw: unknown, index: number): RuntimePart { * - 부품·아카이브 이름은 경로 구분자 없는 단순 파일 이름이어야 한다. * - 인덱스 version 이 이 앱의 최소 버전보다 낮으면 거부한다 — 설치해 봐야 곧바로 * "낡았다" 고 판단돼 매번 다시 받는 루프가 되기 때문이다. + * - `target` 을 주면, 인덱스가 밝힌 platform/arch 가 다를 때 거부한다 — 다른 플랫폼 + * 엔진은 받아 풀어도 검증에서 떨어져 매번 ~160MB 를 다시 받는 루프가 되기 때문이다. + * 두 거부는 RuntimeIndexRejectedError 로 던져 형식 오류와 구분한다. */ export function parseRuntimeIndex( raw: unknown, component: RuntimeComponent, minVersion: string | null = RUNTIME_MIN_VERSION[component], + target?: RuntimeTarget, ): ResolvedRuntimeEntry { if (!isRecord(raw)) throw invalid('최상위 값이 객체가 아닙니다') @@ -144,6 +195,19 @@ export function parseRuntimeIndex( throw invalid('version 이 semver 문자열이 아닙니다') } + const indexTarget = parseIndexTarget(raw) + if ( + target && + indexTarget && + (indexTarget.platform !== target.platform || indexTarget.arch !== target.arch) + ) { + throw new RuntimeIndexRejectedError( + 'platform-mismatch', + `이 플랫폼(${target.platform}-${target.arch})용 런타임이 feed에 없습니다 ` + + `(feed=${indexTarget.platform}-${indexTarget.arch}, version=${version})`, + ) + } + const components = raw.components if (!isRecord(components)) throw invalid('components 가 없습니다') @@ -169,8 +233,8 @@ export function parseRuntimeIndex( } if (!isRuntimeVersionSatisfied(version, minVersion)) { - throw new D3ROError( - ErrorCode.ConfigReadFailed, + throw new RuntimeIndexRejectedError( + 'feed-outdated', `런타임 feed의 ${component} 버전(${version})이 이 앱의 최소 요구 버전(${String(minVersion)})보다 낮습니다`, ) } diff --git a/apps/desktop/src/main/services/stt/audio-utils.ts b/apps/desktop/src/main/services/stt/audio-utils.ts index 7d36747..e252133 100644 --- a/apps/desktop/src/main/services/stt/audio-utils.ts +++ b/apps/desktop/src/main/services/stt/audio-utils.ts @@ -86,3 +86,20 @@ export function createProbeWav(durationMs: number = 500, sampleRate: number = 16 export function bufferToBody(data: Buffer): BodyInit { return data as unknown as BodyInit } + +/** 16kHz 16-bit mono PCM 의 초당 바이트 수 */ +export const PCM16_MONO_16K_BYTES_PER_SECOND = 16000 * 2 + +/** + * 최종 전사 요청의 응답 기한 — 오디오 길이에 비례한다. + * + * 고정 120초는 CUDA 없는 PC(CPU int8)에서 몇 분짜리 핸즈프리 받아쓰기를 항상 끊어, 녹음 전체가 + * 버려졌다. 오디오 1초당 `perAudioSecondMs` 를 주고, 짧은 녹음은 `floorMs` 를 보장한다. + */ +export function transcriptionTimeoutMs( + pcmBytes: number, + options: { floorMs: number; perAudioSecondMs: number } +): number { + const seconds = Math.max(0, pcmBytes) / PCM16_MONO_16K_BYTES_PER_SECOND + return Math.max(options.floorMs, Math.ceil(seconds * options.perAudioSecondMs)) +} diff --git a/apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts b/apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts index 310e68f..29a52a2 100644 --- a/apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts +++ b/apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts @@ -1,18 +1,14 @@ import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { STTProviderConfig } from '@d3ro/core/types' import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService' -import { getCloudSyncService } from '../../CloudSyncService' +import { createCloudSttGateway, type CloudSttGateway } from '../../cloud/cloud-credentials' import { getLogger } from '../../LoggerService' import { pcmToWav } from '../audio-utils' import type { ISTTDriver } from '../types' const logger = getLogger('D3ROCloudDriver') -export interface CloudSttGateway { - getAccessToken(): Promise - getSupabaseUrl(): string | null - getAnonKey(): string | null -} +export type { CloudSttGateway } interface CloudSttPayload { transcript?: unknown @@ -79,7 +75,7 @@ export class D3ROCloudDriver implements ISTTDriver { readonly id = 'd3ro-cloud' as const readonly name = 'D3RO Cloud STT (Managed)' - constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {} + constructor(private readonly cloud: CloudSttGateway = createCloudSttGateway()) {} async transcribe( audioBuffer: Buffer, diff --git a/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts b/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts index a5c55a3..66d6000 100644 --- a/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts +++ b/apps/desktop/src/main/services/suggestion/ModelWarmTracker.ts @@ -8,6 +8,12 @@ // // 워밍업 실패도 여기서 기록한다 — 실패한 워밍업을 매 멈춤마다 다시 쏘지 않도록 // 잠시 물러선다(backoff). +// +// 제안 요청만 보면 안 된다: 같은 모델을 쓰는 받아쓰기(기본 5m)·음성 대화(2m)가 keep_alive 를 +// 짧게 덮어쓰면 Ollama 가 먼저 내린다. 주입된 상주 포트(로컬 LLM 의 모든 요청 기록)가 더 이른 +// 만료를 알면 그것을 따른다. + +import type { ModelResidencyPort } from '../llm/model-residency' export class ModelWarmTracker { private _warmModel: string | null = null @@ -15,6 +21,8 @@ export class ModelWarmTracker { private _failedModel: string | null = null private _failedAt = 0 + constructor(private readonly _residency: ModelResidencyPort | null = null) {} + /** 이 모델이 `until` 까지 메모리에 남아 있다고 본다. 같은 모델의 실패 기록은 지운다. */ noteWarm(model: string, until: number): void { this._warmModel = model @@ -26,7 +34,16 @@ export class ModelWarmTracker { } isWarm(model: string | null, now: number): boolean { - return model !== null && model === this._warmModel && now < this._warmUntil + if (model === null || model !== this._warmModel || now >= this._warmUntil) return false + const residentUntil = this._residency?.residentUntil(model) ?? null + return residentUntil === null || now < residentUntil + } + + /** 이 모델이 warm 이라 믿고 요청했는데 시간 초과했다 — 이미 내려간 것이다. 다음 멈춤은 워밍업부터. */ + noteCold(model: string): void { + if (this._warmModel !== model) return + this._warmModel = null + this._warmUntil = 0 } /** 워밍업이 실패했다 (모델 미설치 · 서버 오류 등). */ diff --git a/apps/desktop/src/main/services/sync/SyncEngine.ts b/apps/desktop/src/main/services/sync/SyncEngine.ts index ae5528e..7fb3fa6 100644 --- a/apps/desktop/src/main/services/sync/SyncEngine.ts +++ b/apps/desktop/src/main/services/sync/SyncEngine.ts @@ -22,7 +22,12 @@ import { } from './sync-adapters' import { fetchRemoteMemoTagKeys, listLocalMemoTagKeys, reconcileMemoTags } from './memo-tag-sync' import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners } from './audio-sync' -import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings } from './settings-sync' +import { + SETTINGS_ROW_ID, + applyRemoteSettings, + fetchRemoteSettings, + settingsNeedResyncAfterInstructionPush, +} from './settings-sync' import { applyRemoteBuiltins, editedBuiltinsDifferingFrom, fetchRemoteBuiltins } from './builtin-instruction-sync' import { deleteOrder, upsertOrder } from './sync-registry' import { @@ -50,6 +55,7 @@ import { type SyncRunResult, } from './sync-types' import { earliestDeletedAt, isRestoredAfter, tombstoneWindowExpired, type TombstoneRef } from './tombstone-policy' +import { pushableEntries } from './push-gate-policy' const logger = getLogger('SyncEngine') @@ -209,9 +215,10 @@ export class SyncEngine extends EventEmitter { runFullSync(): Promise { return this.serialize(async () => { const result = emptyRunResult() - await this.pullTombstonesBeforePush(result, true) - await this.backfillIfNeeded() - await this.flushInner(result, true) + const tombstonesSynced = await this.pullTombstonesBeforePush(result, true) + // 최초 대조는 원격 삭제가 반영된 뒤에만 한다(지운 행을 다시 올리지 않게). + if (tombstonesSynced) await this.backfillIfNeeded() + await this.flushInner(result, tombstonesSynced) await this.pullInner(result) return result }) @@ -222,9 +229,12 @@ export class SyncEngine extends EventEmitter { if (options.releaseParked) releaseParkedEntries() const result = emptyRunResult() const backfillPending = getSyncState(BACKFILL_FLAG) !== 'done' - if (backfillPending) await this.pullTombstonesBeforePush(result, true) - await this.backfillIfNeeded() - await this.flushInner(result, backfillPending) + let tombstonesSynced: boolean | null = null + if (backfillPending) { + tombstonesSynced = await this.pullTombstonesBeforePush(result, true) + if (tombstonesSynced) await this.backfillIfNeeded() + } + await this.flushInner(result, tombstonesSynced) return result }) } @@ -347,24 +357,29 @@ export class SyncEngine extends EventEmitter { /** * push 전에 원격 삭제를 반영한다: 폰에서 지운 행의 로컬 대기 upsert가 서버에 행을 되살리지 않게 * outbox 항목을 버리고 로컬 행을 지운다. always=false 면 대기 upsert가 있을 때만 가져온다. + * 끝까지 반영했거나 읽을 필요가 없었으면 true, 읽기에 실패했으면 false(이번 flush는 upsert를 보내지 않는다). */ - private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise { - if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return + private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise { + if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return true const changed = new Set(result.changed) + let synced = true try { result.deleted += await this.pullTombstones(changed) } catch (err) { if (err instanceof SyncAbortedError) throw err + synced = false const message = errorMessage(err) result.errors.push(`tombstones: ${message}`) - logger.warn(`Pull tombstones before push failed: ${message}`) + logger.warn(`Pull tombstones before push failed; holding upserts until the next flush: ${message}`) } result.changed = [...changed] + return synced } - private async flushInner(result: SyncRunResult, tombstonesPulled: boolean): Promise { - if (!tombstonesPulled) await this.pullTombstonesBeforePush(result, false) - const due = listDueEntries(this.now()) + /** @param tombstonesSynced 호출자가 이미 push 전 tombstone을 읽었으면 그 결과, 아니면 null(여기서 읽는다) */ + private async flushInner(result: SyncRunResult, tombstonesSynced: boolean | null): Promise { + const synced = tombstonesSynced ?? (await this.pullTombstonesBeforePush(result, false)) + const due = pushableEntries(listDueEntries(this.now()), { tombstonesSynced: synced }) if (due.length === 0) return const ctx = this.context() const byEntity = new Map() @@ -383,6 +398,7 @@ export class SyncEngine extends EventEmitter { const outcomes = await handler.push(ctx, bucket.upserts.map((e) => e.rowId)) this.checkpoint() networkDown = this.settle(bucket.upserts, outcomes, result, handler.entity) + if (handler.entity === 'custom_instructions') this.resyncSettingsIfDeferred(outcomes, byEntity) } // 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다) for (const handler of deleteOrder()) { @@ -394,6 +410,21 @@ export class SyncEngine extends EventEmitter { } } + /** + * 설정 push가 서버에 없던 활성 명령 때문에 활성 명령 RPC를 미뤘고, 그 명령이 방금 올라갔으면 + * 설정을 다시 대기열에 넣는다. 이번 flush에 설정 항목이 이미 있으면 그 push가 맞추므로 넣지 않는다. + */ + private resyncSettingsIfDeferred( + outcomes: PushOutcome[], + byEntity: Map + ): void { + if ((byEntity.get('user_settings')?.upserts.length ?? 0) > 0) return + const pushed = outcomes.filter((o) => o.error === null).map((o) => o.id) + if (pushed.length === 0 || !settingsNeedResyncAfterInstructionPush(pushed)) return + enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', this.now()) + logger.info('Deferred active instruction reached the server; settings re-queued') + } + /** outbox를 결과대로 정리. 네트워크 계열 실패가 있었으면 true(이번 flush 중단). */ private settle(entries: OutboxEntry[], outcomes: PushOutcome[], result: SyncRunResult, entity: SyncEntity): boolean { const byId = new Map(outcomes.map((o) => [o.id, o.error])) diff --git a/apps/desktop/src/main/services/sync/active-instruction-push-policy.ts b/apps/desktop/src/main/services/sync/active-instruction-push-policy.ts new file mode 100644 index 0000000..6497245 --- /dev/null +++ b/apps/desktop/src/main/services/sync/active-instruction-push-policy.ts @@ -0,0 +1,52 @@ +// src/main/services/sync/active-instruction-push-policy.ts +// 설정 push의 "활성 명령" 하위 단계 규칙(순수 함수). IO는 settings-sync.ts / SyncEngine.ts가 맡는다. +// +// 활성 명령 RPC(set_active_custom_instruction)는 서버에 그 사용자 명령 행이 있어야 성공한다(없으면 P0002). +// 아직 올라가지 않은(대기·보관 중인) 명령을 가리키면 RPC를 보내지 않고 미룬다 — 그렇지 않으면 +// user_settings 항목이 P0002로 끝없이 재시도되고, 그동안 다른 기기의 설정 pull이 막힌다. +// 미룬 명령이 나중에 서버에 올라가면 설정을 다시 push해 활성 명령을 맞춘다. + +import type { ActiveInstructionPush } from './settings-sync-policy' +import type { SyncRemoteError } from './sync-types' + +export type ActiveInstructionStep = + /** 서버에 올릴 것이 없다 */ + | { kind: 'none' } + /** 서버에 아직 없는 명령 — RPC를 보내지 않고, 명령이 올라간 뒤 다시 맞춘다 */ + | { kind: 'defer'; instructionId: string } + /** RPC로 서버 활성 명령을 바꾼다(null = 해제) */ + | { kind: 'rpc'; instructionId: string | null } + +/** + * 활성 명령 하위 단계를 정한다. + * @param unsyncedInstructionIds 로컬 outbox에 upsert가 남아 있는(= 서버에 없을 수 있는) 사용자 명령 id + */ +export function planActiveInstructionStep( + push: ActiveInstructionPush | null, + unsyncedInstructionIds: ReadonlySet +): ActiveInstructionStep { + if (!push) return { kind: 'none' } + if (push.instructionId !== null && unsyncedInstructionIds.has(push.instructionId)) { + return { kind: 'defer', instructionId: push.instructionId } + } + return { kind: 'rpc', instructionId: push.instructionId } +} + +/** + * 활성 명령 RPC가 "서버에 그 명령이 없다"(P0002)로 실패했는지. + * 이 경우 이미 저장된 설정 필드는 완료로 두고 활성 명령만 건너뛴다 — 다시 보내도 같은 결과다. + */ +export function isActiveInstructionMissing(error: SyncRemoteError): boolean { + return error.code === 'P0002' +} + +/** + * 방금 서버에 올라간 명령 중 미뤄 둔 활성 명령이 있으면 설정을 다시 push해야 한다. + * @param deferredInstructionId 설정 push가 미뤄 둔 활성 명령 id('' 또는 null = 없음) + */ +export function shouldResyncSettingsAfterInstructionPush( + deferredInstructionId: string | null, + pushedInstructionIds: readonly string[] +): boolean { + return !!deferredInstructionId && pushedInstructionIds.includes(deferredInstructionId) +} diff --git a/apps/desktop/src/main/services/sync/audio-sync.ts b/apps/desktop/src/main/services/sync/audio-sync.ts index 4214276..8499ba9 100644 --- a/apps/desktop/src/main/services/sync/audio-sync.ts +++ b/apps/desktop/src/main/services/sync/audio-sync.ts @@ -56,16 +56,26 @@ function meetingAudioPath(id: string): string { return path.join(app.getPath('userData'), 'meeting-audio', `${id}.wav`) } -/** 로컬 녹음 파일 경로. 파일이 없으면 null */ +/** + * 파일 전사 기록의 audioLocalPath 는 사용자가 고른 원본 미디어(동영상 포함)다 — 앱이 녹음한 파일이 + * 아니므로 '녹음 동기화'로 올리지 않는다(설정 문구도 녹음만 올린다고 안내한다). 로컬 재생용으로만 쓴다. + */ +const NON_RECORDING_HISTORY_MODES: ReadonlySet = new Set(['file-transcription']) + +function isSyncableHistoryRecording(row: { mode: string | null; audioLocalPath: string | null }): boolean { + return row.audioLocalPath !== null && !NON_RECORDING_HISTORY_MODES.has(row.mode ?? '') +} + +/** 로컬 녹음 파일 경로. 파일이 없거나 앱 녹음이 아니면(파일 전사 원본) null */ export function localAudioPath(owner: AudioOwner, id: string): string | null { let candidate: string | null if (owner === 'history') { const row = getDatabase() - .select({ audioLocalPath: history.audioLocalPath }) + .select({ audioLocalPath: history.audioLocalPath, mode: history.mode }) .from(history) .where(eq(history.id, id)) .get() - candidate = row?.audioLocalPath ?? null + candidate = row && isSyncableHistoryRecording(row) ? row.audioLocalPath : null } else { candidate = meetingAudioPath(id) } @@ -76,11 +86,11 @@ export function localAudioPath(owner: AudioOwner, id: string): string | null { export function listLocalAudioOwners(owner: AudioOwner): string[] { if (owner === 'history') { return getDatabase() - .select({ id: history.id, audioLocalPath: history.audioLocalPath }) + .select({ id: history.id, audioLocalPath: history.audioLocalPath, mode: history.mode }) .from(history) .where(isNotNull(history.audioLocalPath)) .all() - .filter((r) => isUuid(r.id) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath)) + .filter((r) => isUuid(r.id) && isSyncableHistoryRecording(r) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath)) .map((r) => r.id) } return getDatabase() diff --git a/apps/desktop/src/main/services/sync/push-gate-policy.ts b/apps/desktop/src/main/services/sync/push-gate-policy.ts new file mode 100644 index 0000000..bca7791 --- /dev/null +++ b/apps/desktop/src/main/services/sync/push-gate-policy.ts @@ -0,0 +1,18 @@ +// src/main/services/sync/push-gate-policy.ts +// flush가 이번에 무엇을 push해도 되는지 정하는 규칙(순수 함수). +// +// push 전 tombstone 읽기에 실패하면 upsert를 보내지 않는다(fail closed). 다른 기기에서 지운 행의 +// 로컬 대기 upsert가 서버에 행을 다시 만들고, 서버가 updated_at을 새로 찍으면 다음 pull이 그 행을 +// "보관본 복원"으로 오인해 삭제를 모든 기기에서 영구히 되돌리기 때문이다. 삭제는 되살릴 위험이 +// 없으므로 그대로 보낸다. 건너뛴 upsert는 outbox에 남아 다음 flush에서 다시 시도된다. + +import type { SyncOp } from './sync-types' + +export interface PushGate { + /** push 전 원격 삭제(tombstone)를 끝까지 반영했는지(읽을 필요가 없었으면 true) */ + tombstonesSynced: boolean +} + +export function pushableEntries(due: readonly T[], gate: PushGate): T[] { + return gate.tombstonesSynced ? [...due] : due.filter((entry) => entry.op !== 'upsert') +} diff --git a/apps/desktop/src/main/services/sync/settings-sync.ts b/apps/desktop/src/main/services/sync/settings-sync.ts index 8d89c20..206dea2 100644 --- a/apps/desktop/src/main/services/sync/settings-sync.ts +++ b/apps/desktop/src/main/services/sync/settings-sync.ts @@ -5,7 +5,15 @@ import type { LLMActionSelection, ThemeMode } from '@d3ro/core/types' import { configGet, configSet } from '../ConfigService' import { getCustomInstructionService } from '../CustomInstructionService' +import { getLogger } from '../LoggerService' +import { + isActiveInstructionMissing, + planActiveInstructionStep, + shouldResyncSettingsAfterInstructionPush, + type ActiveInstructionStep, +} from './active-instruction-push-policy' import { isUuid, type PushContext } from './sync-adapters' +import { getSyncState, pendingOps, setSyncState } from './sync-outbox' import { remoteToLocalPatch, resolveActiveInstructionPush, @@ -23,6 +31,10 @@ export const SETTINGS_ROW_ID = 'self' export const SYNCED_CONFIG_KEYS = ['language', 'theme', 'defaultLLMAction', 'activeInstructionId'] as const const SETTINGS_COLUMNS = 'user_id,locale,theme_mode,auto_polish_enabled,active_instruction_id,revision' +/** 서버에 아직 없어 RPC를 미룬 활성 명령 id(sync_state, 사용자 DB별). '' = 없음 */ +const DEFERRED_ACTIVE_INSTRUCTION_KEY = 'settings:deferredActiveInstruction' + +const logger = getLogger('SettingsSync') let applyingRemote = false @@ -49,6 +61,25 @@ function userInstructionIds(): Set { ) } +/** outbox에 upsert가 남아 있어 서버에 없을 수 있는 사용자 명령 id */ +function unsyncedInstructionIds(): Set { + const ids = new Set() + for (const [id, op] of pendingOps('custom_instructions')) if (op === 'upsert') ids.add(id) + return ids +} + +function rememberDeferredActiveInstruction(ctx: PushContext, instructionId: string | null): void { + // 해제됐거나 사용자 DB가 바뀌었으면 쓰지 않는다(엔진이 곧 중단한다). + if (ctx.isCurrent?.() === false) return + const next = instructionId ?? '' + if ((getSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY) ?? '') !== next) setSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY, next) +} + +/** 방금 서버에 올라간 명령 중 설정 push가 미뤄 둔 활성 명령이 있으면 true(설정을 다시 올려야 한다). */ +export function settingsNeedResyncAfterInstructionPush(pushedInstructionIds: readonly string[]): boolean { + return shouldResyncSettingsAfterInstructionPush(getSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY), pushedInstructionIds) +} + export async function fetchRemoteSettings(ctx: PushContext): Promise { const rows = await ctx.remote.selectWhere('user_settings', ctx.userId, [], SETTINGS_COLUMNS) return rows[0] ?? null @@ -97,15 +128,35 @@ export async function pushSettings(ctx: PushContext): Promise { + if (step.kind === 'defer') { + rememberDeferredActiveInstruction(ctx, step.instructionId) + logger.info('Active instruction push deferred: instruction is not on the server yet') + return + } + if (step.kind === 'rpc') { + try { + await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: step.instructionId }) + } catch (err) { + const error = toSyncRemoteError(err) + if (!isActiveInstructionMissing(error)) throw error + logger.warn(`Active instruction push skipped: ${error.message}`) + } + } + rememberDeferredActiveInstruction(ctx, null) +} + /** 원격 설정을 로컬에 반영한다. 바꾼 것이 있으면 true */ export function applyRemoteSettings(row: RemoteRow): boolean { const patch = remoteToLocalPatch(row, readLocalSettings(), userInstructionIds()) diff --git a/apps/desktop/src/main/services/sync/sync-adapters.ts b/apps/desktop/src/main/services/sync/sync-adapters.ts index 7026a8c..c3e5511 100644 --- a/apps/desktop/src/main/services/sync/sync-adapters.ts +++ b/apps/desktop/src/main/services/sync/sync-adapters.ts @@ -1001,13 +1001,14 @@ const knowledgeAdapter: SyncAdapter = { async applyRemote(row, ctx) { const id = row.id if (!isUuid(id)) return false - const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get() - if (exists) return false + // 이미 있는 문서도 청크를 받아 비교한다 — 다른 기기에서 재색인하면 행의 updated_at만 움직이고 + // 원문이 바뀐다. 같으면 RAGService가 무시하고, 다르면 청크를 바꿔 다시 임베딩한다. const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index') // 네트워크를 기다리는 사이 로그아웃·계정 전환이 있었으면 다른 사용자 DB에 쓰지 않는다. assertCurrent(ctx) // 다른 기기가 문서 행을 올리고 청크를 아직(또는 일부만) 올렸을 수 있다 — 다음 pull에서 다시 본다. - // 일부만 받아 저장하면 이후 pull은 "이미 있음"으로 건너뛰어 잘린 문서가 영구히 남는다. + // 청크 업로드는 행 upsert 뒤에 일어나 행의 updated_at을 다시 움직이지 않으므로, 여기서 건너뛰면 + // (새 문서든 재색인된 문서든) 완성본을 영영 놓친다. 잘린 청크로 로컬을 덮지도 않는다. const chunks = completeKnowledgeChunks(chunkRows, num(row.chunk_count)) if (chunks === null) return 'deferred' return getRAGService().applyRemoteDocument({ diff --git a/apps/desktop/src/main/services/sync/sync-outbox.ts b/apps/desktop/src/main/services/sync/sync-outbox.ts index 69b6e96..8290a4d 100644 --- a/apps/desktop/src/main/services/sync/sync-outbox.ts +++ b/apps/desktop/src/main/services/sync/sync-outbox.ts @@ -26,6 +26,10 @@ export interface OutboxEntry { * 로컬 변경을 기록한다. 같은 행의 이전 대기 연산은 최신 연산으로 덮는다 * (upsert 뒤 delete면 delete만 남는다). version을 올려 진행 중인 push가 * 새 변경을 완료 처리하지 못하게 한다. + * + * 단, 대기 중인 delete 는 뒤늦은 upsert 로 되돌리지 않는다 — 행 id 는 UUID 라 삭제된 행이 같은 id 로 + * 되살아나지 않는다. 예전엔 삭제 뒤 끝난 자동 제목 생성의 upsert 가 delete 를 덮어, flush 가 "로컬에 없는 + * upsert" 를 완료로 처리하며 원격 삭제가 영영 나가지 않았다. */ export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now = Date.now()): void { getDatabase() @@ -34,7 +38,7 @@ export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now .onConflictDoUpdate({ target: [syncOutbox.entity, syncOutbox.rowId], set: { - op, + op: op === 'upsert' ? sql`CASE WHEN ${syncOutbox.op} = 'delete' THEN 'delete' ELSE 'upsert' END` : op, version: sql`${syncOutbox.version} + 1`, queuedAt: now, attempts: 0, @@ -149,6 +153,10 @@ export function setSyncState(key: string, value: string, now = Date.now()): void .run() } +export function deleteSyncState(key: string): void { + getDatabase().delete(syncState).where(eq(syncState.key, key)).run() +} + function toEntry(row: SyncOutboxRow): OutboxEntry { return { entity: row.entity as SyncEntity, diff --git a/apps/desktop/src/main/services/voice-command-policy.ts b/apps/desktop/src/main/services/voice-command-policy.ts new file mode 100644 index 0000000..9cef383 --- /dev/null +++ b/apps/desktop/src/main/services/voice-command-policy.ts @@ -0,0 +1,245 @@ +// src/main/services/voice-command-policy.ts +// 음성 단축키의 순수 정책: 기본 키워드 테이블, 키워드 매칭, 저장된 레거시 기본값 이관. +// IO(electron-store, logger)에 의존하지 않으므로 표 형태로 단위 테스트한다. + +import type { + VoiceCommandRule, + VoiceCommandKeyword, + KeywordMatchMode +} from '@d3ro/core/types' + +// ============================================================ +// 기본 키워드 (프리셋 명령어용) +// ============================================================ + +export interface DefaultKeywordEntry { + instructionId: string + keywords: ReadonlyArray + priority: number +} + +function prefixKeywords(words: ReadonlyArray): VoiceCommandKeyword[] { + return words.map((keyword) => ({ keyword, matchMode: 'prefix' as const })) +} + +/** + * 기본 키워드는 명령형(동사형)만 쓴다. '요약', '번역', '설명', 'explain' 같은 + * 맨 명사는 "요약 보고서 첨부해서 보내드립니다." 같은 일반 받아쓰기 문장의 첫 단어로 + * 흔히 나오므로, prefix로 매칭하면 받아쓴 문장이 LLM 변환 결과로 바뀌고 첫 단어가 사라진다. + * 긴 키워드를 먼저 둔다 (같은 규칙 안에서 앞의 키워드부터 검사한다). + */ +export const DEFAULT_VOICE_COMMAND_KEYWORDS: ReadonlyArray = [ + { + instructionId: 'builtin-translate', + keywords: prefixKeywords([ + '영어로 번역해 주세요', + '영어로 번역해주세요', + '영어로 번역해 줘', + '영어로 번역해줘', + '번역해 주세요', + '번역해주세요', + '번역해 줘', + '번역해줘', + 'translate this' + ]), + priority: 0 + }, + { + instructionId: 'builtin-summarize', + keywords: prefixKeywords([ + '요약해 주세요', + '요약해주세요', + '요약해 줘', + '요약해줘', + 'summarize this' + ]), + priority: 1 + }, + { + instructionId: 'builtin-formal', + keywords: prefixKeywords([ + '다듬어 주세요', + '다듬어주세요', + '다듬어 줘', + '다듬어줘', + 'polish this' + ]), + priority: 2 + }, + { + instructionId: 'builtin-explain-code', + keywords: prefixKeywords([ + '설명해 주세요', + '설명해주세요', + '설명해 줘', + '설명해줘', + 'explain this' + ]), + priority: 3 + } +] + +/** + * 이전 버전이 첫 실행 때 저장한 기본 키워드. 저장소에 이 목록이 그대로 남아 있으면 + * (사용자가 손대지 않은 기본값) 새 기본값으로 교체한다. + */ +const LEGACY_DEFAULT_KEYWORDS: Readonly>> = { + 'builtin-translate': ['번역해줘', '번역', '영어로', 'translate'], + 'builtin-summarize': ['요약해줘', '요약', 'summarize'], + 'builtin-formal': ['다듬어줘', '다듬기', 'polish'], + 'builtin-explain-code': ['설명해줘', '설명', 'explain'] +} + +// ============================================================ +// 키워드 매칭 +// ============================================================ + +export interface KeywordMatchResult { + matched: boolean + cleanedText: string +} + +// 공백·구두점 (따옴표/괄호 포함). 키워드 경계 판정에 쓴다. +const WORD_BOUNDARY = /[\s,.!?;:'"()[\]{}\-/…~]/ +// 키워드를 잘라낸 자리에 남는 구분 기호. 여는/닫는 따옴표·괄호는 내용의 일부이므로 남긴다. +const LEADING_SEPARATORS = /^[\s,.!?;:\-/…~–—]+/ +const TRAILING_SEPARATORS = /[\s,;:\-/…~–—]+$/ +// 변환할 내용이 실제로 있는지: 글자나 숫자가 하나라도 있어야 한다. +const HAS_CONTENT = /[\p{L}\p{N}]/u + +export function isWordBoundary(char: string): boolean { + return WORD_BOUNDARY.test(char) +} + +function noMatch(trimmed: string): KeywordMatchResult { + return { matched: false, cleanedText: trimmed } +} + +function withContent(cleaned: string, trimmed: string): KeywordMatchResult { + // 명령어만 말하고 대상 내용이 없으면 명령으로 보지 않는다 ('요약해줘.' → 원문 그대로 받아쓰기). + if (!HAS_CONTENT.test(cleaned)) return noMatch(trimmed) + return { matched: true, cleanedText: cleaned } +} + +/** + * 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다. + * 키워드 앞뒤의 공백/구두점 경계를 존중하고, 키워드를 뺀 뒤 변환할 내용이 남아야 매칭으로 본다. + */ +export function matchKeyword( + text: string, + keyword: string, + mode: KeywordMatchMode +): KeywordMatchResult { + const trimmed = text.trim() + const lowerText = trimmed.toLowerCase() + const lowerKeyword = keyword.toLowerCase() + + if (lowerKeyword.length === 0 || lowerKeyword.length !== keyword.length) { + // 빈 키워드, 또는 소문자 변환으로 길이가 바뀌는 키워드는 오프셋을 신뢰할 수 없다 + return noMatch(trimmed) + } + const keywordLength = lowerKeyword.length + + switch (mode) { + case 'prefix': { + if (!lowerText.startsWith(lowerKeyword)) return noMatch(trimmed) + const afterKeyword = trimmed.charAt(keywordLength) + if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) return noMatch(trimmed) + const cleaned = trimmed.slice(keywordLength).replace(LEADING_SEPARATORS, '') + return withContent(cleaned, trimmed) + } + + case 'suffix': { + if (!lowerText.endsWith(lowerKeyword)) return noMatch(trimmed) + const beforeKeyword = trimmed.charAt(trimmed.length - keywordLength - 1) + if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) return noMatch(trimmed) + const cleaned = trimmed + .slice(0, trimmed.length - keywordLength) + .replace(TRAILING_SEPARATORS, '') + return withContent(cleaned, trimmed) + } + + case 'contains': { + const index = lowerText.indexOf(lowerKeyword) + if (index === -1) return noMatch(trimmed) + // contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거 + const before = trimmed.slice(0, index) + const after = trimmed.slice(index + keywordLength) + const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim() + return withContent(cleaned, trimmed) + } + } +} + +export interface RuleMatch { + rule: VoiceCommandRule + keyword: string + cleanedText: string +} + +/** + * 활성 규칙을 priority 오름차순(0이 가장 높음)으로 검사해 첫 매칭을 돌려준다. + */ +export function findRuleMatch( + text: string, + rules: ReadonlyArray +): RuleMatch | null { + const trimmed = text.trim() + if (trimmed.length === 0) return null + + const sortedRules = rules + .filter((r) => r.enabled && r.keywords.length > 0) + .sort((a, b) => a.priority - b.priority) + + for (const rule of sortedRules) { + for (const kw of rule.keywords) { + const result = matchKeyword(trimmed, kw.keyword, kw.matchMode) + if (result.matched) { + return { rule, keyword: kw.keyword, cleanedText: result.cleanedText } + } + } + } + return null +} + +// ============================================================ +// 기본값 생성 / 레거시 이관 +// ============================================================ + +export function buildDefaultRules(createId: () => string): VoiceCommandRule[] { + return DEFAULT_VOICE_COMMAND_KEYWORDS.map((entry) => ({ + id: createId(), + instructionId: entry.instructionId, + keywords: entry.keywords.map((kw) => ({ ...kw })), + enabled: true, + priority: entry.priority + })) +} + +function isUntouchedLegacyDefault(rule: VoiceCommandRule): boolean { + const legacy = LEGACY_DEFAULT_KEYWORDS[rule.instructionId] + if (!legacy || rule.keywords.length !== legacy.length) return false + return rule.keywords.every( + (kw, i) => kw.matchMode === 'prefix' && kw.keyword === legacy[i] + ) +} + +/** + * 이전 버전이 저장한, 사용자가 손대지 않은 맨 명사 기본 키워드를 새 명령형 기본값으로 바꾼다. + * 사용자가 IPC로 바꾼 키워드는 그대로 둔다. migrated는 교체한 규칙 수다. + */ +export function migrateLegacyDefaultRules( + rules: ReadonlyArray +): { rules: VoiceCommandRule[]; migrated: number } { + let migrated = 0 + const next = rules.map((rule) => { + if (!isUntouchedLegacyDefault(rule)) return rule + const entry = DEFAULT_VOICE_COMMAND_KEYWORDS.find( + (e) => e.instructionId === rule.instructionId + ) + if (!entry) return rule + migrated++ + return { ...rule, keywords: entry.keywords.map((kw) => ({ ...kw })) } + }) + return { rules: next, migrated } +} diff --git a/apps/desktop/src/main/update-policy.ts b/apps/desktop/src/main/update-policy.ts index 4bb41bc..ebf3ac2 100644 --- a/apps/desktop/src/main/update-policy.ts +++ b/apps/desktop/src/main/update-policy.ts @@ -217,7 +217,17 @@ export function isWithinRollout( return rolloutBucket(deviceId, version) < percentage } -/** 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. 실패 필드는 기본값 유지. */ +/** + * 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. + * + * 없는 필드와 형식이 틀린 일반 필드는 기본값을 유지한다. 단 안전 필드는 값이 있는데 + * 형식이 틀리면 가장 보수적인 값으로 읽는다(fail-closed) — 예전에는 `"killSwitch": "true"` + * 나 `"stagingPercentage": "5"` 같은 오타가 킬 스위치 꺼짐·100% 배포로 조용히 바뀌어 + * 운영자 의도와 정반대로 동작했다. + * - killSwitch : boolean 이 아니면 true (업데이트 중단) + * - stagingPercentage : 정수가 아니면 0 (아무에게도 배포하지 않음; 필수 업데이트는 무관) + * 게시 전 엄격한 검증은 scripts/ci/lib/update-policy-schema.mjs 가 맡는다. + */ export function parseUpdatePolicy(raw: unknown): UpdatePolicy { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return DEFAULT_UPDATE_POLICY const record = raw as Record @@ -244,8 +254,14 @@ export function parseUpdatePolicy(raw: unknown): UpdatePolicy { } if (Number.isSafeInteger(record.stagingPercentage)) { policy.stagingPercentage = Math.min(100, Math.max(0, record.stagingPercentage as number)) + } else if (record.stagingPercentage !== undefined) { + policy.stagingPercentage = 0 + } + if (typeof record.killSwitch === 'boolean') { + policy.killSwitch = record.killSwitch + } else if (record.killSwitch !== undefined) { + policy.killSwitch = true } - if (typeof record.killSwitch === 'boolean') policy.killSwitch = record.killSwitch if (record.channels && typeof record.channels === 'object' && !Array.isArray(record.channels)) { const channels = record.channels as Record diff --git a/apps/desktop/src/main/voice-session-persist.ts b/apps/desktop/src/main/voice-session-persist.ts index 5da1fe2..5e2698a 100644 --- a/apps/desktop/src/main/voice-session-persist.ts +++ b/apps/desktop/src/main/voice-session-persist.ts @@ -1,7 +1,6 @@ // VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장. // bootstrap과 테스트가 같은 함수를 호출한다. -import { join } from 'path' import { app } from 'electron' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { getHistoryService } from './services/HistoryService' @@ -19,12 +18,16 @@ export interface PersistableVoiceSession { startedAt: number } +/** + * @param audioPath VoiceMode 가 완료 전에 실제로 저장한 녹음 경로(RecordingStore.save 결과). + * 오디오가 없거나 저장에 실패했으면 null — 존재하지 않는 경로를 히스토리에 남기지 않는다. + */ export function persistCompletedVoiceSession( session: PersistableVoiceSession, finalText: string, + audioPath: string | null = null, ): HistoryEntry { const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length - const audioPath = join(app.getPath('userData'), 'recordings', `${session.id}.wav`) const mode = session.mode === 'hands-free' ? 'dictation' : session.mode return getHistoryService().create({ @@ -51,11 +54,12 @@ export function persistCompletedVoiceSessionSafe( emitError: (error: D3ROError, session: PersistableVoiceSession) => void, session: PersistableVoiceSession, finalText: string, + audioPath: string | null = null, ): HistoryEntry | null { // 히스토리 저장이 실패해도 결과는 소비자에게 전달돼야 하므로 먼저 라우팅한다 routeCompletedVoiceSession(session, finalText) try { - return persistCompletedVoiceSession(session, finalText) + return persistCompletedVoiceSession(session, finalText, audioPath) } catch (error) { const d3ro = error instanceof D3ROError diff --git a/apps/desktop/src/main/windows/app-menu.ts b/apps/desktop/src/main/windows/app-menu.ts new file mode 100644 index 0000000..6161e34 --- /dev/null +++ b/apps/desktop/src/main/windows/app-menu.ts @@ -0,0 +1,23 @@ +// src/main/windows/app-menu.ts +// 애플리케이션 메뉴 정책 — 플랫폼별로 어떤 메뉴를 둘지 결정한다. +// +// Win/Linux: 보더리스 창 + 커스텀 TitleBar 라 메뉴가 없어야 한다(Alt 키로 숨은 메뉴가 활성화되지 않게). +// macOS: Cmd+C/V/X/A/Z 와 Cmd+Q 는 애플리케이션 메뉴의 role 항목을 거쳐서만 동작한다. 메뉴를 null 로 +// 지우면 입력란의 복사·붙여넣기·실행 취소와 종료 단축키가 모두 죽으므로 role 기반 최소 메뉴를 둔다. +// role 메뉴의 라벨은 Electron/OS 기본값을 쓴다(앱 코드에 하드코딩 문구 없음). + +import type { Menu, MenuItemConstructorOptions } from 'electron' + +/** 플랫폼에 맞는 애플리케이션 메뉴 템플릿. null 이면 메뉴를 두지 않는다. */ +export function buildApplicationMenuTemplate(platform: NodeJS.Platform): MenuItemConstructorOptions[] | null { + if (platform !== 'darwin') return null + return [{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] +} + +/** Menu 정적 API 중 이 정책이 쓰는 부분 — 테스트에서 가짜로 바꾼다 */ +export type ApplicationMenuApi = Pick + +export function installApplicationMenu(menuApi: ApplicationMenuApi, platform: NodeJS.Platform): void { + const template = buildApplicationMenuTemplate(platform) + menuApi.setApplicationMenu(template ? menuApi.buildFromTemplate(template) : null) +} diff --git a/apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts b/apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts new file mode 100644 index 0000000..e4f2066 --- /dev/null +++ b/apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts @@ -0,0 +1,46 @@ +// redteam r3-4 회귀: macOS Dock 아이콘 클릭('activate')이 트레이로 숨긴 메인 창을 다시 띄운다. +// 예전엔 'activate' 리스너가 없어 closeToTray(기본 true)로 창을 닫은 뒤 Dock 으로 돌아올 수 없었다. + +import { describe, expect, it, vi } from 'vitest' + +type Listener = (...args: unknown[]) => void + +const state = vi.hoisted(() => ({ + appListeners: new Map(), + showMainWindow: vi.fn(() => null), +})) + +vi.mock('electron', () => ({ + app: { + on: vi.fn((event: string, listener: Listener) => { + state.appListeners.set(event, listener) + }), + // 부트스트랩은 이 테스트의 관심사가 아니다 — ready 를 오지 않게 둔다 + whenReady: vi.fn(() => new Promise(() => undefined)), + setAsDefaultProtocolClient: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), + disableHardwareAcceleration: vi.fn(), + commandLine: { appendSwitch: vi.fn() }, + exit: vi.fn(), + getPath: vi.fn(() => '.'), + }, +})) + +vi.mock('../../src/main/app-identity', () => ({ applyAppIdentity: vi.fn() })) +vi.mock('../../src/main/bootstrap', () => ({ bootstrap: vi.fn(async () => undefined) })) +vi.mock('../../src/main/lifecycle', () => ({ setupLifecycle: vi.fn() })) +vi.mock('../../src/main/windows/WindowManager', () => ({ showMainWindow: state.showMainWindow })) +vi.mock('../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +describe("app 'activate' (macOS Dock 클릭)", () => { + it('메인 창을 다시 띄운다', async () => { + await import('../../src/main/index') + + const activate = state.appListeners.get('activate') + expect(activate).toBeTypeOf('function') + activate?.() + expect(state.showMainWindow).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts b/apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts new file mode 100644 index 0000000..68679b5 --- /dev/null +++ b/apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts @@ -0,0 +1,138 @@ +// tests/main/ipc/ipc-redteam-r3-1.test.ts +// IPC 회귀 (r3-1): +// - caption:stop 은 사용자 자막만 멈추고 회의가 소유한 자막은 거부한다 (items 13·41) +// - stt:setModel 은 설치된 모델만 올린다 — 미설치 모델은 설정만 저장 (item 10) +// - 입력 수집 동의·일시정지 변경을 제안 서비스에 알린다 (item 7) +// - realtime-token 은 주입된 자격 증명 포트로 호출한다 (item 2) + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' + +const handlers = vi.hoisted(() => new Map Promise>()) + +const caption = vi.hoisted(() => ({ stop: vi.fn(), start: vi.fn(), getState: vi.fn(() => 'active') })) +const stt = vi.hoisted(() => ({ initialize: vi.fn(async () => undefined), isModelInstalled: vi.fn(() => false), on: vi.fn() })) +const sttManager = vi.hoisted(() => ({ getActiveProvider: vi.fn(() => 'local'), on: vi.fn() })) +const config = vi.hoisted(() => ({ configGet: vi.fn(), configSet: vi.fn() })) +const telemetry = vi.hoisted(() => ({ setEnabled: vi.fn(), setPaused: vi.fn(), getState: vi.fn(() => ({})) })) +const suggestion = vi.hoisted(() => ({ handleInputConsentChanged: vi.fn(), applyConfig: vi.fn() })) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (event: unknown, params?: unknown) => Promise) => { + handlers.set(channel, handler) + }), + on: vi.fn(), + }, + session: { defaultSession: { setDisplayMediaRequestHandler: vi.fn() } }, + desktopCapturer: { getSources: vi.fn(async () => []) }, +})) +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) +vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => caption })) +vi.mock('../../../src/main/windows/WindowManager', () => ({ + endCaptionOverlayDrag: vi.fn(), + resetCaptionOverlayPosition: vi.fn(), + setCaptionOverlayInteractive: vi.fn(), + startCaptionOverlayDrag: vi.fn(), + getMainWindow: vi.fn(() => null), +})) +vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => stt })) +vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ on: vi.fn() }) })) +vi.mock('../../../src/main/services/stt/STTManager', () => ({ getSTTManager: () => sttManager })) +vi.mock('../../../src/main/services/ConfigService', () => config) +vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => telemetry })) +vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: vi.fn() })) +vi.mock('../../../src/main/services/SuggestionService', () => ({ getSuggestionService: () => suggestion })) +vi.mock('../../../src/main/services/VoiceConversationService', () => ({ getVoiceConversationService: vi.fn() })) +vi.mock('../../../src/main/services/CloudSyncService', () => ({ + getCloudSyncService: () => { + throw new Error('handlers must use the injected credentials port') + }, +})) + +async function invoke(channel: string, params?: unknown): Promise<{ success: boolean; error?: { code: number } }> { + const handler = handlers.get(channel) + if (!handler) throw new Error(`handler not registered: ${channel}`) + return (await handler({}, params)) as { success: boolean; error?: { code: number } } +} + +beforeEach(() => { + vi.clearAllMocks() + handlers.clear() +}) + +describe('caption:stop 소유자 확인', () => { + beforeEach(async () => { + const mod = await import('../../../src/main/ipc/caption-handlers') + mod.registerCaptionHandlers() + }) + + it("owner 'user' 로만 멈춘다", async () => { + caption.stop.mockResolvedValue(null) + expect((await invoke(IPC_CHANNELS.CAPTION.STOP)).success).toBe(true) + expect(caption.stop).toHaveBeenCalledWith('user') + }) + + it('회의가 소유한 자막이면 거부한다 (강제 정지하지 않음)', async () => { + caption.stop.mockResolvedValue(false) + const result = await invoke(IPC_CHANNELS.CAPTION.STOP) + expect(result.success).toBe(false) + expect(caption.stop).not.toHaveBeenCalledWith() + }) +}) + +describe('stt:setModel 미설치 모델', () => { + beforeEach(async () => { + const mod = await import('../../../src/main/ipc/stt-handlers') + mod.registerSTTHandlers() + }) + + it('미설치 모델은 설정만 저장하고 사이드카에 올리지 않는다', async () => { + stt.isModelInstalled.mockReturnValue(false) + const result = await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'large-v3' }) + expect(result.success).toBe(true) + expect(config.configSet).toHaveBeenCalledWith('sttModelId', 'large-v3') + expect(stt.initialize).not.toHaveBeenCalled() + }) + + it('설치된 모델은 바로 올린다', async () => { + stt.isModelInstalled.mockReturnValue(true) + await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'small' }) + expect(stt.initialize).toHaveBeenCalledWith('small') + }) +}) + +describe('입력 수집 동의 변경', () => { + beforeEach(async () => { + const mod = await import('../../../src/main/ipc/input-telemetry-handlers') + mod.registerInputTelemetryHandlers() + }) + + it('동의를 끄면 제안 서비스에 알린다', async () => { + await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED, { enabled: false }) + expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1) + }) + + it('일시정지도 제안 서비스에 알린다', async () => { + await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED, { paused: true }) + expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1) + }) +}) + +describe('realtime-token 은 자격 증명 포트를 쓴다', () => { + it('로그아웃 상태의 포트가 error 를 주면 실패로 돌려준다', async () => { + const mod = await import('../../../src/main/ipc/voice-conversation-handlers') + const invokeFn = vi.fn(async () => ({ data: null, error: { message: 'No active session' } })) + mod.registerVoiceConversationHandlers({ + hasCredentials: () => false, + accessToken: async () => null, + invoke: invokeFn, + invokeStream: async () => ({ stream: null, error: { message: 'No active session' } }), + }) + const result = await invoke(IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, {}) + expect(result.success).toBe(false) + expect(invokeFn).toHaveBeenCalledWith('realtime-token', {}) + }) +}) diff --git a/apps/desktop/tests/main/services/dictionary-file-codec.test.ts b/apps/desktop/tests/main/services/dictionary-file-codec.test.ts new file mode 100644 index 0000000..e202bd2 --- /dev/null +++ b/apps/desktop/tests/main/services/dictionary-file-codec.test.ts @@ -0,0 +1,89 @@ +// tests/main/services/dictionary-file-codec.test.ts +// 사전 파일 형식(CSV·JSON) 순수 코덱 — DictionaryService 에서 분리한 로직의 단위 테스트 + +import { describe, it, expect } from 'vitest' +import { + DICTIONARY_CSV_HEADER, + parseDictionaryFile, + serializeDictionary, + toDictionaryImportRow +} from '../../../src/main/services/dictionary/dictionary-file-codec' +import type { DictionaryEntry } from '@d3ro/core/types' + +const entry: DictionaryEntry = { + id: 'e1', + word: '=SUM, "quoted"', + pronunciation: null, + category: 'technical', + usageCount: 3, + lastUsedAt: null, + createdAt: 100, + updatedAt: 200 +} + +describe('serializeDictionary', () => { + it('CSV 는 BOM·헤더·CRLF 를 쓰고 수식·따옴표를 이스케이프한다', () => { + const csv = serializeDictionary([entry], 'csv') + expect(csv.startsWith(`\uFEFF${DICTIONARY_CSV_HEADER.join(',')}\r\n`)).toBe(true) + expect(csv).toContain('"=SUM, ""quoted""",,technical,3,100,200\r\n') + }) + + it('JSON 은 entries 객체로 쓴다', () => { + expect(JSON.parse(serializeDictionary([entry], 'json'))).toEqual({ entries: [entry] }) + }) + + it('CSV 로 쓴 내용을 다시 읽으면 같은 값이 나온다', () => { + const [record] = parseDictionaryFile(serializeDictionary([entry], 'csv'), 'csv') + expect(record).toMatchObject({ word: entry.word, category: 'technical', usageCount: '3' }) + }) +}) + +describe('parseDictionaryFile', () => { + it('JSON 배열과 entries 객체를 모두 받는다', () => { + expect(parseDictionaryFile('[{"word":"a"}, 1, null]', 'json')).toEqual([{ word: 'a' }]) + expect(parseDictionaryFile('{"entries":[{"word":"b"}]}', 'json')).toEqual([{ word: 'b' }]) + }) + + it('형식이 맞지 않으면 Error 를 던진다', () => { + expect(() => parseDictionaryFile('{"x":1}', 'json')).toThrow(/entries/) + expect(() => parseDictionaryFile('', 'csv')).toThrow(/empty CSV/) + expect(() => parseDictionaryFile('term\r\nx\r\n', 'csv')).toThrow(/word/) + }) +}) + +describe('toDictionaryImportRow', () => { + it('필드를 정규화하고 기본값을 채운다', () => { + expect( + toDictionaryImportRow( + { word: ' AI ', category: 'bogus', usage_count: '2.7', created_at: 50 }, + 999 + ) + ).toEqual({ + ok: true, + row: { + word: 'AI', + pronunciation: null, + category: 'user', + usageCount: 2, + lastUsedAt: null, + createdAt: 50, + updatedAt: 50 + } + }) + }) + + it('빈 단어·너무 긴 값을 문제로 돌려준다', () => { + expect(toDictionaryImportRow({ pronunciation: 'x' }, 1)).toMatchObject({ + ok: false, + problem: { reason: 'empty-word' } + }) + expect(toDictionaryImportRow({ word: 'w'.repeat(121) }, 1)).toMatchObject({ + ok: false, + problem: { reason: 'too-long', field: 'word', max: 120 } + }) + expect(toDictionaryImportRow({ word: 'ok', pronunciation: 'p'.repeat(201) }, 1)).toMatchObject({ + ok: false, + problem: { reason: 'too-long', field: 'pronunciation', max: 200 } + }) + }) +}) diff --git a/apps/desktop/tests/main/services/dictionary-redteam-r3-26.test.ts b/apps/desktop/tests/main/services/dictionary-redteam-r3-26.test.ts new file mode 100644 index 0000000..acf5b85 --- /dev/null +++ b/apps/desktop/tests/main/services/dictionary-redteam-r3-26.test.ts @@ -0,0 +1,180 @@ +// tests/main/services/dictionary-redteam-r3-26.test.ts +// 서버 길이 제한(단어 120·발음 200)을 넘는 사전 항목은 로컬 저장·동기화 push 전에 거부돼야 한다. +// 예전엔 로컬에 저장한 뒤 pushOne 했고, 서버가 22023 으로 거부해 아웃박스에 영구 보류됐다. + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { ErrorCode } from '@d3ro/core/errors' + +const readFileSync = vi.fn() +const pushOne = vi.fn(async () => undefined) + +vi.mock('fs', () => ({ + default: { writeFileSync: vi.fn(), readFileSync }, + writeFileSync: vi.fn(), + readFileSync +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/d3ro' }, + dialog: { showSaveDialog: vi.fn() } +})) + +vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null })) + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +vi.mock('../../../src/main/services/CloudSyncService', () => ({ + getCloudSyncService: () => ({ pushOne, pushDelete: vi.fn() }) +})) + +function chain(result: { all?: unknown[]; get?: unknown }): Record { + const builder: Record = {} + builder.from = vi.fn(() => builder) + builder.where = vi.fn(() => builder) + builder.all = vi.fn(() => result.all ?? []) + builder.get = vi.fn(() => result.get) + return builder +} + +const existingRow = { + id: 'row-1', + word: 'AI', + pronunciation: null, + category: 'user', + usageCount: 0, + lastUsedAt: null, + createdAt: 1, + updatedAt: 1 +} + +const insertValues = vi.fn((_values: Record) => ({ + run: vi.fn(() => ({ changes: 1 })) +})) +const updateSet = vi.fn(() => ({ where: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) })) + +const txStub = { + select: vi.fn(() => chain({ get: undefined })), + insert: vi.fn(() => ({ values: insertValues })) +} + +const mockDb = { + select: vi.fn(() => chain({ all: [], get: existingRow })), + insert: vi.fn(() => ({ values: insertValues })), + update: vi.fn(() => ({ set: updateSet })), + transaction: vi.fn((fn: (tx: typeof txStub) => void) => fn(txStub)) +} + +vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb })) + +type ServiceModule = typeof import('../../../src/main/services/DictionaryService') +let mod: ServiceModule + +beforeEach(async () => { + vi.clearAllMocks() + vi.resetModules() + mod = await import('../../../src/main/services/DictionaryService') +}) + +interface CapturedError { + name: string + message: string + code: unknown + details: unknown +} + +// vi.resetModules() 로 서비스가 다른 D3ROError 클래스 인스턴스를 쓰므로 instanceof 대신 모양으로 확인한다 +function captureError(fn: () => unknown): CapturedError { + try { + fn() + } catch (err) { + const captured = err as CapturedError + expect(captured.name).toBe('D3ROError') + return captured + } + throw new Error('expected a D3ROError') +} + +describe('DictionaryService.add — 서버 길이 제한', () => { + it('121자 단어는 저장·push 없이 field/max 를 담아 거부한다', () => { + const err = captureError(() => mod.getDictionaryService().add({ word: 'w'.repeat(121) })) + expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) + expect(err.details).toMatchObject({ field: 'word', max: 120 }) + expect(mockDb.insert).not.toHaveBeenCalled() + expect(pushOne).not.toHaveBeenCalled() + }) + + it('201자 발음은 저장·push 없이 거부한다', () => { + const err = captureError(() => + mod.getDictionaryService().add({ word: 'ok', pronunciation: 'p'.repeat(201) }) + ) + expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 }) + expect(mockDb.insert).not.toHaveBeenCalled() + expect(pushOne).not.toHaveBeenCalled() + }) + + it('제한 이내 항목은 그대로 저장하고 push 한다', () => { + const entry = mod.getDictionaryService().add({ word: 'w'.repeat(120), pronunciation: ' p ' }) + expect(entry.word).toBe('w'.repeat(120)) + expect(entry.pronunciation).toBe('p') + expect(mockDb.insert).toHaveBeenCalledTimes(1) + expect(pushOne).toHaveBeenCalledWith('dictionary', entry.id) + }) + + it('빈 단어는 기존과 같은 메시지로 거부한다', () => { + const err = captureError(() => mod.getDictionaryService().add({ word: ' ' })) + expect(err.message).toBe('Dictionary word is empty') + }) +}) + +describe('DictionaryService.update — 서버 길이 제한', () => { + it('발음을 201자로 바꾸는 편집은 쓰기·push 없이 거부한다', () => { + const err = captureError(() => + mod.getDictionaryService().update({ id: 'row-1', pronunciation: 'p'.repeat(201) }) + ) + expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) + expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 }) + expect(mockDb.update).not.toHaveBeenCalled() + expect(pushOne).not.toHaveBeenCalled() + }) + + it('단어를 121자로 바꾸는 편집을 거부한다', () => { + const err = captureError(() => + mod.getDictionaryService().update({ id: 'row-1', word: 'w'.repeat(121) }) + ) + expect(err.details).toMatchObject({ field: 'word', max: 120 }) + expect(mockDb.update).not.toHaveBeenCalled() + }) +}) + +describe('DictionaryService.importDictionary — 서버 길이 제한', () => { + it('너무 긴 단어·발음 행은 오류로 세고 저장·push 하지 않는다', () => { + readFileSync.mockReturnValue( + JSON.stringify({ + entries: [ + { word: 'w'.repeat(121) }, + { word: 'long-pron', pronunciation: 'p'.repeat(201) }, + { word: 'ok', pronunciation: '오케이' } + ] + }) + ) + const result = mod + .getDictionaryService() + .importDictionary({ filePath: '/in/d.json', format: 'json' }) + expect(result).toEqual({ imported: 1, skipped: 0, errors: 2 }) + expect(insertValues).toHaveBeenCalledTimes(1) + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ word: 'ok', pronunciation: '오케이', category: 'user' }) + ) + expect(pushOne).toHaveBeenCalledTimes(1) + }) + + it('CSV 의 너무 긴 행도 거부한다', () => { + readFileSync.mockReturnValue(`word,pronunciation\r\n${'x'.repeat(121)},\r\nfine,\r\n`) + const result = mod + .getDictionaryService() + .importDictionary({ filePath: '/in/d.csv', format: 'csv' }) + expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 }) + }) +}) diff --git a/apps/desktop/tests/main/services/keybinding-altgr-redteam-r3-8.test.ts b/apps/desktop/tests/main/services/keybinding-altgr-redteam-r3-8.test.ts new file mode 100644 index 0000000..46fee64 --- /dev/null +++ b/apps/desktop/tests/main/services/keybinding-altgr-redteam-r3-8.test.ts @@ -0,0 +1,104 @@ +// tests/main/services/keybinding-altgr-redteam-r3-8.test.ts +// 레드팀 r3-8 회귀 테스트 (데스크톱 어댑터) — AltGr 배열. +// +// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 time)으로 보내고, libuiohook 은 그 +// RAlt 눌림에 ctrlKey 를 싣는다. 어댑터가 그대로 옮기면 '@'(AltGr+Q) 마다 'command' 가 발동하고 +// 기본 dictation(RightAlt 단독)은 발동하지 않는다. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { uIOhook, UiohookKey } from 'uiohook-napi' +import type { UiohookKeyboardEvent } from 'uiohook-napi' +import { ALTGR_CHORD_GRACE_MS } from '@d3ro/core/keybinding-runtime' +import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { getKeyBindingService } from '../../../src/main/services/KeyBindingService' +import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function restorePlatform(): void { + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) +} + +type KeyHandler = (e: UiohookKeyboardEvent) => void + +function handlerOf(event: 'keydown' | 'keyup'): KeyHandler { + const calls = vi.mocked(uIOhook.on).mock.calls.filter((c) => c[0] === event) + const last = calls[calls.length - 1] + if (!last) throw new Error(`no ${event} handler`) + return last[1] as KeyHandler +} + +interface Mods { + ctrl?: boolean + alt?: boolean +} + +function keyEvent(keycode: number, time: number, mods: Mods = {}): UiohookKeyboardEvent { + return { + keycode, + time, + ctrlKey: mods.ctrl ?? false, + altKey: mods.alt ?? false, + shiftKey: false, + metaKey: false + } as unknown as UiohookKeyboardEvent +} + +describe('KeyBindingService — AltGr 배열 (Windows)', () => { + const events: string[] = [] + const listener = (p: KeyBindingTriggerPayload): void => { + events.push(`${p.actionId}:${p.type}`) + } + + beforeEach(() => { + vi.useFakeTimers() + setPlatform('win32') + initInMemoryConfig() + vi.mocked(uIOhook.on).mockClear() + events.length = 0 + const service = getKeyBindingService() + service.loadFromConfig() + service.start() + service.on('triggered', listener) + }) + + afterEach(() => { + const service = getKeyBindingService() + service.off('triggered', listener) + service.stop() + resetInMemoryConfig() + restorePlatform() + vi.useRealTimers() + }) + + function altGrDown(time: number): void { + handlerOf('keydown')(keyEvent(UiohookKey.Ctrl, time, { ctrl: true })) + handlerOf('keydown')(keyEvent(UiohookKey.AltRight, time, { ctrl: true, alt: true })) + } + + function altGrUp(): void { + handlerOf('keyup')(keyEvent(UiohookKey.Ctrl, 0, { alt: true })) + handlerOf('keyup')(keyEvent(UiohookKey.AltRight, 0)) + } + + it("AltGr+Q('@') 는 어떤 트리거도 내보내지 않는다", () => { + altGrDown(5_000) + handlerOf('keydown')(keyEvent(UiohookKey.Q, 5_040, { ctrl: true, alt: true })) + handlerOf('keyup')(keyEvent(UiohookKey.Q, 5_080, { ctrl: true, alt: true })) + altGrUp() + vi.advanceTimersByTime(1_000) + expect(events).toEqual([]) + }) + + it('AltGr 를 누르고 있으면 기본 dictation 이 발동한다 (command 가 아니다)', () => { + altGrDown(6_000) + vi.advanceTimersByTime(ALTGR_CHORD_GRACE_MS) + expect(events).toEqual(['dictation:pressed']) + altGrUp() + expect(events).toEqual(['dictation:pressed', 'dictation:released']) + }) +}) diff --git a/apps/desktop/tests/main/services/meeting-condense-redteam-r3-3.test.ts b/apps/desktop/tests/main/services/meeting-condense-redteam-r3-3.test.ts new file mode 100644 index 0000000..cf6ce1d --- /dev/null +++ b/apps/desktop/tests/main/services/meeting-condense-redteam-r3-3.test.ts @@ -0,0 +1,28 @@ +// tests/main/services/meeting-condense-redteam-r3-3.test.ts +// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의)도 map 조각이 프록시 한도 안에 들어가야 한다. +import { describe, expect, it, vi } from 'vitest' +import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser' + +const PROXY_MESSAGE_LIMIT = 8_000 + +/** CaptionService fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 */ +const singleLine = Array.from({ length: 900 }, (_, i) => `${i}번째 발언에서 일정과 담당자를 정리했습니다.`).join(' ') + +describe('condenseTranscriptToBudget — 한 줄짜리 긴 전사', () => { + it('한 줄 20,000자 전사를 여러 조각으로 나눠 줄이고, 조각 입력은 모두 한도 안이다', async () => { + expect(singleLine.includes('\n')).toBe(false) + expect(singleLine.length).toBeGreaterThan(20_000) + const inputs: string[] = [] + const llm = { + generate: vi.fn(async (text: string) => { + inputs.push(text) + return { text: `요약 ${inputs.length}` } + }), + } + const result = await condenseTranscriptToBudget(llm, singleLine, PROXY_MESSAGE_LIMIT) + expect(result.condensed).toBe(true) + expect(result.text.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT) + expect(inputs.length).toBeGreaterThan(1) + for (const input of inputs) expect(input.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT) + }) +}) diff --git a/apps/desktop/tests/main/services/runtime-provisioner-redteam-r3-5.test.ts b/apps/desktop/tests/main/services/runtime-provisioner-redteam-r3-5.test.ts new file mode 100644 index 0000000..d2c508d --- /dev/null +++ b/apps/desktop/tests/main/services/runtime-provisioner-redteam-r3-5.test.ts @@ -0,0 +1,195 @@ +// tests/main/services/runtime-provisioner-redteam-r3-5.test.ts +// 런타임 인덱스의 플랫폼 구분과 "feed 가 앱보다 늦은" 경우의 회귀 테스트. +// +// 회귀 대상: +// 1) runtime.json 에 platform/arch 가 없어 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고 +// 검증에서 떨어진 뒤, ensure 할 때마다 ~160MB 를 다시 받던 문제 +// 2) feed 버전이 앱의 RUNTIME_MIN_VERSION 보다 낮으면 설치된 엔진까지 버리고 로컬 STT 가 +// 완전히 멈추던 문제 + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + RuntimeIndexRejectedError, + isRuntimeIndexRejected, + parseRuntimeIndex, +} from '../../../src/main/services/runtime/runtime-index' +import type { RuntimeFetch, RuntimeFetchResponse } from '../../../src/main/services/runtime/download-part' +import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner' + +const FEED = 'https://feed.test/runtime-latest' +const SHA = 'c'.repeat(64) +const PART = 'd3ro-runtime-sidecar.tar.gz.001' + +function index(version: string, target?: { platform: string; arch: string }): Record { + return { + schemaVersion: 1, + version, + ...(target ?? {}), + components: { + sidecar: { + archive: 'd3ro-runtime-sidecar.tar.gz', + sha256: SHA, + totalSize: 10, + parts: [{ name: PART, size: 10, sha256: SHA, url: `${FEED}/${PART}` }], + }, + }, + } +} + +function feed(body: Record): { fetchImpl: RuntimeFetch; calls: string[] } { + const calls: string[] = [] + const fetchImpl: RuntimeFetch = async (url) => { + calls.push(url) + if (url === `${FEED}/runtime.json`) { + return new Response(JSON.stringify(body), { status: 200 }) as RuntimeFetchResponse + } + return new Response(new Uint8Array(10), { status: 200 }) as RuntimeFetchResponse + } + return { fetchImpl, calls } +} + +let workDir: string + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r3-5-')) +}) + +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) +}) + +function provisioner( + fetchImpl: RuntimeFetch, + platform: NodeJS.Platform = 'win32', + arch = 'x64', +): RuntimeProvisioner { + return new RuntimeProvisioner({ + userDataDir: () => workDir, + fetchImpl, + feedUrl: FEED, + platform, + arch, + stallTimeoutMs: 1000, + partAttempts: 1, + }) +} + +/** 예전에 받아 둔 엔진이 설치돼 있는 상태 (마커 버전 지정) */ +function installOldSidecar(p: RuntimeProvisioner, markerVersion: string): void { + const dir = p.componentDir('sidecar') + mkdirSync(join(dir, '_internal'), { recursive: true }) + writeFileSync(p.binaryPath('sidecar'), 'old engine') + writeFileSync(join(dir, '.runtime-version'), markerVersion) +} + +describe('parseRuntimeIndex — 플랫폼 대상', () => { + const WIN = { platform: 'win32', arch: 'x64' } + + it('인덱스가 밝힌 플랫폼과 앱 플랫폼이 다르면 platform-mismatch 로 거부한다', () => { + let caught: unknown + try { + parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(RuntimeIndexRejectedError) + expect(isRuntimeIndexRejected(caught, 'platform-mismatch')).toBe(true) + expect((caught as Error).message).toMatch(/darwin-arm64/) + }) + + it('아키텍처만 달라도 거부한다', () => { + expect(() => + parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'win32', arch: 'arm64' }), + ).toThrow(RuntimeIndexRejectedError) + }) + + it('같은 플랫폼이면 통과하고, 대상을 넘기지 않으면 검사하지 않는다', () => { + expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', WIN).version).toBe('1.9.0') + expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0').version).toBe('1.9.0') + }) + + it('platform/arch 가 없는 예전 인덱스는 그대로 읽는다 (하위 호환)', () => { + expect(parseRuntimeIndex(index('1.9.0'), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }).version).toBe( + '1.9.0', + ) + }) + + it('platform/arch 형식이 깨졌으면 형식 오류로 거부한다', () => { + const broken = { ...index('1.9.0'), platform: 'win32', arch: 7 } + let caught: unknown + try { + parseRuntimeIndex(broken, 'sidecar', '1.7.0', WIN) + } catch (err) { + caught = err + } + expect((caught as Error).message).toMatch(/platform\/arch/) + expect(isRuntimeIndexRejected(caught)).toBe(false) + }) + + it('최소 버전 미만 feed 는 feed-outdated 로 구분된다', () => { + let caught: unknown + try { + parseRuntimeIndex(index('1.6.0', WIN), 'sidecar', '1.7.0', WIN) + } catch (err) { + caught = err + } + expect(isRuntimeIndexRejected(caught, 'feed-outdated')).toBe(true) + expect((caught as Error).message).toMatch(/최소 요구 버전/) + }) +}) + +describe('RuntimeProvisioner — 다른 플랫폼 feed', () => { + it('macOS 앱은 Windows 런타임 인덱스를 보면 부품을 받기 전에 실패한다', async () => { + const f = feed(index('1.9.0', { platform: 'win32', arch: 'x64' })) + const p = provisioner(f.fetchImpl, 'darwin', 'arm64') + + await expect(p.ensure('sidecar')).rejects.toThrow(/이 플랫폼\(darwin-arm64\)용 런타임이 feed에 없습니다/) + expect(f.calls).toEqual([`${FEED}/runtime.json`]) + expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false) + + // 다시 불러도 인덱스만 읽는다 — 대용량 재다운로드 루프가 없다 + f.calls.length = 0 + await expect(p.ensure('sidecar')).rejects.toThrow(RuntimeIndexRejectedError) + expect(f.calls).toEqual([`${FEED}/runtime.json`]) + }) +}) + +describe('RuntimeProvisioner — feed 가 앱의 최소 버전보다 낮을 때', () => { + it('설치된 (낡은) 엔진이 있으면 그 경로를 돌려주고 부품을 받지 않는다', async () => { + const f = feed(index('1.6.0')) + const p = provisioner(f.fetchImpl) + installOldSidecar(p, '1.6.0') + + await expect(p.ensure('sidecar')).resolves.toBe(p.binaryPath('sidecar')) + expect(f.calls).toEqual([`${FEED}/runtime.json`]) + expect(p.isInstalled('sidecar')).toBe(true) + }) + + it('"다시 설치"(force)는 조용히 넘어가지 않고 실패를 알린다', async () => { + const f = feed(index('1.6.0')) + const p = provisioner(f.fetchImpl) + installOldSidecar(p, '1.6.0') + + await expect(p.ensure('sidecar', { force: true })).rejects.toThrow(/최소 요구 버전/) + expect(p.isInstalled('sidecar')).toBe(true) + }) + + it('설치된 엔진이 없으면 실패한다', async () => { + const f = feed(index('1.6.0')) + await expect(provisioner(f.fetchImpl).ensure('sidecar')).rejects.toThrow(/최소 요구 버전/) + }) + + it('다른 이유의 실패(인덱스 404)는 설치된 엔진으로 덮지 않는다', async () => { + const calls: string[] = [] + const fetchImpl: RuntimeFetch = async (url) => { + calls.push(url) + return new Response(null, { status: 404 }) as RuntimeFetchResponse + } + const p = provisioner(fetchImpl) + installOldSidecar(p, '1.6.0') + await expect(p.ensure('sidecar')).rejects.toThrow(/HTTP 404/) + }) +}) diff --git a/apps/desktop/tests/main/services/stt-timeout-redteam-r3-1.test.ts b/apps/desktop/tests/main/services/stt-timeout-redteam-r3-1.test.ts new file mode 100644 index 0000000..053daa4 --- /dev/null +++ b/apps/desktop/tests/main/services/stt-timeout-redteam-r3-1.test.ts @@ -0,0 +1,21 @@ +// tests/main/services/stt-timeout-redteam-r3-1.test.ts +// 최종 전사 응답 기한은 오디오 길이에 비례한다 (item 9) — 고정 120초가 몇 분짜리 핸즈프리 녹음을 버렸다. +import { describe, expect, it } from 'vitest' +import { transcriptionTimeoutMs, PCM16_MONO_16K_BYTES_PER_SECOND } from '../../../src/main/services/stt/audio-utils' + +describe('transcriptionTimeoutMs', () => { + const options = { floorMs: 120_000, perAudioSecondMs: 3000 } + + it('짧은 녹음은 하한(120초)을 쓴다', () => { + expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 5, options)).toBe(120_000) + }) + + it('10분 녹음은 오디오 길이에 비례해 기다린다', () => { + expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 600, options)).toBe(1_800_000) + }) + + it('음수·0 바이트는 하한', () => { + expect(transcriptionTimeoutMs(0, options)).toBe(120_000) + expect(transcriptionTimeoutMs(-5, options)).toBe(120_000) + }) +}) diff --git a/apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts b/apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts new file mode 100644 index 0000000..e2ca4b7 --- /dev/null +++ b/apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts @@ -0,0 +1,317 @@ +// tests/main/services/suggestion-redteam-r3-1.test.ts +// 제안 서비스 회귀 (r3-1): +// - 케어렛을 모르는 문서 중간 편집 뒤에는 수락하지 않는다 +// - 다른 기능의 짧은 keep_alive 로 모델이 내려갔으면 warm 이 아니다 / 시간 초과면 콜드로 본다 +// - 입력 수집 동의가 없으면 모델을 데우지 않고, 철회하면 세션·보관 문맥을 버린다 +// - 한 세션은 한 이력 행이다 (채우기 후보는 행을 늘리지 않고, 수락은 세션 행에 기록) +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PersonalPhrase } from '@d3ro/core/input-intelligence' + +const config = vi.hoisted(() => ({ + suggestionEnabled: true as boolean, + suggestionModelId: 'model-a' as string | null, + llmModelId: 'model-a' as string | null, + inputExcludedApps: [] as string[], + suggestionTriggerDelayMs: 600, + suggestionMinPrefixChars: 8, + suggestionMaxRequestsPerMinute: 12, + suggestionDailyBudget: 500, + suggestionRequestTimeoutMs: 8000, + inputLearnTypedText: false as boolean, + inputTelemetryEnabled: false as boolean, + inputTelemetryPaused: false as boolean, + suggestionOverlayInteractive: true, + insertMethod: 'clipboard' +})) +const harness = vi.hoisted(() => ({ + isAvailable: vi.fn(() => true), + streamGenerate: vi.fn(), + insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })), + phrases: [] as PersonalPhrase[] +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: keyof typeof config) => config[key]), + configSet: vi.fn((key: keyof typeof config, value: never) => { + config[key] = value + }) +})) +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate }) +})) +vi.mock('../../../src/main/services/InputTelemetryService', () => ({ + getInputTelemetryService: () => ({ listPhrases: () => harness.phrases }) +})) +vi.mock('../../../src/main/services/PersonalGraphService', () => ({ + getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) }) +})) +vi.mock('../../../src/main/services/TextInsertService', () => ({ + getTextInsertService: () => ({ insertText: harness.insertText }) +})) +vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true })) +vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null })) +vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() })) +vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} })) + +import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService' +import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget' +import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker' +import type { TypingContext } from '../../../src/main/services/InputTelemetryService' + +const PREFIX = '오늘 회의에서 논의한 내용을 정리해서' +const SUGGESTION = '다음 단계도 확인하겠습니다.' + +function typingContext(overrides: Partial = {}): TypingContext { + const prefix = overrides.prefix ?? PREFIX + return { + prefix, + fullText: prefix, + caretOffset: prefix.length, + anchor: { x: 100, y: 100, width: 2, height: 20 }, + anchorKind: 'caret', + isPassword: false, + isEditable: true, + isComposing: false, + hasSelection: false, + available: true, + appName: 'Slack.exe', + windowTitle: 'general', + windowHandle: 101, + idleMs: 2000, + capturedAt: Date.now(), + editedSinceFocus: true, + typedRecently: true, + ...overrides + } +} + +interface Fakes { + deps: SuggestionServiceDeps + record: ReturnType + markAccepted: ReturnType + expectInsert: ReturnType + recordAccepted: ReturnType +} + +function fakes(): Fakes { + const record = vi.fn() + const markAccepted = vi.fn() + const expectInsert = vi.fn(() => vi.fn()) + const recordAccepted = vi.fn() + return { + record, + markAccepted, + expectInsert, + recordAccepted, + deps: { + repository: { record, markAccepted, list: vi.fn(() => []) }, + budget: new SuggestionBudget(), + warmth: new ModelWarmTracker(), + foreground: { currentWindowHandle: () => 101 }, + learning: { expectProgrammaticInsert: expectInsert, recordAccepted } + } + } +} + +function streamOf(text: string): AsyncGenerator { + return (async function* () { + yield text + })() +} + +/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */ +function gatedStream(signal: AbortSignal | undefined, gate: Promise): AsyncGenerator { + return (async function* () { + const text = await new Promise((resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + void gate.then(resolve) + }) + yield text + })() +} + +interface Internal { + _generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise + _lastContext: TypingContext | null + _inFlight: boolean + _warmUpPromise: Promise | null +} + +let services: SuggestionService[] = [] +function create(deps: SuggestionServiceDeps, warm = true): SuggestionService { + if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = new SuggestionService(deps) + services.push(service) + return service +} + + +import { ModelResidencyLedger, parseKeepAliveMs } from '../../../src/main/services/llm/model-residency' + +beforeEach(() => { + config.suggestionEnabled = true + config.suggestionModelId = 'model-a' + config.llmModelId = 'model-a' + config.suggestionRequestTimeoutMs = 8000 + config.inputLearnTypedText = false + config.inputTelemetryEnabled = false + config.inputTelemetryPaused = false + harness.isAvailable.mockReset() + harness.isAvailable.mockReturnValue(true) + harness.streamGenerate.mockReset() + harness.insertText.mockClear() + harness.phrases = [] +}) + +afterEach(() => { + for (const service of services) service.dispose() + services = [] +}) + +async function flush(): Promise { + for (let i = 0; i < 8; i += 1) await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('케어렛을 모르는 필드 (item 3)', () => { + it('문서 중간 편집 뒤(caretReliable=false)에는 표시 중인 후보를 수락하지 않는다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) + const fake = fakes() + const service = create(fake.deps) + await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) + expect(service.isVisible).toBe(true) + + const internal = service as unknown as Internal + internal._lastContext = typingContext({ caretOffset: null, caretReliable: false }) + const result = await service.accept() + + expect(result).toEqual({ ok: false, reason: 'caret-unknown' }) + expect(harness.insertText).not.toHaveBeenCalled() + }) + + it('케어렛을 모르는 문맥이 오면 세션을 닫는다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) + const fake = fakes() + const service = create(fake.deps) + await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) + service.handleTypingContext(typingContext({ caretOffset: null, caretReliable: false })) + expect(service.isVisible).toBe(false) + expect(service.getState().lastSkipReason).toBe('caret-unknown') + }) +}) + +describe('모델 상주 추정 (item 4)', () => { + it('keep_alive 문자열을 해석한다', () => { + expect(parseKeepAliveMs('10m')).toBe(600_000) + expect(parseKeepAliveMs('2m')).toBe(120_000) + expect(parseKeepAliveMs(undefined)).toBe(300_000) + expect(parseKeepAliveMs('1h30m')).toBe(5_400_000) + }) + + it('같은 모델을 더 짧은 keep_alive 로 쓴 요청이 있으면 그 만료 뒤에는 warm 이 아니다', () => { + const ledger = new ModelResidencyLedger() + const tracker = new ModelWarmTracker(ledger) + const t0 = 1_000_000 + ledger.noteRequest('model-a', '10m', t0) + tracker.noteWarm('model-a', t0 + 570_000) + // 1분 뒤 음성 대화가 같은 모델을 keep_alive 2m 로 사용 + ledger.noteRequest('model-a', '2m', t0 + 60_000) + expect(tracker.isWarm('model-a', t0 + 120_000)).toBe(true) + expect(tracker.isWarm('model-a', t0 + 6 * 60_000)).toBe(false) + }) + + it('warm 이라 믿은 요청이 시간 초과하면 콜드로 본다 (다음 멈춤은 워밍업)', async () => { + config.suggestionRequestTimeoutMs = 20 + harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => + gatedStream(options.signal, new Promise(() => undefined)) + ) + const fake = fakes() + const service = create(fake.deps) + expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(true) + await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) + expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(false) + }) +}) + +describe('입력 수집 동의 (item 7)', () => { + it('동의 없이 제안을 켜면 모델을 데우지 않는다', async () => { + const fake = fakes() + const service = create(fake.deps, false) + service.setEnabled(true) + await flush() + expect(harness.streamGenerate).not.toHaveBeenCalled() + }) + + it('동의를 철회하면 떠 있는 세션을 닫고 보관 문맥을 버린다 (수동 요청이 옛 접두로 생성하지 않음)', async () => { + config.inputTelemetryEnabled = true + harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) + const fake = fakes() + const service = create(fake.deps) + service.handleTypingContext(typingContext()) + await flush() + expect(service.isVisible).toBe(true) + + config.inputTelemetryEnabled = false + harness.streamGenerate.mockClear() + service.handleInputConsentChanged() + + expect(service.isVisible).toBe(false) + expect(await service.requestNow()).toEqual({ ok: false, reason: 'not-editable' }) + expect(harness.streamGenerate).not.toHaveBeenCalled() + }) +}) + +describe('제안 이력은 세션당 한 행 (item 8)', () => { + function sessionRepository() { + return { + record: vi.fn(() => 'row-1'), + updateSessionCandidates: vi.fn(), + markSessionAccepted: vi.fn(), + markAccepted: vi.fn(), + list: vi.fn(() => []) + } + } + + it('채우기 후보는 새 행(지연 0)을 만들지 않고 세션 행의 후보 수만 갱신한다', async () => { + const outputs = ['첫 번째 후보 문장입니다.', '두 번째 후보 문장입니다.', '세 번째 후보 문장입니다.'] + let call = 0 + harness.streamGenerate.mockImplementation(() => streamOf(outputs[Math.min(call++, outputs.length - 1)])) + const fake = fakes() + const repository = sessionRepository() + fake.deps.repository = repository + const service = create(fake.deps) + await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) + await flush() + + expect(service.getState().candidates.length).toBeGreaterThanOrEqual(2) + expect(repository.record).toHaveBeenCalledTimes(1) + expect(repository.updateSessionCandidates).toHaveBeenCalledWith('row-1', expect.any(Number)) + }) + + it('로컬 기억 세션에서 2번째 후보를 수락하면 그 세션 행에 기록한다', async () => { + config.suggestionModelId = null + config.llmModelId = null + harness.phrases = [ + { id: 'p1', phrase: '다음 단계도 확인하겠습니다.', count: 3, score: 3, lastUsedAt: Date.now(), appName: null, source: 'typed' }, + { id: 'p2', phrase: '회의록은 내일 공유드리겠습니다.', count: 2, score: 2, lastUsedAt: Date.now(), appName: null, source: 'typed' } + ] as unknown as typeof harness.phrases + const fake = fakes() + const repository = sessionRepository() + fake.deps.repository = repository + const service = create(fake.deps, false) + const context = typingContext({ prefix: '오늘 회의 정리했습니다.' }) + const internal = service as unknown as Internal + internal._lastContext = context + await internal._generate(context.prefix, context, 3, 160) + const state = service.getState() + expect(state.candidates.length).toBeGreaterThanOrEqual(2) + expect(repository.record).toHaveBeenCalledTimes(1) + + const second = state.candidates[1].text + expect(await service.accept(1)).toEqual({ ok: true }) + expect(repository.markSessionAccepted).toHaveBeenCalledWith('row-1', second) + expect(repository.markAccepted).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/services/voice-command-redteam-r3-9.test.ts b/apps/desktop/tests/main/services/voice-command-redteam-r3-9.test.ts new file mode 100644 index 0000000..7f061e5 --- /dev/null +++ b/apps/desktop/tests/main/services/voice-command-redteam-r3-9.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest' +import type { VoiceCommandRule } from '@d3ro/core/types' +import { + DEFAULT_VOICE_COMMAND_KEYWORDS, + buildDefaultRules, + findRuleMatch, + matchKeyword, + migrateLegacyDefaultRules +} from '../../../src/main/services/voice-command-policy' +import { + createVoiceCommandService, + type VoiceCommandStorePort +} from '../../../src/main/services/VoiceCommandService' + +function memoryStore( + initial: { rules?: VoiceCommandRule[]; enabled?: boolean } = {} +): VoiceCommandStorePort & { saved: VoiceCommandRule[] | undefined } { + const state = { + rules: initial.rules, + enabled: initial.enabled, + saved: undefined as VoiceCommandRule[] | undefined + } + return { + get saved() { + return state.saved + }, + loadRules: () => state.rules, + saveRules: (rules) => { + state.rules = rules + state.saved = rules + }, + loadEnabled: () => state.enabled, + saveEnabled: (enabled) => { + state.enabled = enabled + } + } +} + +let seq = 0 +const defaultRules = (): VoiceCommandRule[] => buildDefaultRules(() => `r${seq++}`) + +describe('기본 키워드: 일반 받아쓰기 문장을 명령으로 삼키지 않는다', () => { + const dictation = [ + '요약 보고서 첨부해서 보내드립니다.', + '요약.', + '설명 드리겠습니다, 이번 배포는 늦어집니다.', + '번역 작업은 다음 주에 끝납니다.', + '영어로 된 문서를 보내 주세요.', + '다듬기 작업이 남았습니다.', + 'Explain to the team that the deploy is delayed', + 'Polish the slides before the meeting', + 'Translate team is out today', + 'Summarize: nothing yet' + ] + + for (const text of dictation) { + it(`"${text}" 는 매칭되지 않는다`, () => { + expect(findRuleMatch(text, defaultRules())).toBeNull() + }) + } +}) + +describe('기본 키워드: 명령형은 매칭하고 키워드를 떼어낸다', () => { + const cases: Array<[string, string, string]> = [ + ['번역해줘 이 문장', 'builtin-translate', '이 문장'], + ['번역해 줘, 오늘 회의 끝', 'builtin-translate', '오늘 회의 끝'], + ['영어로 번역해줘. 안녕하세요', 'builtin-translate', '안녕하세요'], + ['Translate this: good morning', 'builtin-translate', 'good morning'], + ['요약해줘 긴 글', 'builtin-summarize', '긴 글'], + ['요약해 주세요. 오늘 회의 내용', 'builtin-summarize', '오늘 회의 내용'], + ['다듬어줘 메일 초안', 'builtin-formal', '메일 초안'], + ['설명해줘 const x = 1', 'builtin-explain-code', 'const x = 1'], + ['explain this "for (;;)"', 'builtin-explain-code', '"for (;;)"'] + ] + + for (const [text, instructionId, cleaned] of cases) { + it(`"${text}" → ${instructionId}`, () => { + const found = findRuleMatch(text, defaultRules()) + expect(found?.rule.instructionId).toBe(instructionId) + expect(found?.cleanedText).toBe(cleaned) + }) + } + + it('명령어만 말하고 내용이 없으면 명령으로 보지 않는다', () => { + expect(findRuleMatch('요약해줘.', defaultRules())).toBeNull() + expect(findRuleMatch('번역해줘', defaultRules())).toBeNull() + }) + + it('기본 키워드에 맨 명사가 없다', () => { + const all = DEFAULT_VOICE_COMMAND_KEYWORDS.flatMap((e) => e.keywords.map((k) => k.keyword)) + for (const bare of ['요약', '설명', '번역', '영어로', '다듬기', 'explain', 'polish', 'translate', 'summarize']) { + expect(all).not.toContain(bare) + } + }) +}) + +describe('matchKeyword: 잘라낸 자리의 구두점과 빈 내용', () => { + const table: Array<[string, string, 'prefix' | 'suffix' | 'contains', boolean, string]> = [ + ['짧게, 오늘 회의 내용', '짧게', 'prefix', true, '오늘 회의 내용'], + ['짧게.', '짧게', 'prefix', false, '짧게.'], + ['짧게 ...', '짧게', 'prefix', false, '짧게 ...'], + ['짧게요 오늘', '짧게', 'prefix', false, '짧게요 오늘'], + ['오늘 회의 내용, 번역해줘', '번역해줘', 'suffix', true, '오늘 회의 내용'], + ['번역해줘', '번역해줘', 'suffix', false, '번역해줘'], + ['이 메일 정중하게 써줘', '정중하게', 'contains', true, '이 메일 써줘'], + ['정중하게', '정중하게', 'contains', false, '정중하게'], + ['anything', '', 'prefix', false, 'anything'] + ] + + for (const [text, keyword, mode, matched, cleaned] of table) { + it(`${mode} "${keyword}" in "${text}"`, () => { + expect(matchKeyword(text, keyword, mode)).toEqual({ matched, cleanedText: cleaned }) + }) + } +}) + +describe('레거시 기본값 이관', () => { + const legacy = (): VoiceCommandRule[] => [ + { + id: 'a', + instructionId: 'builtin-summarize', + keywords: [ + { keyword: '요약해줘', matchMode: 'prefix' }, + { keyword: '요약', matchMode: 'prefix' }, + { keyword: 'summarize', matchMode: 'prefix' } + ], + enabled: true, + priority: 1 + }, + { + id: 'b', + instructionId: 'builtin-translate', + keywords: [{ keyword: '영작', matchMode: 'prefix' }], + enabled: true, + priority: 0 + } + ] + + it('손대지 않은 레거시 기본값만 교체하고 사용자 키워드는 둔다', () => { + const { rules, migrated } = migrateLegacyDefaultRules(legacy()) + expect(migrated).toBe(1) + expect(rules[0].id).toBe('a') + expect(rules[0].keywords.map((k) => k.keyword)).not.toContain('요약') + expect(rules[1].keywords).toEqual([{ keyword: '영작', matchMode: 'prefix' }]) + expect(migrateLegacyDefaultRules(rules).migrated).toBe(0) + }) + + it('서비스 초기화 때 저장된 레거시 기본값을 이관하고 저장한다', () => { + const store = memoryStore({ rules: legacy(), enabled: true }) + const svc = createVoiceCommandService(store) + svc.initialize() + svc.initDefaultKeywords() + + expect(svc.match('요약 보고서 첨부해서 보내드립니다.').matched).toBe(false) + const cmd = svc.match('요약해줘 긴 글') + expect(cmd.instructionId).toBe('builtin-summarize') + expect(cmd.cleanedText).toBe('긴 글') + expect(store.saved?.find((r) => r.id === 'a')?.keywords.map((k) => k.keyword)).not.toContain( + '요약' + ) + }) + + it('이관할 것이 없으면 저장하지 않는다', () => { + const store = memoryStore({ rules: defaultRules(), enabled: true }) + createVoiceCommandService(store).initialize() + expect(store.saved).toBeUndefined() + }) +}) diff --git a/apps/desktop/tests/main/services/voice-recording-store-redteam-r3-1.test.ts b/apps/desktop/tests/main/services/voice-recording-store-redteam-r3-1.test.ts new file mode 100644 index 0000000..870b783 --- /dev/null +++ b/apps/desktop/tests/main/services/voice-recording-store-redteam-r3-1.test.ts @@ -0,0 +1,282 @@ +// tests/main/services/voice-recording-store-redteam-r3-1.test.ts +// 받아쓰기 녹음 저장 순서 회귀 (r3-1, items 11·12): +// - 녹음 WAV 는 session-completed(히스토리 생성·history_audio 동기화 예약) 전에 저장된다 +// - 완료 이벤트는 실제 저장 경로를 싣고, 저장 실패·오디오 없음이면 null 이다 +// - 히스토리는 이벤트의 경로만 audioLocalPath 로 쓴다 + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { EventEmitter } from 'events' +import os from 'os' +import path from 'path' +import fs from 'fs' +import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService' +import { createFsRecordingStore, type RecordingStore } from '../../../src/main/services/recording/recording-store' +import { pcmToWav } from '../../../src/main/services/stt/audio-utils' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +type SttResult = { text: string; segments: never[]; language: string; duration: number; processingTime: number } +const result = (text: string): SttResult => ({ text, segments: [], language: 'ko', duration: 1, processingTime: 1 }) + +const mockSTT = vi.hoisted(() => ({ + initialize: vi.fn(), + transcribe: vi.fn(), + transcribePartial: vi.fn(async () => ''), + getModels: vi.fn(), + getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })), +})) + +vi.mock('../../../src/main/services/LocalSTTService', () => ({ + getLocalSTTService: () => mockSTT, + resetLocalSTTServiceForTests: () => undefined, +})) + +const audioBus = new EventEmitter() +const mockAudio = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn(), on: vi.fn(), off: vi.fn() })) +vi.mock('../../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => mockAudio, +})) + +const keyBinding = vi.hoisted(() => ({ + handler: null as ((payload: KeyBindingTriggerPayload) => void) | null, +})) +vi.mock('../../../src/main/services/KeyBindingService', () => ({ + getKeyBindingService: () => ({ + on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => { + keyBinding.handler = fn + }, + off: vi.fn(), + }), +})) + +const config = vi.hoisted(() => ({ values: {} as Record })) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => config.values[key]), +})) + +const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() })) +vi.mock('../../../src/main/services/TextInsertService', () => ({ + getTextInsertService: () => mockInsert, +})) + +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: () => true }), +})) + +const gateway = vi.hoisted(() => ({ processText: vi.fn() })) +vi.mock('../../../src/main/services/llm/LlmGateway', () => ({ + getLlmGateway: () => gateway, +})) + +vi.mock('../../../src/main/services/CaptionService', () => ({ + getCaptionService: () => ({ getState: () => 'inactive', stop: vi.fn(), start: vi.fn() }), +})) + +const chain = vi.hoisted(() => ({ execute: vi.fn() })) +vi.mock('../../../src/main/services/ChainService', () => ({ + getChainService: () => chain, +})) + +const instructions = vi.hoisted(() => ({ + byId: {} as Record, +})) +vi.mock('../../../src/main/services/CustomInstructionService', () => ({ + getCustomInstructionService: () => ({ getById: (id: string) => instructions.byId[id] ?? null }), +})) + +const voiceCommand = vi.hoisted(() => ({ instructionId: null as string | null })) +vi.mock('../../../src/main/services/VoiceCommandService', () => ({ + getVoiceCommandService: () => ({ + isEnabled: () => voiceCommand.instructionId !== null, + match: (text: string) => + voiceCommand.instructionId + ? { matched: true, ruleId: 'r', instructionId: voiceCommand.instructionId, cleanedText: text, matchedKeyword: 'k' } + : { matched: false, ruleId: null, instructionId: null, cleanedText: text, matchedKeyword: null }, + }), +})) + +const screen = vi.hoisted(() => ({ + enabled: false, + captureContext: vi.fn(), + captureSelectedText: vi.fn(), +})) +vi.mock('../../../src/main/services/ScreenContextService', () => ({ + getScreenContextService: () => ({ + isEnabled: () => screen.enabled, + captureContext: screen.captureContext, + captureSelectedText: screen.captureSelectedText, + buildContextPrompt: (ctx: { appName: string | null; selectedText: string | null }) => + `[ctx app=${ctx.appName ?? '-'} sel=${ctx.selectedText ?? '-'}]\n`, + }), +})) + +type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService') +let getVoiceModeService: VoiceModeModule['getVoiceModeService'] +let createWithStore: VoiceModeModule['createVoiceModeServiceForTests'] + +const SPEECH = Buffer.alloc(16000 * 2) // 1초 + +async function flush(times = 8): Promise { + for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r)) +} + +function advanceClock(ms: number): void { + const base = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(base + ms) +} + +function key( + actionId: 'dictation' | 'hands-free', + type: 'pressed' | 'released', + isDoublePress: boolean, +): KeyBindingTriggerPayload { + return { + actionId, + type, + isDoublePress, + holdMode: actionId === 'dictation', + timestamp: Date.now(), + durationMs: 0, + } as unknown as KeyBindingTriggerPayload +} + +function screenContext(appName: string): { context: { appName: string; windowTitle: string; selectedText: null; capturedAt: number }; selectedTextAttempted: boolean } { + return { context: { appName, windowTitle: 'w', selectedText: null, capturedAt: 0 }, selectedTextAttempted: false } +} + +beforeEach(async () => { + vi.restoreAllMocks() + vi.resetModules() + audioBus.removeAllListeners() + config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' } + keyBinding.handler = null + screen.enabled = false + voiceCommand.instructionId = null + instructions.byId = {} + for (const fn of [ + mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels, + mockAudio.start, mockAudio.stop, mockAudio.on, mockAudio.off, mockInsert.insertText, + gateway.processText, chain.execute, screen.captureContext, screen.captureSelectedText, + ]) { + fn.mockReset() + } + mockSTT.initialize.mockResolvedValue(undefined) + mockSTT.transcribe.mockResolvedValue(result('기본 전사')) + mockSTT.transcribePartial.mockResolvedValue('') + mockSTT.getModels.mockReturnValue([ + { id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 }, + ]) + mockAudio.start.mockResolvedValue(undefined) + mockAudio.stop.mockResolvedValue(undefined) + mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => { + audioBus.on(ev, fn) + }) + mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => { + audioBus.off(ev, fn) + }) + mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 }) + gateway.processText.mockImplementation(async (text: string) => `LLM(${text})`) + chain.execute.mockResolvedValue({ finalText: '체인 결과' }) + screen.captureContext.mockResolvedValue(screenContext('Code')) + screen.captureSelectedText.mockResolvedValue('선택 문장') + const mod = await import('../../../src/main/services/VoiceModeService') + mod.resetVoiceModeServiceForTests() + getVoiceModeService = mod.getVoiceModeService + createWithStore = mod.createVoiceModeServiceForTests +}) + + +async function recordAndStop(store: RecordingStore): Promise<{ order: string[]; completed: Array<{ audioPath: string | null }> }> { + const order: string[] = [] + const completed: Array<{ audioPath: string | null }> = [] + const svc = createWithStore({ + save: async (sessionId, pcm) => { + const saved = await store.save(sessionId, pcm) + order.push('saved') + return saved + }, + }) + svc.on('session-completed', ({ audioPath }) => { + order.push('completed') + completed.push({ audioPath }) + }) + await svc.startSession('dictation') + await vi.waitFor(() => expect(svc.isActive).toBe(true)) + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(3000) + await svc.stopSession() + await flush() + return { order, completed } +} + +describe('녹음 저장 → 완료 순서', () => { + it('WAV 저장이 끝난 뒤에 session-completed 가 나가고 실제 경로를 싣는다', async () => { + let release: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const store: RecordingStore = { + save: async (sessionId) => { + await gate + return `/rec/${sessionId}.wav` + }, + } + const pending = recordAndStop(store) + await flush() + release() + const { order, completed } = await pending + expect(order).toEqual(['saved', 'completed']) + expect(completed[0].audioPath).toMatch(/^\/rec\/.+\.wav$/) + }) + + it('저장에 실패하면 audioPath 는 null 이다 (존재하지 않는 경로를 남기지 않음)', async () => { + const { completed } = await recordAndStop({ save: async () => null }) + expect(completed).toEqual([{ audioPath: null }]) + }) +}) + +describe('fs 녹음 저장소', () => { + it('pcmToWav 와 같은 바이트로 recordings/.wav 에 쓴다', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-rec-r3-1-')) + try { + const store = createFsRecordingStore({ + resolveDir: async () => dir, + mkdir: (target) => fs.promises.mkdir(target, { recursive: true }), + writeFile: (target, data) => fs.promises.writeFile(target, data), + }) + const pcm = Buffer.alloc(3200, 1) + const saved = await store.save('abc', pcm) + expect(saved).toBe(path.join(dir, 'abc.wav')) + expect(fs.readFileSync(saved as string).equals(pcmToWav(pcm, 16000, 1, 16))).toBe(true) + expect(await store.save('empty', Buffer.alloc(0))).toBeNull() + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('쓰기 실패는 예외 대신 null', async () => { + const store = createFsRecordingStore({ + resolveDir: async () => '/nowhere', + mkdir: async () => undefined, + writeFile: async () => { + throw new Error('EACCES') + }, + }) + expect(await store.save('x', Buffer.alloc(10))).toBeNull() + }) +}) diff --git a/apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts b/apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts new file mode 100644 index 0000000..326185f --- /dev/null +++ b/apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts @@ -0,0 +1,81 @@ +// tests/main/sync/audio-sync-redteam-r3-1.test.ts +// 동기화 회귀 (r3-1): +// - 파일 전사 기록(audioLocalPath = 사용자가 고른 원본 미디어)은 '녹음 동기화'로 올리지 않는다 +// - 대기 중인 원격 삭제를 뒤늦은 upsert(예: 자동 제목)가 덮어쓰지 않는다 + +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestDb } from '../../helpers/createTestDb' +import { FakeSyncRemote } from '../../helpers/fakeSyncRemote' +import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' +import { history } from '../../../src/main/db/schema' +import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { listLocalAudioOwners, localAudioPath } from '../../../src/main/services/sync/audio-sync' +import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' +import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox' + +const USER = '11111111-1111-4111-8111-111111111111' + +let testDb: ReturnType +let remote: FakeSyncRemote +let engine: SyncEngine +let tmpDir: string + +beforeEach(() => { + testDb = createTestDb() + bindTestDatabase(testDb.db, USER) + initInMemoryConfig() + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed')) + remote = new FakeSyncRemote(USER) + engine = new SyncEngine({ remote, userId: USER }) + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-audio-r3-1-')) +}) + +afterEach(() => { + vi.restoreAllMocks() + engine.dispose() + unbindTestDatabase() + resetInMemoryConfig() + testDb.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +function insertHistory(mode: 'dictation' | 'file-transcription', fileName: string): string { + const id = crypto.randomUUID() + const file = path.join(tmpDir, fileName) + fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake media')) + const now = Date.now() + testDb.db + .insert(history) + .values({ id, originalText: 'spoken', duration: 2, mode, audioLocalPath: file, createdAt: now, updatedAt: now }) + .run() + return id +} + +describe('파일 전사 원본은 녹음으로 올리지 않는다 (item 19)', () => { + it('파일 전사 기록의 원본 미디어는 업로드 대상이 아니다', async () => { + const fileId = insertHistory('file-transcription', 'meeting-video.mp4') + const dictationId = insertHistory('dictation', 'dictation.wav') + + expect(localAudioPath('history', fileId)).toBeNull() + expect(localAudioPath('history', dictationId)).not.toBeNull() + expect(listLocalAudioOwners('history')).toEqual([dictationId]) + + const result = await engine.runFullSync() + expect(result.errors).toEqual([]) + expect(remote.objects.has(`audio/${USER}/desktop/history/${fileId}.mp4`)).toBe(false) + expect(remote.objects.has(`audio/${USER}/desktop/history/${dictationId}.wav`)).toBe(true) + }) +}) + +describe('대기 중인 삭제는 뒤늦은 upsert 에 덮이지 않는다 (item 37)', () => { + it('delete 뒤에 같은 행의 upsert 가 와도 op 는 delete 로 남는다', () => { + const id = crypto.randomUUID() + enqueueChange('history', id, 'upsert') + enqueueChange('history', id, 'delete') + enqueueChange('history', id, 'upsert') + expect(pendingOps('history').get(id)).toBe('delete') + }) +}) diff --git a/apps/desktop/tests/main/sync/cloud-credentials-redteam-r3-1.test.ts b/apps/desktop/tests/main/sync/cloud-credentials-redteam-r3-1.test.ts new file mode 100644 index 0000000..7493cca --- /dev/null +++ b/apps/desktop/tests/main/sync/cloud-credentials-redteam-r3-1.test.ts @@ -0,0 +1,264 @@ +// tests/main/sync/cloud-credentials-redteam-r3-1.test.ts +// 로그아웃이 오프라인·5xx 로 서버 폐기에 실패해도(auth-js 가 { error } 를 돌려주고 _removeSession 을 건너뜀) +// 자격 증명 포트(Cloud STT·Premium·realtime-token)가 옛 계정 토큰을 더는 내주지 않는지 검증한다. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' + +const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudcred-r3-1-${process.pid}`) +const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token') + +const h = vi.hoisted(() => { + type AuthCallback = (event: string, session: unknown) => void + const state = { + currentUserId: '_local' as string | null, + authCallback: null as AuthCallback | null, + tierGate: null as Promise | null, + } + return { + state, + db: { + openForUser: (userId: string) => { + state.currentUserId = userId + return { created: false, dbPath: `/db/${userId}` } + }, + openLocal: () => { + state.currentUserId = '_local' + return { created: false, dbPath: '/db/_local' } + }, + }, + enqueueChange: (..._args: unknown[]) => undefined, + } +}) + +const spies = vi.hoisted(() => ({ + refreshSession: null as null | ((...args: unknown[]) => Promise), + signOut: null as null | ((...args: unknown[]) => Promise), + clients: [] as Array<{ session: null | { access_token: string; user: { id: string } } }>, +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => path.join(os.tmpdir(), `d3ro-cloudcred-r3-1-${process.pid}`), + getVersion: () => '1.0.0', + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plain: string) => Buffer.from(plain, 'utf-8'), + decryptString: (data: Buffer) => data.toString('utf-8'), + }, +})) + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: () => undefined, + configSet: vi.fn(), + onConfigChanged: () => () => undefined, +})) + +vi.mock('../../../src/main/db', () => ({ + LOCAL_USER_ID: '_local', + openForUser: vi.fn((userId: string) => h.db.openForUser(userId)), + openLocal: vi.fn(() => h.db.openLocal()), + closeCurrent: vi.fn(() => { + h.state.currentUserId = null + }), + getCurrentUserId: () => h.state.currentUserId, + importLocalModeData: vi.fn(() => null), +})) + +vi.mock('../../../src/main/services/sync/sync-outbox', () => ({ + enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)), + getSyncState: () => null, + setSyncState: vi.fn(), +})) + +vi.mock('../../../src/main/services/sync/SyncEngine', () => ({ + SyncEngine: class { + on(): void {} + dispose(): void {} + async whenIdle(): Promise { + return true + } + async runFullSync() { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } + } + async flush() { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } + } + async pull() { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } + } + getStatus() { + return { pending: 0, parked: 0 } + } + }, +})) + +vi.mock('../../../src/main/services/sync/sync-adapters', () => ({ + SyncAbortedError: class SyncAbortedError extends Error {}, +})) +vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} })) +vi.mock('../../../src/main/services/sync/device-registration', () => ({ + checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })), + currentDeviceInfo: () => ({}), + unregisterDesktopDevice: vi.fn(async () => undefined), +})) +vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] })) +vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} })) +vi.mock('../../../src/main/services/sync/settings-sync', () => ({ + SETTINGS_ROW_ID: 'settings', + SYNCED_CONFIG_KEYS: [], + isApplyingRemoteSettings: () => false, +})) +vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] })) +vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) })) +vi.mock('../../../src/main/services/LicenseService', () => ({ + getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }), +})) +vi.mock('../../../src/main/services/VoiceModeService', () => ({ + getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }), +})) +vi.mock('../../../src/main/services/MeetingModeService', () => ({ + getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }), +})) +vi.mock('../../../src/main/services/CaptionService', () => ({ + getCaptionService: () => ({ stop: vi.fn(async () => null) }), +})) + +vi.mock('@supabase/supabase-js', () => { + const query = (): Record => { + const builder: Record = {} + for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder + builder.maybeSingle = async () => { + if (h.state.tierGate) await h.state.tierGate + return { data: null, error: null } + } + return builder + } + const channel = { + on() { + return channel + }, + subscribe() { + return channel + }, + unsubscribe: async () => undefined, + state: 'joined', + } + return { + createClient: () => { + const clientState = { session: null as null | { access_token: string; user: { id: string } } } + spies.clients.push(clientState) + return { + auth: { + refreshSession: async (...args: unknown[]) => { + const result = (await spies.refreshSession!(...args)) as { data: { session: unknown } } + if (result.data.session) clientState.session = result.data.session as typeof clientState.session + return result + }, + signOut: (...args: unknown[]) => spies.signOut!(...args), + onAuthStateChange: (cb: (event: string, session: unknown) => void) => { + h.state.authCallback = cb + return { data: { subscription: { unsubscribe: () => undefined } } } + }, + getSession: async () => ({ data: { session: clientState.session } }), + stopAutoRefresh: vi.fn(async () => undefined), + }, + from: () => query(), + channel: () => channel, + realtime: { setAuth: async () => undefined }, + functions: { + invoke: vi.fn(async () => ({ data: { ok: true }, error: null })), + }, + } + }, + } +}) + +import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService' +import { cloudSyncCredentials, createCloudSttGateway } from '../../../src/main/services/cloud/cloud-credentials' +import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver' + +function session() { + return { access_token: 'at-user-1', refresh_token: 'rt-new', user: { id: 'user-1', email: 'u@example.test' } } +} + +describe('로그아웃 실패(retryable) 뒤 자격 증명 폐기', () => { + beforeEach(() => { + resetCloudSyncServiceForTests() + vi.clearAllMocks() + spies.clients.length = 0 + fs.rmSync(USER_DATA, { recursive: true, force: true }) + fs.mkdirSync(USER_DATA, { recursive: true }) + fs.writeFileSync(TOKEN_FILE, JSON.stringify({ v: 1, rt: 'rt-1', uid: 'user-1' })) + h.state.currentUserId = 'user-1' + h.state.tierGate = null + spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) + // auth-js: 네트워크 실패면 throw 하지 않고 { error } 를 돌려주며 클라이언트 세션을 지우지 않는다 + spies.signOut = vi.fn(async () => ({ + error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' }, + })) + }) + + afterEach(() => { + resetCloudSyncServiceForTests() + fs.rmSync(USER_DATA, { recursive: true, force: true }) + }) + + it('로그인 중에는 토큰과 Edge Function 호출이 가능하다', async () => { + const sync = getCloudSyncService() + await sync.init() + expect(cloudSyncCredentials.hasCredentials()).toBe(true) + await expect(cloudSyncCredentials.accessToken()).resolves.toBe('at-user-1') + const result = await cloudSyncCredentials.invoke('realtime-token', {}) + expect(result.error).toBeNull() + }) + + it('signOut 이 { error } 를 돌려줘도 접근자는 모두 null / no session 이다', async () => { + const sync = getCloudSyncService() + await sync.init() + // 옛 클라이언트는 여전히 세션을 들고 있다(auth-js 동작 재현) + expect(spies.clients[0].session).not.toBeNull() + + await sync.signOut() + + expect(sync.isAuthenticated()).toBe(false) + expect(cloudSyncCredentials.hasCredentials()).toBe(false) + await expect(sync.getAccessToken()).resolves.toBeNull() + await expect(cloudSyncCredentials.accessToken()).resolves.toBeNull() + const invoked = await cloudSyncCredentials.invoke('realtime-token', {}) + expect(invoked.error?.message).toMatch(/session/i) + const streamed = await cloudSyncCredentials.invokeStream('llm-proxy', {}) + expect(streamed.stream).toBeNull() + expect(streamed.error).not.toBeNull() + }) + + it('실패한 signOut 뒤 옛 클라이언트는 자동 갱신을 멈추고 새 클라이언트로 바뀐다', async () => { + const sync = getCloudSyncService() + await sync.init() + await sync.signOut() + expect(spies.clients).toHaveLength(2) + expect(spies.clients[1].session).toBeNull() + }) + + it('Cloud STT 드라이버는 로그아웃 뒤 업로드하지 않는다', async () => { + const sync = getCloudSyncService() + await sync.init() + await sync.signOut() + const fetchSpy = vi.spyOn(globalThis, 'fetch') + const driver = new D3ROCloudDriver({ + ...createCloudSttGateway(), + getSupabaseUrl: () => 'https://example.supabase.co', + getAnonKey: () => 'anon', + }) + await expect(driver.transcribe(Buffer.alloc(3200))).rejects.toThrow() + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) +}) diff --git a/apps/desktop/tests/main/sync/knowledge-reindex-redteam-r3-2.test.ts b/apps/desktop/tests/main/sync/knowledge-reindex-redteam-r3-2.test.ts new file mode 100644 index 0000000..e0db848 --- /dev/null +++ b/apps/desktop/tests/main/sync/knowledge-reindex-redteam-r3-2.test.ts @@ -0,0 +1,159 @@ +// 레드팀 r3-2: 다른 기기에서 재색인한 지식 문서가 이미 문서를 가진 데스크톱에도 반영된다. +// - 원문이 바뀌었으면 청크를 교체하고(옛 임베딩 제거) 다시 색인한다 +// - 원문이 같으면(자기 push의 에코 등) 로컬 청크·임베딩을 건드리지 않는다 +// - 재업로드가 중간이면 잘린 청크로 덮지 않고 미뤘다가 완성본을 반영한다 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eq } from 'drizzle-orm' +import { createTestDb } from '../../helpers/createTestDb' +import { FakeSyncRemote } from '../../helpers/fakeSyncRemote' +import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' +import { ragChunks, ragDocuments } from '../../../src/main/db/schema' +import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { + getCustomInstructionService, + resetCustomInstructionServiceForTests, +} from '../../../src/main/services/CustomInstructionService' +import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService' +import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService' +import { resetRAGServiceForTests } from '../../../src/main/services/RAGService' +import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' +import { planRemoteDocument, sameChunks, usableChunks } from '../../../src/main/services/rag/remote-document' + +const USER = '11111111-1111-4111-8111-111111111111' + +let testDb: ReturnType +let remote: FakeSyncRemote +let engine: SyncEngine + +beforeEach(() => { + testDb = createTestDb() + bindTestDatabase(testDb.db, USER) + initInMemoryConfig() + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + resetRAGServiceForTests() + getCustomInstructionService().initialize() + // 임베딩 서버(Ollama)는 없다 — 원문만 저장되는 경로를 탄다. + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed')) + remote = new FakeSyncRemote(USER) + engine = new SyncEngine({ remote, userId: USER }) +}) + +afterEach(() => { + vi.restoreAllMocks() + engine.dispose() + unbindTestDatabase() + resetInMemoryConfig() + resetRAGServiceForTests() + testDb.close() +}) + +function localChunks(id: string): Array<{ content: string; embedding: string }> { + return testDb.db + .select() + .from(ragChunks) + .where(eq(ragChunks.documentId, id)) + .all() + .sort((a, b) => a.chunkIndex - b.chunkIndex) + .map((c) => ({ content: c.content, embedding: c.embedding })) +} + +function markEmbedded(id: string): void { + testDb.db.update(ragChunks).set({ embedding: '[1,2]' }).where(eq(ragChunks.documentId, id)).run() + testDb.db.update(ragDocuments).set({ indexed: true, indexedAt: 1 }).where(eq(ragDocuments.id, id)).run() +} + +/** 다른 데스크톱(A)의 재색인 push를 흉내: 행 upsert(updated_at 이동) → 청크 전부 교체 */ +function remoteReindex(id: string, chunks: string[], uploaded = chunks.length): void { + remote.mobileUpdate('knowledge_documents', id, { chunk_count: chunks.length }) + const rows = remote.rows('knowledge_chunks') + for (let i = rows.length - 1; i >= 0; i--) if (rows[i].document_id === id) rows.splice(i, 1) + chunks.slice(0, uploaded).forEach((content, chunk_index) => + rows.push({ id: crypto.randomUUID(), document_id: id, chunk_index, content }) + ) +} + +function uploadRest(id: string, chunks: string[], from: number): void { + chunks.slice(from).forEach((content, offset) => + remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: from + offset, content }) + ) +} + +async function seedSyncedDocument(chunks: string[]): Promise { + const id = crypto.randomUUID() + remote.mobileInsert('knowledge_documents', { id, title: 'Doc', file_name: 'doc.md', file_type: 'md', chunk_count: chunks.length }) + chunks.forEach((content, chunk_index) => + remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index, content }) + ) + await engine.runFullSync() + expect(localChunks(id).map((c) => c.content)).toEqual(chunks) + // 받은 직후 시작된 색인 시도(임베딩 서버 없음)가 끝난 뒤에 "이미 색인됨" 상태를 만든다 + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)) + markEmbedded(id) + return id +} + +describe('재색인된 지식 문서 동기화', () => { + it('다른 기기에서 원문이 바뀌면 이미 있는 문서의 청크를 교체하고 옛 임베딩을 버린다', async () => { + const id = await seedSyncedDocument(['old intro', 'old body']) + + remoteReindex(id, ['new intro', 'new body', 'new appendix']) + await engine.pull() + + const chunks = localChunks(id) + expect(chunks.map((c) => c.content)).toEqual(['new intro', 'new body', 'new appendix']) + // 옛 벡터가 새 원문에 붙어 검색되지 않는다(임베딩 서버가 없어 아직 비어 있다) + expect(chunks.every((c) => c.embedding === '')).toBe(true) + const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get() + expect(doc?.chunkCount).toBe(3) + expect(doc?.indexed).toBe(false) + }) + + it('원문이 같으면(에코·메타데이터만 변경) 로컬 청크와 임베딩을 그대로 둔다', async () => { + const id = await seedSyncedDocument(['same a', 'same b']) + + remote.mobileUpdate('knowledge_documents', id, { indexed: true }) + await engine.pull() + + expect(localChunks(id)).toEqual([ + { content: 'same a', embedding: '[1,2]' }, + { content: 'same b', embedding: '[1,2]' }, + ]) + expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()?.indexed).toBe(true) + }) + + it('재업로드가 중간이면 잘린 청크로 덮지 않고, 완성되면 반영한다', async () => { + const id = await seedSyncedDocument(['v1 a', 'v1 b']) + const next = ['v2 a', 'v2 b', 'v2 c'] + + remoteReindex(id, next, 1) + await engine.pull() + expect(localChunks(id).map((c) => c.content)).toEqual(['v1 a', 'v1 b']) + + uploadRest(id, next, 1) + await engine.pull() + expect(localChunks(id).map((c) => c.content)).toEqual(next) + }) +}) + +describe('planRemoteDocument', () => { + it('로컬에 없으면 insert, 같으면 skip, 다르면 replace', () => { + expect(planRemoteDocument(null, ['a', ' ', 'b'])).toEqual({ kind: 'insert', chunks: ['a', 'b'] }) + expect(planRemoteDocument(['a', 'b'], ['a', 'b'])).toEqual({ kind: 'skip' }) + expect(planRemoteDocument(['a', 'b'], ['a', 'c'])).toEqual({ kind: 'replace', chunks: ['a', 'c'] }) + expect(planRemoteDocument(['a'], ['a', 'b'])).toEqual({ kind: 'replace', chunks: ['a', 'b'] }) + }) + + it('받은 청크가 전부 비어 있으면 로컬을 비우지 않는다', () => { + expect(planRemoteDocument(['a'], ['', ' '])).toEqual({ kind: 'skip' }) + expect(planRemoteDocument(null, [])).toEqual({ kind: 'skip' }) + }) + + it('빈 청크는 비교에서 빠진다', () => { + expect(usableChunks(['a', '', ' b '])).toEqual(['a', ' b ']) + expect(planRemoteDocument(['a', ''], ['a'])).toEqual({ kind: 'skip' }) + expect(sameChunks(['a'], ['a', 'b'])).toBe(false) + }) +}) diff --git a/apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts b/apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts new file mode 100644 index 0000000..4828170 --- /dev/null +++ b/apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts @@ -0,0 +1,269 @@ +// 레드팀 r3-0: push 전 tombstone 읽기 실패 시 upsert 보류(fail closed), +// 서버에 없는 활성 명령이 설정 항목을 끝없는 재시도에 묶어 설정 pull을 막던 문제. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eq } from 'drizzle-orm' +import { createTestDb } from '../../helpers/createTestDb' +import { FakeSyncRemote } from '../../helpers/fakeSyncRemote' +import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' +import { history } from '../../../src/main/db/schema' +import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { + getCustomInstructionService, + resetCustomInstructionServiceForTests, +} from '../../../src/main/services/CustomInstructionService' +import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService' +import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService' +import { resetRAGServiceForTests } from '../../../src/main/services/RAGService' +import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' +import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox' +import { + SyncRemoteError, + type RemotePageRequest, + type RemoteRow, +} from '../../../src/main/services/sync/sync-types' +import { pushableEntries } from '../../../src/main/services/sync/push-gate-policy' +import { + isActiveInstructionMissing, + planActiveInstructionStep, + shouldResyncSettingsAfterInstructionPush, +} from '../../../src/main/services/sync/active-instruction-push-policy' +import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port' + +const USER = '11111111-1111-4111-8111-111111111111' +const U = '22222222-2222-4222-8222-222222222222' + +/** + * 운영 서버처럼 INSERT 때 updated_at을 서버 시각으로 새로 찍고(0037 stamp_sync_timestamp_v1), + * sync_tombstones 읽기를 지정한 횟수만큼 일시 오류로 실패시키는 원격. + */ +class ServerStampingRemote extends FakeSyncRemote { + failTombstoneReads = 0 + + override async fetchPage(request: RemotePageRequest): Promise { + if (request.table === 'sync_tombstones' && this.failTombstoneReads > 0) { + this.failTombstoneReads-- + throw new SyncRemoteError('canceling statement due to statement timeout', '57014', true) + } + return super.fetchPage(request) + } + + override async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise { + const existing = new Set(this.rows(table).map((r) => String(r.id))) + await super.upsert(table, rows, onConflict) + for (const row of this.rows(table)) { + if (!existing.has(String(row.id))) row.updated_at = this.now() + } + } +} + +const offlineEmbedder: EmbeddingPort = { + model: 'test-embed', + ensureModel: () => Promise.reject(new Error('no embedding server in tests')), + embed: () => Promise.reject(new Error('no embedding server in tests')), +} + +let testDb: ReturnType +let remote: ServerStampingRemote +let engine: SyncEngine +let clock: number + +beforeEach(() => { + testDb = createTestDb() + bindTestDatabase(testDb.db, USER) + initInMemoryConfig() + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 }) + getCustomInstructionService().initialize() + remote = new ServerStampingRemote(USER) + clock = Date.parse('2026-09-28T00:00:00.000Z') + engine = new SyncEngine({ remote, userId: USER, now: () => clock }) +}) + +afterEach(() => { + vi.restoreAllMocks() + engine.dispose() + resetRAGServiceForTests() + unbindTestDatabase() + resetInMemoryConfig() + testDb.close() +}) + +function historyRow(id: string, text: string): Record { + return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' } +} + +function localHistoryIds(): string[] { + return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort() +} + +describe('push 전 tombstone 읽기 실패 → upsert 보류(fail closed)', () => { + it('폰에서 지운 행의 대기 upsert가 서버에 행을 되살리지 않고, 다음 pull에서 삭제가 반영된다', async () => { + const x = crypto.randomUUID() + remote.mobileInsert('history', historyRow(x, 'will be deleted on phone')) + await engine.runFullSync() + expect(localHistoryIds()).toEqual([x]) + + // 데스크톱의 대기 편집(즐겨찾기 등) + 폰의 삭제(T1) + enqueueChange('history', x, 'upsert', clock) + remote.mobileDelete('history', x) + + remote.failTombstoneReads = 1 + const flushed = await engine.flush() + expect(flushed.errors.some((e) => e.startsWith('tombstones:'))).toBe(true) + expect(flushed.pushed).toBe(0) + expect(remote.find('history', x)).toBeUndefined() + expect(pendingOps('history').get(x)).toBe('upsert') + + await engine.pull() + expect(localHistoryIds()).toEqual([]) + expect(pendingOps('history').has(x)).toBe(false) + expect(remote.find('history', x)).toBeUndefined() + }) + + it('tombstone 읽기가 실패해도 삭제는 보낸다', async () => { + const x = crypto.randomUUID() + const y = crypto.randomUUID() + remote.mobileInsert('history', historyRow(x, 'edit me')) + remote.mobileInsert('history', historyRow(y, 'delete me')) + await engine.runFullSync() + + enqueueChange('history', x, 'upsert', clock) + testDb.db.delete(history).where(eq(history.id, y)).run() + enqueueChange('history', y, 'delete', clock) + remote.failTombstoneReads = 1 + const flushed = await engine.flush() + + expect(remote.find('history', y)).toBeUndefined() + expect(pendingOps('history').has(y)).toBe(false) + expect(pendingOps('history').get(x)).toBe('upsert') + expect(flushed.pushed).toBe(1) + }) + + it('runFullSync에서 tombstone 읽기가 실패하면 최초 대조·upsert를 미루고 다음 flush에서 이어 간다', async () => { + const local = crypto.randomUUID() + const at = clock + testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run() + + remote.failTombstoneReads = 1 + await engine.runFullSync() + expect(remote.find('history', local)).toBeUndefined() + + await engine.flush() + expect(remote.find('history', local)?.original_text).toBe('offline note') + }) +}) + +describe('서버에 없는 활성 명령과 설정 동기화', () => { + function rejectInstructionUploads(error: SyncRemoteError | null): void { + remote.rejectRow = (table) => (table === 'custom_instructions' ? error : null) + } + + function seedRemoteSettings(): void { + remote.mobileInsert('user_settings', { + locale: 'ko', + theme_mode: 'system', + auto_polish_enabled: true, + revision: 1, + active_instruction_id: null, + }) + } + + it('올라가지 못한(보관된) 명령이 활성이어도 설정 항목은 완료되고, 폰의 설정 변경을 계속 받는다', async () => { + seedRemoteSettings() + const command = getCustomInstructionService().create({ name: 'Local only', description: '', prompt: 'Keep it local' }) + rejectInstructionUploads(new SyncRemoteError('value too long for type character varying(4000)', '22001', false)) + await engine.runFullSync() + expect(pendingOps('custom_instructions').get(command.id)).toBe('upsert') + + // 서버에 없는 명령을 활성으로 고르고 테마를 바꾼다(CloudSyncService가 설정 항목을 넣는다) + configSet('activeInstructionId', command.id) + configSet('theme', 'dark') + enqueueChange('user_settings', 'self', 'upsert', clock) + const flushed = await engine.flush() + expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([]) + expect(pendingOps('user_settings').has('self')).toBe(false) + expect(remote.rows('user_settings')[0].theme_mode).toBe('dark') + expect(configGet('activeInstructionId')).toBe(command.id) + expect(remote.calls).not.toContain('rpc:set_active_custom_instruction') + + const settings = remote.rows('user_settings')[0] + Object.assign(settings, { theme_mode: 'light', revision: Number(settings.revision) + 1, updated_at: remote.now() }) + await engine.pull() + expect(configGet('theme')).toBe('light') + }) + + it('outbox에 없는데 서버가 모르는 명령(P0002)은 설정 항목을 재시도에 묶지 않는다', async () => { + seedRemoteSettings() + const command = getCustomInstructionService().create({ name: 'Gone', description: '', prompt: 'Vanished on server' }) + await engine.runFullSync() + // 서버에서 tombstone 없이 사라진 경우(보존 기간 정리 등) + const rows = remote.rows('custom_instructions') + rows.splice(rows.findIndex((r) => r.id === command.id), 1) + + configSet('activeInstructionId', command.id) + enqueueChange('user_settings', 'self', 'upsert', clock) + const flushed = await engine.flush() + expect(remote.calls).toContain('rpc:set_active_custom_instruction') + expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([]) + expect(pendingOps('user_settings').has('self')).toBe(false) + }) + + it('미룬 활성 명령이 나중에 서버에 올라가면 설정을 다시 올려 서버 활성 명령을 맞춘다', async () => { + seedRemoteSettings() + const command = getCustomInstructionService().create({ name: 'Late', description: '', prompt: 'Arrives later' }) + rejectInstructionUploads(new SyncRemoteError('canceling statement due to statement timeout', '57014', true)) + await engine.runFullSync() + configSet('activeInstructionId', command.id) + enqueueChange('user_settings', 'self', 'upsert', clock) + await engine.flush() + expect(pendingOps('user_settings').has('self')).toBe(false) + expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull() + expect(remote.calls).not.toContain('rpc:set_active_custom_instruction') + + rejectInstructionUploads(null) + clock += 60 * 60_000 + await engine.flush() + expect(remote.find('custom_instructions', command.id)).toBeDefined() + expect(pendingOps('user_settings').has('self')).toBe(true) + + await engine.flush() + expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id) + expect(pendingOps('user_settings').has('self')).toBe(false) + }) +}) + +describe('push-gate-policy', () => { + const entries = [ + { op: 'upsert' as const, rowId: 'a' }, + { op: 'delete' as const, rowId: 'b' }, + ] + + it('tombstone을 반영했으면 전부, 실패했으면 삭제만 push한다', () => { + expect(pushableEntries(entries, { tombstonesSynced: true })).toEqual(entries) + expect(pushableEntries(entries, { tombstonesSynced: false })).toEqual([{ op: 'delete', rowId: 'b' }]) + }) +}) + +describe('active-instruction-push-policy', () => { + it('서버에 아직 없는 명령은 미루고, 나머지는 RPC로 보낸다', () => { + expect(planActiveInstructionStep(null, new Set([U]))).toEqual({ kind: 'none' }) + expect(planActiveInstructionStep({ instructionId: U }, new Set([U]))).toEqual({ kind: 'defer', instructionId: U }) + expect(planActiveInstructionStep({ instructionId: U }, new Set())).toEqual({ kind: 'rpc', instructionId: U }) + expect(planActiveInstructionStep({ instructionId: null }, new Set([U]))).toEqual({ kind: 'rpc', instructionId: null }) + }) + + it('P0002만 "서버에 명령 없음"으로 본다', () => { + expect(isActiveInstructionMissing(new SyncRemoteError('instruction_not_found', 'P0002', true))).toBe(true) + expect(isActiveInstructionMissing(new SyncRemoteError('fetch failed', 'network', true))).toBe(false) + }) + + it('미룬 명령이 올라갔을 때만 설정을 다시 올린다', () => { + expect(shouldResyncSettingsAfterInstructionPush(U, [U])).toBe(true) + expect(shouldResyncSettingsAfterInstructionPush(U, ['other'])).toBe(false) + expect(shouldResyncSettingsAfterInstructionPush('', [U])).toBe(false) + expect(shouldResyncSettingsAfterInstructionPush(null, [U])).toBe(false) + }) +}) diff --git a/apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts b/apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts new file mode 100644 index 0000000..7607819 --- /dev/null +++ b/apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts @@ -0,0 +1,83 @@ +// tests/main/update-policy-redteam-r3-5.test.ts +// 원격 정책의 안전 필드(killSwitch, stagingPercentage)가 형식이 틀리면 가장 허용적인 값이 +// 아니라 가장 보수적인 값으로 읽히는지 확인한다. +// +// 회귀: `"killSwitch": "true"` 가 false 로, `"stagingPercentage": "5"` / 5.5 가 100% 로 +// 조용히 바뀌어 운영자 의도와 반대로 전체 배포되던 문제. + +import { describe, it, expect } from 'vitest' +import { + DEFAULT_UPDATE_POLICY, + evaluateUpdateOffer, + parseUpdatePolicy, +} from '../../src/main/update-policy' + +const BASE = { + schemaVersion: 1, + defaultChannel: 'latest', + channels: { + latest: { allowPrerelease: false }, + beta: { allowPrerelease: true }, + alpha: { allowPrerelease: true }, + }, + minimumSupportedVersion: '1.0.0', + forceInstallBelow: null, + fullInstallOnMajorChange: true, + fullInstallVersionGap: 3, + stagingPercentage: 100, + killSwitch: false, +} + +describe('parseUpdatePolicy — 안전 필드 fail-closed', () => { + it.each(['true', 'false', 1, 0, null, {}])('killSwitch=%j 는 켜진 것으로 본다', (value) => { + expect(parseUpdatePolicy({ ...BASE, killSwitch: value }).killSwitch).toBe(true) + }) + + it.each([5.5, '5', null, Number.NaN, '100'])('stagingPercentage=%j 는 0% 로 본다', (value) => { + expect(parseUpdatePolicy({ ...BASE, stagingPercentage: value }).stagingPercentage).toBe(0) + }) + + it('형식이 틀린 킬 스위치는 업데이트 제안을 막는다', () => { + const policy = parseUpdatePolicy({ ...BASE, killSwitch: 'true' }) + const offer = evaluateUpdateOffer({ + policy, + channel: 'latest', + currentVersion: '1.8.0', + targetVersion: '1.9.0', + skippedVersion: null, + deviceId: 'device-a', + }) + expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' }) + }) + + it('형식이 틀린 staging 은 필수 업데이트가 아니면 아무에게도 노출하지 않는다', () => { + const policy = parseUpdatePolicy({ ...BASE, stagingPercentage: '5' }) + for (const deviceId of ['a', 'b', 'c', 'd', 'e', 'f']) { + const offer = evaluateUpdateOffer({ + policy, + channel: 'latest', + currentVersion: '1.8.0', + targetVersion: '1.9.0', + skippedVersion: null, + deviceId, + }) + expect(offer).toMatchObject({ action: 'ignore', reason: 'rollout' }) + } + }) + + it('필드가 아예 없으면 기존처럼 기본값을 쓴다', () => { + const { killSwitch: _k, stagingPercentage: _s, ...rest } = BASE + const parsed = parseUpdatePolicy(rest) + expect(parsed.killSwitch).toBe(DEFAULT_UPDATE_POLICY.killSwitch) + expect(parsed.stagingPercentage).toBe(DEFAULT_UPDATE_POLICY.stagingPercentage) + }) + + it('올바른 값은 그대로(범위 밖 정수는 기존처럼 잘라서) 읽는다', () => { + expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 5, killSwitch: false })).toMatchObject({ + stagingPercentage: 5, + killSwitch: false, + }) + expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 250 }).stagingPercentage).toBe(100) + expect(parseUpdatePolicy({ ...BASE, stagingPercentage: -3 }).stagingPercentage).toBe(0) + }) +}) diff --git a/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts b/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts index b57d657..545c7d1 100644 --- a/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts +++ b/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts @@ -83,6 +83,7 @@ import { getMeetingModeService } from '../../src/main/services/MeetingModeServic import { configSet } from '../../src/main/services/ConfigService' import { getDatabase } from '../../src/main/db' import { meetingSessions } from '../../src/main/db/schema' +import { syncStateRecordingLedger } from '../../src/main/services/meeting/local-recording-ledger' function row(id: string) { return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get() @@ -163,7 +164,10 @@ describe('앱 종료·중단 복구', () => { it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => { insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' }) + // 전사가 없는 행은 이 기기에서 시작한 녹음(원장 표식)일 때만 닫는다 — redteam r3: 폰의 진행 중 회의 보호 insertSession('stuck-empty', { status: 'processing' }) + syncStateRecordingLedger.mark('stuck-empty') + insertSession('phone-live', { status: 'recording' }) insertSession('done', { status: 'completed', rawTranscript: 'x' }) expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2) @@ -171,6 +175,8 @@ describe('앱 종료·중단 복구', () => { expect(row('stuck-with-text')?.status).toBe('completed') expect(row('stuck-with-text')?.endedAt).not.toBeNull() expect(row('stuck-empty')?.status).toBe('error') + // 표식도 전사도 없는 행은 다른 기기(폰)의 진행 중 회의일 수 있어 그대로 둔다 + expect(row('phone-live')?.status).toBe('recording') expect(row('done')?.status).toBe('completed') }) }) diff --git a/apps/desktop/tests/red/templates-field-ids-redteam-r3-7.test.ts b/apps/desktop/tests/red/templates-field-ids-redteam-r3-7.test.ts new file mode 100644 index 0000000..cbc9c9b --- /dev/null +++ b/apps/desktop/tests/red/templates-field-ids-redteam-r3-7.test.ts @@ -0,0 +1,90 @@ +// 받아쓰기 템플릿 필드 id 회귀 테스트 (redteam r3-7) +// 버그: 편집기가 필드 개수로 id를 만들고(중간 삭제 후 재사용) 이름을 그대로 id로 써서 +// 빈 id·중복 id 템플릿이 저장됐다. 세션에서 같은 id 필드의 받아쓴 값이 앞 값을 덮어써 +// 출력({{id}})에서 앞 값이 조용히 사라졌다. 이제 서비스가 저장 전에 TemplateInvalidFormat 으로 거부한다. + +import { describe, expect, it } from 'vitest' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import type { TemplateField } from '@d3ro/core/types' +import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService' +import { useRedHarness } from './harness' + +useRedHarness() + +function field(id: string): TemplateField { + return { id, name: id, label: id, promptText: '', required: true, maxDurationSec: 30 } +} + +function expectInvalidFormat(run: () => unknown): void { + let caught: unknown + try { + run() + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(D3ROError) + expect((caught as D3ROError).code).toBe(ErrorCode.TemplateInvalidFormat) +} + +describe('받아쓰기 템플릿 필드 id 검증', () => { + it('중복 필드 id 템플릿 생성을 거부하고 저장하지 않는다', () => { + const svc = getDictationTemplateService() + const before = svc.getAll().length + // 편집기 시나리오: [field1, field2, field3] → field1 삭제 → 추가 → [field2, field3, field3] + expectInvalidFormat(() => + svc.create({ + name: 'dup', + description: '', + fields: [field('field2'), field('field3'), field('field3')], + outputFormat: '{{field2}} {{field3}}', + }), + ) + expect(svc.getAll()).toHaveLength(before) + }) + + it('빈/공백 필드 id 템플릿 생성을 거부한다', () => { + const svc = getDictationTemplateService() + expectInvalidFormat(() => + svc.create({ name: 'empty', description: '', fields: [field('a'), field('')], outputFormat: '{{a}}' }), + ) + expectInvalidFormat(() => + svc.create({ name: 'blank', description: '', fields: [field(' ')], outputFormat: '' }), + ) + }) + + it('수정으로 중복 필드 id를 넣으면 거부하고 기존 필드를 유지한다', () => { + const svc = getDictationTemplateService() + const created = svc.create({ + name: 'ok', + description: '', + fields: [field('a'), field('b')], + outputFormat: '{{a}} {{b}}', + }) + expectInvalidFormat(() => svc.update({ id: created.id, fields: [field('a'), field('a')] })) + expect(svc.getById(created.id)?.fields.map((f) => f.id)).toEqual(['a', 'b']) + }) + + it('필드를 바꾸지 않는 수정과 유효한 필드 수정은 그대로 된다', () => { + const svc = getDictationTemplateService() + const created = svc.create({ name: 'ok', description: '', fields: [field('a')], outputFormat: '{{a}}' }) + expect(svc.update({ id: created.id, name: 'renamed' }).name).toBe('renamed') + expect(svc.update({ id: created.id, fields: [field('a'), field('b')] }).fields).toHaveLength(2) + }) + + it('유효한 템플릿은 필드마다 받아쓴 값이 모두 출력에 남는다', () => { + const svc = getDictationTemplateService() + const created = svc.create({ + name: 'flow', + description: '', + fields: [field('field2'), field('field3'), field('field4')], + outputFormat: '{{field2}}|{{field3}}|{{field4}}', + }) + const outputs: string[] = [] + svc.on('session-completed', (e: { outputText: string }) => outputs.push(e.outputText)) + svc.startSession(created.id) + svc.consumeDictatedText('one') + svc.consumeDictatedText('two') + svc.consumeDictatedText('three') + expect(outputs).toEqual(['one|two|three']) + }) +}) diff --git a/apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts b/apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts new file mode 100644 index 0000000..c21d555 --- /dev/null +++ b/apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { ErrorCode } from '@d3ro/core/errors' +import { + normalizeErrorCode, + offersForceRestart, + presentError, + settingsTabOf, +} from '../../src/renderer/navigation/error-recovery' +import { buildMeetingErrorInfo, forceRestartLabel } from '../../src/renderer/pages/meeting/meeting-error' +import { canDismissOnboarding } from '../../src/renderer/components/onboarding-dismiss' + +// 회귀: +// 1) voice:error 101(STT 모델 없음)이 닫을 수 없는 온보딩 모달을 열어 메인 창을 가뒀다. +// 2) 회의 후처리 실패(882)가 '회의를 시작할 수 없습니다'로 보이고, 주 버튼이 새 녹음을 강제로 시작했다. +// 3) 회의 오류 분류가 ErrorCode 와 맞지 않았다(1400=충돌, 200-220=마이크). + +describe('presentError — voice', () => { + it('101 은 온보딩이 아니라 설정 → STT 탭 딥링크로 복구한다', () => { + const p = presentError({ context: 'voice', code: ErrorCode.STTModelNotFound }) + expect(p.category).toBe('sttModelMissing') + expect(p.messageKey).toBe('voice.error.modelMissing') + expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }]) + expect(settingsTabOf(p)).toBe('stt') + }) + + it.each([103, 130, 131, 132])('%i 은 엔진 오류 문구를 유지하고 복구 동작은 없다', (code) => { + const p = presentError({ context: 'voice', code }) + expect(p.category).toBe('sttEngine') + expect(p.messageKey).toBe('voice.error.engine') + expect(p.recoveries).toEqual([]) + }) + + it('300 경고는 llmSkipped 문구, 300 오류는 원문 그대로', () => { + expect(presentError({ context: 'voice', code: 300, severity: 'warning' }).messageKey).toBe('voice.warning.llmSkipped') + const asError = presentError({ context: 'voice', code: 300, severity: 'error' }) + expect(asError.category).toBe('general') + expect(asError.messageKey).toBeNull() + }) + + it('모르는 코드는 원문을 쓰고 설정 딥링크가 없다', () => { + const p = presentError({ context: 'voice', code: 999 }) + expect(p.messageKey).toBeNull() + expect(settingsTabOf(p)).toBeNull() + }) +}) + +describe('presentError — meetingProcessing', () => { + it('882 는 후처리 실패이고 강제 재시작(새 녹음)을 절대 제시하지 않는다', () => { + const p = presentError({ context: 'meetingProcessing', code: ErrorCode.MeetingProcessingFailed, message: '후처리 실패: SQLITE_FULL' }) + expect(p.category).toBe('processing') + expect(offersForceRestart(p)).toBe(false) + }) + + it('후처리 문구에 model/already 같은 키워드가 있어도 processing 이다', () => { + for (const message of ['후처리 실패: model busy', '후처리 실패: already locked', '후처리 실패: audio']) { + const p = presentError({ context: 'meetingProcessing', code: 882, message }) + expect(p.category).toBe('processing') + expect(offersForceRestart(p)).toBe(false) + } + }) +}) + +describe('presentError — meetingStart', () => { + it.each([ErrorCode.MeetingAlreadyRecording, ErrorCode.CaptionAlreadyActive])('%i 는 충돌 → 강제 재시작', (code) => { + const p = presentError({ context: 'meetingStart', code, message: 'x' }) + expect(p.category).toBe('collision') + expect(p.recoveries).toEqual([{ kind: 'forceRestart' }]) + }) + + it('1400 은 ErrorCode 가 아니므로 충돌로 보지 않는다', () => { + expect(presentError({ context: 'meetingStart', code: 1400, message: 'x' }).category).toBe('general') + }) + + it.each([101, 103, 110, 130, 132, 140])('STT 코드 %i 는 STT 설정 + 재시도', (code) => { + const p = presentError({ context: 'meetingStart', code, message: 'x' }) + expect(p.category).toBe('stt') + expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }, { kind: 'forceRestart' }]) + }) + + it.each([400, 401, 410, 420, 431])('오디오 코드 %i 는 오디오 설정 + 재시도', (code) => { + const p = presentError({ context: 'meetingStart', code, message: 'x' }) + expect(p.category).toBe('audio') + expect(settingsTabOf(p)).toBe('audio') + }) + + it.each([200, 210, 220])('TTS 코드 %i 는 마이크 오류가 아니다', (code) => { + const p = presentError({ context: 'meetingStart', code, message: 'x' }) + expect(p.category).toBe('general') + expect(settingsTabOf(p)).toBeNull() + }) + + it('시작 경로에 882 가 와도 강제 재시작을 주지 않는다', () => { + expect(offersForceRestart(presentError({ context: 'meetingStart', code: 882 }))).toBe(false) + }) + + it('코드가 없는 catch 경로는 문구 키워드로 폴백한다', () => { + expect(presentError({ context: 'meetingStart', message: 'Caption already active' }).category).toBe('collision') + expect(presentError({ context: 'meetingStart', message: 'whisper sidecar died' }).category).toBe('stt') + expect(presentError({ context: 'meetingStart', message: 'mic not found' }).category).toBe('audio') + expect(presentError({ context: 'meetingStart', message: 'ipc timeout' }).category).toBe('general') + }) + + it('숫자 문자열 코드도 정규화한다', () => { + expect(normalizeErrorCode('880')).toBe(880) + expect(normalizeErrorCode('STT_UNAVAILABLE')).toBeNull() + expect(normalizeErrorCode(undefined)).toBeNull() + expect(presentError({ context: 'meetingStart', code: '880' }).category).toBe('collision') + }) +}) + +describe('buildMeetingErrorInfo', () => { + it('후처리 실패는 시작 실패 제목이 아니고 복구 버튼이 없다', () => { + const info = buildMeetingErrorInfo('meetingProcessing', '후처리 실패: disk full', 882) + expect(info.title).not.toBe('회의를 시작할 수 없습니다') + expect(info.category).toBe('processing') + expect(info.recoveries).toEqual([]) + expect(info.code).toBe(882) + expect(info.message).toBe('후처리 실패: disk full') + }) + + it('충돌 라벨과 일반 라벨이 다르다', () => { + expect(forceRestartLabel('collision')).not.toBe(forceRestartLabel('general')) + }) + + it('코드·문구가 없으면 카테고리 기본값을 쓴다', () => { + const info = buildMeetingErrorInfo('meetingStart') + expect(info.category).toBe('general') + expect(info.code).toBe('MEETING_START_ERROR') + expect(info.message.length).toBeGreaterThan(0) + }) +}) + +describe('canDismissOnboarding', () => { + it('첫 실행(미완료)은 success 전까지 닫을 수 없다', () => { + expect(canDismissOnboarding('select_mode', false)).toBe(false) + expect(canDismissOnboarding('local_ollama_setup', false)).toBe(false) + expect(canDismissOnboarding('success', false)).toBe(true) + }) + + it('이미 온보딩을 마친 사용자는 어느 단계에서든 닫을 수 있다', () => { + for (const phase of ['select_mode', 'local_ollama_setup', 'online_auth', 'failed'] as const) { + expect(canDismissOnboarding(phase, true)).toBe(true) + } + }) +}) diff --git a/apps/desktop/tests/unit/keybinding-picker-capture-redteam-r3-23.test.ts b/apps/desktop/tests/unit/keybinding-picker-capture-redteam-r3-23.test.ts new file mode 100644 index 0000000..c1618b3 --- /dev/null +++ b/apps/desktop/tests/unit/keybinding-picker-capture-redteam-r3-23.test.ts @@ -0,0 +1,147 @@ +import fs from 'fs' +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MouseButton, VK } from '@d3ro/core/keybinding' + +type EffectFn = () => void | (() => void) +const effects: EffectFn[] = [] + +vi.mock('react', () => ({ + useEffect: (fn: EffectFn) => { + effects.push(fn) + }, +})) + +const { + KeyBindingCaptureSession, + resolveCapturePort, + useKeyBindingCaptureSuspension, +} = await import('../../src/renderer/components/keybinding/keybinding-capture') +const { BROWSER_BUTTON_TO_MOUSE, NO_MODIFIERS, buildBinding, resolveKeyCode } = await import( + '../../src/renderer/components/keybinding/keybinding-recording' +) + +const PICKER = path.resolve( + __dirname, + '../../src/renderer/components/keybinding/KeyBindingPicker.tsx' +) + +function fakePort() { + const calls: string[] = [] + return { + calls, + port: { + beginCapture: vi.fn(async () => { + calls.push('begin') + }), + endCapture: vi.fn(async () => { + calls.push('end') + }), + }, + } +} + +describe('KeyBindingPicker — 녹화 중 전역 트리거 중단 (r3-23)', () => { + beforeEach(() => { + effects.length = 0 + }) + + it('피커가 열려 있는 동안 캡처 억제 훅을 open 으로 건다', () => { + const source = fs.readFileSync(PICKER, 'utf8') + expect(source).toContain('useKeyBindingCaptureSuspension(open)') + // 전역 on/off 는 hotkeyEnabled 를 저장하므로 캡처에 쓰면 안 된다. + expect(source).not.toMatch(/keybinding\s*\.\s*setEnabled/) + }) + + it('열리면 beginCapture, 닫히거나 언마운트되면 endCapture 를 한 번씩 보낸다', () => { + const { port, calls } = fakePort() + useKeyBindingCaptureSuspension(true, () => port) + expect(effects).toHaveLength(1) + const cleanup = effects[0]() + expect(calls).toEqual(['begin']) + expect(typeof cleanup).toBe('function') + if (typeof cleanup === 'function') cleanup() + expect(calls).toEqual(['begin', 'end']) + }) + + it('닫혀 있으면 아무것도 보내지 않는다', () => { + const { port, calls } = fakePort() + useKeyBindingCaptureSuspension(false, () => port) + const cleanup = effects[0]() + expect(cleanup).toBeUndefined() + expect(calls).toEqual([]) + }) + + it('세션은 begin/end 를 중복 전송하지 않고 begin 없이 end 를 보내지 않는다', () => { + const { port, calls } = fakePort() + const session = new KeyBindingCaptureSession(port) + session.end() + session.begin() + session.begin() + expect(session.isActive).toBe(true) + session.end() + session.end() + expect(session.isActive).toBe(false) + expect(calls).toEqual(['begin', 'end']) + }) + + it('IPC 실패는 녹화를 막지 않는다', async () => { + const session = new KeyBindingCaptureSession({ + beginCapture: () => Promise.reject(new Error('no handler')), + endCapture: () => Promise.reject(new Error('no handler')), + }) + expect(() => { + session.begin() + session.end() + }).not.toThrow() + await Promise.resolve() + }) + + it('포트가 없으면(null) 조용히 아무것도 하지 않는다', () => { + const session = new KeyBindingCaptureSession(null) + session.begin() + expect(session.isActive).toBe(false) + }) + + it('preload API 구조로 포트를 판별한다', () => { + expect(resolveCapturePort(undefined)).toBeNull() + expect(resolveCapturePort({ setEnabled: () => undefined })).toBeNull() + expect(resolveCapturePort({ beginCapture: () => undefined })).toBeNull() + + const begin = vi.fn(() => Promise.resolve({ success: true })) + const end = vi.fn(() => Promise.resolve({ success: true })) + const port = resolveCapturePort({ beginCapture: begin, endCapture: end }) + expect(port).not.toBeNull() + void port?.beginCapture() + void port?.endCapture() + expect(begin).toHaveBeenCalledTimes(1) + expect(end).toHaveBeenCalledTimes(1) + }) +}) + +describe('keybinding-recording — 추출된 녹화 규칙 (동작 보존)', () => { + it('좌/우 수정자를 location 으로 구분한다', () => { + expect(resolveKeyCode({ keyCode: 17, which: 17, location: 1 })).toBe(VK.CtrlLeft) + expect(resolveKeyCode({ keyCode: 17, which: 17, location: 2 })).toBe(VK.CtrlRight) + expect(resolveKeyCode({ keyCode: 16, which: 16, location: 2 })).toBe(VK.ShiftRight) + expect(resolveKeyCode({ keyCode: 18, which: 18, location: 1 })).toBe(VK.AltLeft) + expect(resolveKeyCode({ keyCode: 93, which: 93, location: 2 })).toBe(VK.MetaRight) + expect(resolveKeyCode({ keyCode: 93, which: 93, location: 0 })).toBe(93) + expect(resolveKeyCode({ keyCode: 0, which: 65, location: 0 })).toBe(65) + }) + + it('주 키는 비수정자 키, 없으면 마지막 수정자', () => { + expect(buildBinding([], NO_MODIFIERS)).toBeNull() + const combo = buildBinding([VK.CtrlLeft, 65], { ...NO_MODIFIERS, ctrl: true }) + expect(combo).toMatchObject({ device: 'keyboard', code: 65, ctrl: true }) + const mods = buildBinding([VK.CtrlLeft, VK.AltRight], { ...NO_MODIFIERS, ctrl: true }) + expect(mods?.code).toBe(VK.AltRight) + }) + + it('브라우저 마우스 버튼을 uiohook 코드로 옮긴다', () => { + expect(BROWSER_BUTTON_TO_MOUSE[1]).toBe(MouseButton.Middle) + expect(BROWSER_BUTTON_TO_MOUSE[2]).toBe(MouseButton.Right) + expect(BROWSER_BUTTON_TO_MOUSE[3]).toBe(MouseButton.Back) + expect(BROWSER_BUTTON_TO_MOUSE[4]).toBe(MouseButton.Forward) + }) +}) diff --git a/apps/desktop/tests/unit/stt-model-download-redteam-r3-24.test.ts b/apps/desktop/tests/unit/stt-model-download-redteam-r3-24.test.ts new file mode 100644 index 0000000..15dbd8c --- /dev/null +++ b/apps/desktop/tests/unit/stt-model-download-redteam-r3-24.test.ts @@ -0,0 +1,129 @@ +// tests/unit/stt-model-download-redteam-r3-24.test.ts +// STT 탭 모델 다운로드가 실패/취소로 끝나도 '다운로드 중 (x%)' 에 멈추지 않는지 회귀 검증. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { ErrorCode, ipcError, ipcSuccess, type IPCResult } from '@d3ro/core/errors' +import { + classifyModelDownloadResult, + releaseIfCurrent, + runModelDownload, + type ModelDownloadView, +} from '../../src/renderer/components/stt/modelDownload' + +/** STTTab 의 downloadingModelId / downloadError 상태를 흉내 내는 가짜 view */ +function fakeView(): ModelDownloadView & { + downloadingModelId: string | null + error: { modelId: string; message: string } | null + events: string[] +} { + const state = { + downloadingModelId: null as string | null, + error: null as { modelId: string; message: string } | null, + events: [] as string[], + } + return Object.assign(state, { + started: (id: string) => { + state.events.push(`started:${id}`) + state.error = null + state.downloadingModelId = id + }, + succeeded: (id: string) => { + state.events.push(`succeeded:${id}`) + state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id) + }, + cancelled: (id: string) => { + state.events.push(`cancelled:${id}`) + state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id) + }, + failed: (id: string, message: string) => { + state.events.push(`failed:${id}`) + state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id) + state.error = { modelId: id, message } + }, + }) +} + +describe('runModelDownload', () => { + it('ipcError(사이드카 기동 실패 등)면 진행 표시를 풀고 오류 메시지를 노출한다', async () => { + const view = fakeView() + const outcome = await runModelDownload( + 'large-v3-turbo', + { download: async () => ipcError(ErrorCode.STTModelDownloadFailed, '다운로드 시작 실패 (HTTP 500): boom') }, + view, + ) + expect(outcome).toEqual({ kind: 'failed', message: '다운로드 시작 실패 (HTTP 500): boom' }) + expect(view.downloadingModelId).toBeNull() + expect(view.error).toEqual({ modelId: 'large-v3-turbo', message: '다운로드 시작 실패 (HTTP 500): boom' }) + }) + + it('취소 결과는 오류 없이 진행 표시만 푼다', async () => { + const view = fakeView() + const outcome = await runModelDownload( + 'small', + { download: async () => ipcError(ErrorCode.STTModelDownloadCancelled, '모델 다운로드 취소: small') }, + view, + ) + expect(outcome.kind).toBe('cancelled') + expect(view.downloadingModelId).toBeNull() + expect(view.error).toBeNull() + }) + + it('invoke 자체가 reject 되어도 진행 표시를 푼다', async () => { + const view = fakeView() + const outcome = await runModelDownload( + 'small', + { download: async () => { throw new Error('ipc channel closed') } }, + view, + ) + expect(outcome).toEqual({ kind: 'failed', message: 'ipc channel closed' }) + expect(view.downloadingModelId).toBeNull() + }) + + it('성공하면 succeeded 로 끝나고 오류가 없다', async () => { + const view = fakeView() + const outcome = await runModelDownload('small', { download: async () => ipcSuccess(undefined) }, view) + expect(outcome.kind).toBe('succeeded') + expect(view.events).toEqual(['started:small', 'succeeded:small']) + expect(view.downloadingModelId).toBeNull() + expect(view.error).toBeNull() + }) + + it('늦게 도착한 A 실패가 그 사이 시작한 B 의 진행 표시를 지우지 않는다', async () => { + const view = fakeView() + let failA: (r: IPCResult) => void = () => undefined + const pendingA = runModelDownload( + 'A', + { download: () => new Promise>((resolve) => { failA = resolve }) }, + view, + ) + view.started('B') + failA(ipcError(ErrorCode.STTModelDownloadFailed, 'A failed')) + await pendingA + expect(view.downloadingModelId).toBe('B') + }) +}) + +describe('classifyModelDownloadResult', () => { + it('실패 코드별 결말', () => { + expect(classifyModelDownloadResult(ipcSuccess(undefined))).toEqual({ kind: 'succeeded' }) + expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadCancelled, 'x'))).toEqual({ kind: 'cancelled' }) + expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadFailed, 'y'))).toEqual({ + kind: 'failed', + message: 'y', + }) + expect(classifyModelDownloadResult({ thrown: 'raw' })).toEqual({ kind: 'failed', message: 'raw' }) + }) +}) + +describe('STTTab wiring', () => { + it('handleDownloadModel 이 IPC 결과를 버리지 않고 runModelDownload 로 처리한다', () => { + const src = readFileSync( + join(__dirname, '../../src/renderer/components/STTTab.tsx'), + 'utf8', + ) + expect(src).toContain('await runModelDownload(') + expect(src).not.toMatch(/await window\.electronAPI\.stt\.downloadModel\(\{ modelId \}\)\s*\n\s*\}, \[\]\)/) + }) +}) diff --git a/apps/desktop/tests/unit/system-audio-capture-redteam-r3-22.test.ts b/apps/desktop/tests/unit/system-audio-capture-redteam-r3-22.test.ts new file mode 100644 index 0000000..a8579dc --- /dev/null +++ b/apps/desktop/tests/unit/system-audio-capture-redteam-r3-22.test.ts @@ -0,0 +1,319 @@ +// tests/unit/system-audio-capture-redteam-r3-22.test.ts +// SystemAudioCaptureController: stop-during-start 취소, off/on 중복 획득 방지, +// getDisplayMedia 실패 시 loopback 해제를 fake port로 검증한다. + +import { describe, it, expect, vi } from 'vitest' +import { + SystemAudioCaptureController, + type PcmPipeline, + type SystemAudioPort, + type SystemAudioStream, +} from '../../src/renderer/utils/systemAudioCaptureController' +import { float32ToPcm16 } from '../../src/renderer/utils/systemAudioCapture' + +interface Deferred { + promise: Promise + resolve: (v: T) => void + reject: (e: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +class FakeStream implements SystemAudioStream { + stopped = false + videoDropped = false + constructor(readonly id: number, private readonly audio = true) {} + dropVideoTracks(): void { + this.videoDropped = true + } + hasAudioTrack(): boolean { + return this.audio + } + stop(): void { + this.stopped = true + } +} + +class FakePipeline implements PcmPipeline { + closed = false + constructor(readonly stream: FakeStream, readonly onPcm: (b: ArrayBuffer) => void) {} + close(): void { + this.closed = true + } +} + +class FakePort implements SystemAudioPort { + loopbackEnabled = false + enableCalls = 0 + disableCalls = 0 + displayRequests: Deferred[] = [] + pipelines: FakePipeline[] = [] + /** 루프백 핸들러가 꺼진 상태에서 getDisplayMedia 요청 → 실제 Electron처럼 실패 */ + requestsWithoutLoopback = 0 + + async enableLoopback(): Promise { + this.enableCalls++ + this.loopbackEnabled = true + } + async disableLoopback(): Promise { + this.disableCalls++ + this.loopbackEnabled = false + } + getDisplayMedia(): Promise { + if (!this.loopbackEnabled) this.requestsWithoutLoopback++ + const d = deferred() + this.displayRequests.push(d) + return d.promise + } + createPcmPipeline(stream: FakeStream, onPcm: (b: ArrayBuffer) => void): PcmPipeline { + const p = new FakePipeline(stream, onPcm) + this.pipelines.push(p) + return p + } + openPipelines(): FakePipeline[] { + return this.pipelines.filter((p) => !p.closed) + } +} + +/** 마이크로태스크 큐 비우기 */ +async function flush(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +function setup(): { port: FakePort; ctrl: SystemAudioCaptureController; onPcm: ReturnType } { + const port = new FakePort() + const onPcm = vi.fn() + const ctrl = new SystemAudioCaptureController(port, { onPcm }) + return { port, ctrl, onPcm } +} + +describe('SystemAudioCaptureController', () => { + it('starts: enable loopback → getDisplayMedia → disable loopback → pipeline', async () => { + const { port, ctrl, onPcm } = setup() + const started = ctrl.start() + expect(ctrl.getState()).toBe('starting') + await flush() + const stream = new FakeStream(1) + port.displayRequests[0].resolve(stream) + await started + + expect(ctrl.getState()).toBe('active') + expect(ctrl.isActive()).toBe(true) + expect(port.enableCalls).toBe(1) + expect(port.disableCalls).toBe(1) + expect(port.loopbackEnabled).toBe(false) + expect(stream.videoDropped).toBe(true) + expect(port.openPipelines()).toHaveLength(1) + expect(port.pipelines[0].onPcm).toBe(onPcm) + + ctrl.stop() + expect(ctrl.getState()).toBe('idle') + expect(port.pipelines[0].closed).toBe(true) + expect(stream.stopped).toBe(true) + }) + + it('stop while getDisplayMedia is pending cancels the start and releases the stream', async () => { + const { port, ctrl } = setup() + const started = ctrl.start() + await flush() + expect(port.displayRequests).toHaveLength(1) + + ctrl.stop() // 대기 중 STOP 도착 + expect(ctrl.getState()).toBe('idle') + + const stream = new FakeStream(1) + port.displayRequests[0].resolve(stream) + await expect(started).resolves.toBeUndefined() + + expect(ctrl.getState()).toBe('idle') + expect(stream.stopped).toBe(true) + expect(port.pipelines).toHaveLength(0) // 캡처 파이프라인이 만들어지지 않음 + expect(port.loopbackEnabled).toBe(false) + }) + + it('stop issued synchronously after start acquires nothing', async () => { + const { port, ctrl } = setup() + const started = ctrl.start() + ctrl.stop() + await started + expect(port.displayRequests).toHaveLength(0) + expect(port.loopbackEnabled).toBe(false) + expect(ctrl.getState()).toBe('idle') + }) + + it('stop while enableLoopback is pending disables loopback without requesting media', async () => { + const { port, ctrl } = setup() + const enable = deferred() + port.enableLoopback = async () => { + port.enableCalls++ + await enable.promise + port.loopbackEnabled = true + } + const started = ctrl.start() + await flush() + expect(port.enableCalls).toBe(1) + ctrl.stop() + enable.resolve() + await started + expect(port.displayRequests).toHaveLength(0) + expect(port.loopbackEnabled).toBe(false) + expect(port.disableCalls).toBe(1) + }) + + it('start while starting returns the same pending promise (no second acquisition)', async () => { + const { port, ctrl } = setup() + const a = ctrl.start() + const b = ctrl.start() + expect(b).toBe(a) + await flush() + expect(port.enableCalls).toBe(1) + expect(port.displayRequests).toHaveLength(1) + port.displayRequests[0].resolve(new FakeStream(1)) + await a + expect(port.openPipelines()).toHaveLength(1) + }) + + it('start while active is a no-op', async () => { + const { port, ctrl } = setup() + const a = ctrl.start() + await flush() + port.displayRequests[0].resolve(new FakeStream(1)) + await a + await ctrl.start() + expect(port.enableCalls).toBe(1) + expect(port.openPipelines()).toHaveLength(1) + }) + + it('on → off → on inside the pending window leaves exactly one stoppable capture', async () => { + const { port, ctrl } = setup() + const first = ctrl.start() + await flush() + ctrl.stop() + const second = ctrl.start() + await flush() + + // 두 번째 start는 첫 starter 정리 전까지 loopback/getDisplayMedia를 건드리지 않는다 + expect(port.displayRequests).toHaveLength(1) + + const s1 = new FakeStream(1) + port.displayRequests[0].resolve(s1) + await first + await flush() + expect(s1.stopped).toBe(true) + + // 이제 두 번째 starter가 자기 loopback 핸들러로 요청 + expect(port.displayRequests).toHaveLength(2) + expect(port.requestsWithoutLoopback).toBe(0) + const s2 = new FakeStream(2) + port.displayRequests[1].resolve(s2) + await second + + expect(ctrl.getState()).toBe('active') + expect(port.openPipelines()).toHaveLength(1) + expect(port.openPipelines()[0].stream).toBe(s2) + + ctrl.stop() + expect(port.openPipelines()).toHaveLength(0) + expect(s2.stopped).toBe(true) + }) + + it('on → off → on → off leaves nothing running', async () => { + const { port, ctrl } = setup() + const first = ctrl.start() + await flush() + ctrl.stop() + const second = ctrl.start() + ctrl.stop() + + port.displayRequests[0].resolve(new FakeStream(1)) + await first + await second + await flush() + + expect(ctrl.getState()).toBe('idle') + expect(port.displayRequests).toHaveLength(1) // 두 번째 starter는 시작 전에 취소됨 + expect(port.pipelines).toHaveLength(0) + expect(port.loopbackEnabled).toBe(false) + }) + + it('failed getDisplayMedia always disables loopback and rejects to the caller', async () => { + const { port, ctrl } = setup() + const started = ctrl.start() + await flush() + port.displayRequests[0].reject(new Error('denied')) + await expect(started).rejects.toThrow('denied') + expect(port.loopbackEnabled).toBe(false) + expect(port.disableCalls).toBe(1) + expect(ctrl.getState()).toBe('idle') + + // 실패 후 재시작 가능 + const again = ctrl.start() + await flush() + port.displayRequests[1].resolve(new FakeStream(2)) + await again + expect(ctrl.getState()).toBe('active') + }) + + it('failed getDisplayMedia after stop still disables loopback but does not reject', async () => { + const { port, ctrl } = setup() + const started = ctrl.start() + await flush() + ctrl.stop() + port.displayRequests[0].reject(new Error('denied')) + await expect(started).resolves.toBeUndefined() + expect(port.loopbackEnabled).toBe(false) + }) + + it('stream without an audio track is stopped and the start rejects', async () => { + const { port, ctrl } = setup() + const started = ctrl.start() + await flush() + const stream = new FakeStream(1, false) + port.displayRequests[0].resolve(stream) + await expect(started).rejects.toThrow('No audio track') + expect(stream.stopped).toBe(true) + expect(port.pipelines).toHaveLength(0) + expect(ctrl.getState()).toBe('idle') + }) + + it('stop is idempotent when nothing was started', () => { + const { port, ctrl } = setup() + expect(() => { + ctrl.stop() + ctrl.stop() + }).not.toThrow() + expect(ctrl.getState()).toBe('idle') + expect(port.enableCalls).toBe(0) + }) + + it('cleanup errors are reported, not thrown', async () => { + const port = new FakePort() + const onCleanupError = vi.fn() + const ctrl = new SystemAudioCaptureController(port, { onPcm: vi.fn(), onCleanupError }) + const started = ctrl.start() + await flush() + const stream = new FakeStream(1) + stream.stop = () => { + throw new Error('stop failed') + } + port.displayRequests[0].resolve(stream) + await started + expect(() => ctrl.stop()).not.toThrow() + expect(onCleanupError).toHaveBeenCalledTimes(1) + }) +}) + +describe('float32ToPcm16', () => { + it('clamps and scales samples to PCM16', () => { + const out = new Int16Array(float32ToPcm16(new Float32Array([0, 1, -1, 2, -2, 0.5]))) + expect(Array.from(out)).toEqual([0, 32767, -32768, 32767, -32768, 16383]) + }) +}) diff --git a/apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts b/apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts new file mode 100644 index 0000000..44f6ded --- /dev/null +++ b/apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts @@ -0,0 +1,224 @@ +jest.mock('../src/lib/supabase', () => ({ + supabase: { functions: { invoke: jest.fn() } }, +})) + +import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js' +import { supabase } from '../src/lib/supabase' +import { + edgeFailureField, + invokeEdgeFunction, + readEdgeFunctionHttpFailure, + type EdgeFunctionFailure, +} from '../src/lib/edge-functions' +import { + accountDeletionFailureMessageKey, + edgeAccountDeletionService, + runAccountDeletion, + type AccountDeletionService, +} from '../src/features/account/account-deletion-service' + +const mockInvoke = (supabase as unknown as { functions: { invoke: jest.Mock } }).functions.invoke + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** Mirrors functions-js 2.103: non-2xx → { data: null, error: FunctionsHttpError(response) }. */ +function httpFailure(body: unknown, status: number): { data: null; error: FunctionsHttpError } { + return { data: null, error: new FunctionsHttpError(jsonResponse(body, status)) } +} + +beforeEach(() => { + mockInvoke.mockReset() +}) + +describe('invokeEdgeFunction adapter', () => { + test('decodes the JSON body of a 403 FunctionsHttpError instead of relying on data', async () => { + mockInvoke.mockResolvedValue(httpFailure({ + error: 'Recent authentication is required', + code: 'REAUTHENTICATION_REQUIRED', + }, 403)) + + const result = await invokeEdgeFunction('account-delete', { confirmation: 'x' }) + + expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'x' } }) + expect(result.ok).toBe(false) + const failure = result as EdgeFunctionFailure + expect(failure.status).toBe(403) + expect(failure.body).toEqual({ + readable: true, + payload: { error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' }, + }) + expect(edgeFailureField(failure, 'code')).toBe('REAUTHENTICATION_REQUIRED') + expect(edgeFailureField(failure, 'error')).toBe('Recent authentication is required') + expect(failure.message).toBe('Edge Function returned a non-2xx status code') + }) + + test('reports an unreadable body without inventing a code', async () => { + mockInvoke.mockResolvedValue({ + data: null, + error: new FunctionsHttpError(new Response('bad gateway', { status: 502 })), + }) + + const result = await invokeEdgeFunction('team-invite', {}) + + expect(result).toMatchObject({ ok: false, status: 502, body: { readable: false } }) + expect(edgeFailureField(result as EdgeFunctionFailure, 'code')).toBeNull() + }) + + test('keeps fetch errors and thrown values as failures without an HTTP status', async () => { + mockInvoke.mockResolvedValueOnce({ data: null, error: new FunctionsFetchError('offline') }) + await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({ + ok: false, + status: null, + body: null, + }) + + const thrown = new TypeError('Network request failed') + mockInvoke.mockRejectedValueOnce(thrown) + await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({ + ok: false, + status: null, + body: null, + message: 'Network request failed', + cause: thrown, + }) + }) + + test('returns data on success', async () => { + mockInvoke.mockResolvedValue({ data: { success: true }, error: null }) + await expect(invokeEdgeFunction('account-delete', {})).resolves.toEqual({ + ok: true, + data: { success: true }, + }) + }) + + test('readEdgeFunctionHttpFailure ignores errors without a Response context', async () => { + await expect(readEdgeFunctionHttpFailure(new Error('x'))).resolves.toBeNull() + await expect(readEdgeFunctionHttpFailure({ context: 'nope' })).resolves.toBeNull() + await expect(readEdgeFunctionHttpFailure(null)).resolves.toBeNull() + }) +}) + +describe('edgeAccountDeletionService', () => { + test('recognises REAUTHENTICATION_REQUIRED from a 403 response body', async () => { + mockInvoke.mockResolvedValue(httpFailure({ + error: 'Recent authentication is required', + code: 'REAUTHENTICATION_REQUIRED', + }, 403)) + + await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ + ok: false, + code: 'REAUTHENTICATION_REQUIRED', + message: 'Recent authentication is required', + }) + expect(mockInvoke).toHaveBeenCalledWith('account-delete', { + body: { confirmation: 'DELETE_MY_ACCOUNT' }, + }) + }) + + test('recognises ACTIVE_SUBSCRIPTION from a 409 response body', async () => { + mockInvoke.mockResolvedValue(httpFailure({ + error: 'Cancel the active subscription before deleting the account', + code: 'ACTIVE_SUBSCRIPTION', + }, 409)) + + await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({ + ok: false, + code: 'ACTIVE_SUBSCRIPTION', + }) + }) + + test('maps a missing function (404) to SERVER_ENDPOINT_UNAVAILABLE', async () => { + mockInvoke.mockResolvedValue(httpFailure({ code: 'NOT_FOUND', message: 'Requested function was not found' }, 404)) + + await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({ + ok: false, + code: 'SERVER_ENDPOINT_UNAVAILABLE', + }) + }) + + test('treats a 2xx without success=true as REQUEST_FAILED and succeeds only on success=true', async () => { + mockInvoke.mockResolvedValueOnce({ data: { success: false, error: 'nope' }, error: null }) + await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ + ok: false, + code: 'REQUEST_FAILED', + message: 'nope', + }) + + mockInvoke.mockResolvedValueOnce({ data: { success: true }, error: null }) + await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: true }) + }) +}) + +describe('accountDeletionFailureMessageKey', () => { + test('always maps to translated copy, never raw server/SDK text', () => { + expect(accountDeletionFailureMessageKey('REAUTHENTICATION_REQUIRED')) + .toBe('mobile.account.reauthenticationRequired') + expect(accountDeletionFailureMessageKey('SERVER_ENDPOINT_UNAVAILABLE')) + .toBe('mobile.account.deleteUnavailable') + expect(accountDeletionFailureMessageKey('ACTIVE_SUBSCRIPTION')).toBe('mobile.account.deleteFailed') + expect(accountDeletionFailureMessageKey('REQUEST_FAILED')).toBe('mobile.account.deleteFailed') + }) +}) + +describe('runAccountDeletion ordering', () => { + function service(result: Awaited>): AccountDeletionService { + return { deleteCurrentAccount: jest.fn(async () => result) } + } + + test('a refused deletion leaves push registration and the local session untouched', async () => { + const detachPushRegistration = jest.fn(async () => undefined) + const purgeLocalSession = jest.fn(async () => undefined) + + await expect(runAccountDeletion({ + deletionService: service({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }), + detachPushRegistration, + purgeLocalSession, + })).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }) + + expect(detachPushRegistration).not.toHaveBeenCalled() + expect(purgeLocalSession).not.toHaveBeenCalled() + }) + + test('detaches push after server confirmation and before the local purge', async () => { + const calls: string[] = [] + const deletionService: AccountDeletionService = { + deleteCurrentAccount: jest.fn(async () => { + calls.push('delete') + return { ok: true } as const + }), + } + + await expect(runAccountDeletion({ + deletionService, + detachPushRegistration: async () => { calls.push('detach') }, + purgeLocalSession: async () => { calls.push('purge') }, + })).resolves.toEqual({ ok: true }) + + expect(calls).toEqual(['delete', 'detach', 'purge']) + }) + + test('a push detach failure after deletion does not block the local purge', async () => { + const purgeLocalSession = jest.fn(async () => undefined) + + await expect(runAccountDeletion({ + deletionService: service({ ok: true }), + detachPushRegistration: async () => { throw new Error('both push boundaries failed') }, + purgeLocalSession, + })).resolves.toEqual({ ok: true }) + + expect(purgeLocalSession).toHaveBeenCalledTimes(1) + }) + + test('a local purge failure propagates to the caller', async () => { + await expect(runAccountDeletion({ + deletionService: service({ ok: true }), + detachPushRegistration: async () => undefined, + purgeLocalSession: async () => { throw new Error('purge failed') }, + })).rejects.toThrow('purge failed') + }) +}) diff --git a/apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts b/apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts new file mode 100644 index 0000000..c9c6420 --- /dev/null +++ b/apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts @@ -0,0 +1,214 @@ +const mockClearHistory = jest.fn() +const mockClearEntitlements = jest.fn() +const mockClearPreferences = jest.fn() +const mockCancelRecorder = jest.fn() +const mockAcquireRecorder = jest.fn() +const mockClearQueuedAudio = jest.fn() +const mockRetainQueuedAudio = jest.fn() +const mockClearActions = jest.fn() +const mockClearGenerationKeys = jest.fn() +const mockDeletePush = jest.fn() + +jest.mock('../src/features/history/history-cache', () => ({ + clearAllHistoryCaches: () => mockClearHistory(), +})) +jest.mock('../src/lib/entitlement-context', () => ({ + clearAllEntitlementCaches: () => mockClearEntitlements(), +})) +jest.mock('../src/lib/preferences-context', () => ({ + clearAllUserPreferenceCaches: () => mockClearPreferences(), +})) +jest.mock('../src/lib/audio-recorder', () => ({ + audioRecorder: { + cancel: () => mockCancelRecorder(), + acquire: (owner: string) => mockAcquireRecorder(owner), + }, +})) +jest.mock('../src/features/recording/durable-processing-queue', () => ({ + clearAllQueuedAudio: () => mockClearQueuedAudio(), + retainQueuedAudioOnlyForUser: (userId: string) => mockRetainQueuedAudio(userId), +})) +jest.mock('../src/features/actions/action-service', () => ({ + clearAllActionHistories: () => mockClearActions(), +})) +jest.mock('../src/features/templates', () => ({ + clearEveryGenerationIdempotencyKey: () => mockClearGenerationKeys(), +})) +jest.mock('../src/features/notifications/notification-native', () => ({ + deleteNativePushRegistration: () => mockDeletePush(), +})) + +import AsyncStorage from '@react-native-async-storage/async-storage' +import { purgeAllAccountLocalData } from '../src/lib/account-local-data' +import { DISCARD_UNSYNCED_WORK } from '../src/lib/auth-transition-policy' +import { RecorderBusyError } from '../src/lib/recorder/recorder-errors' +import type { + RecorderSession, + RecordingRuntimeSnapshot, +} from '../src/lib/recorder/recorder-types' +import { stopLiveCaptureKeepingFile } from '../src/lib/retain-live-capture' +import { + createRetainedWorkOwnerStore, + retainedAccountWork, + retainedAccountWorkTestContract, +} from '../src/lib/retained-account-work' + +const USER_A = '11111111-1111-4111-8111-111111111111' + +const cacheOperations = [ + mockClearHistory, + mockClearEntitlements, + mockClearPreferences, + mockClearActions, + mockClearGenerationKeys, + mockDeletePush, +] + +function snapshot(state: RecordingRuntimeSnapshot['state'], withFile: boolean): RecordingRuntimeSnapshot { + return { + state, + recording: withFile + ? { uri: 'file:///c.wav', path: '/c.wav', fileName: 'c.wav', mimeType: 'audio/wav', size: 10, durationMs: 90 * 60_000 } + : null, + meetingId: null, + interruptionReason: null, + startedAtMs: 0, + } +} + +function fakeSession(overrides: Partial = {}): jest.Mocked { + return { + owner: 'record', + start: jest.fn(async () => undefined), + pause: jest.fn(async () => undefined), + resume: jest.fn(async () => undefined), + stop: jest.fn(async () => snapshot('stopped', true).recording!), + cleanup: jest.fn(async () => undefined), + cancel: jest.fn(async () => undefined), + restore: jest.fn(async () => snapshot('stopped', true)), + ...overrides, + } as jest.Mocked +} + +describe('account purge keeps unsynced work on involuntary sign-out (redteam r3-16 #2)', () => { + beforeEach(async () => { + await AsyncStorage.clear() + await retainedAccountWork.release() + for (const operation of [...cacheOperations, mockCancelRecorder, mockClearQueuedAudio, mockRetainQueuedAudio]) { + operation.mockReset().mockResolvedValue(undefined) + } + mockAcquireRecorder.mockReset().mockResolvedValue(fakeSession()) + }) + + it('purges caches but keeps the capture and the owner queue', async () => { + await purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A }) + + for (const operation of cacheOperations) expect(operation).toHaveBeenCalledTimes(1) + expect(mockCancelRecorder).not.toHaveBeenCalled() + expect(mockClearQueuedAudio).not.toHaveBeenCalled() + expect(mockRetainQueuedAudio).toHaveBeenCalledWith(USER_A) + expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe(USER_A) + }) + + it('discards everything and releases the retention marker on a discard boundary', async () => { + await retainedAccountWork.retain(USER_A) + await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK) + + expect(mockCancelRecorder).toHaveBeenCalledTimes(1) + expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1) + expect(retainedAccountWork.current()).toBeNull() + expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBeNull() + }) + + it('runs a discard requested during a retain run instead of sharing it', async () => { + let finishRetain: (() => void) | null = null + mockRetainQueuedAudio.mockReturnValueOnce(new Promise((resolve) => { + finishRetain = resolve + })) + const retain = purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A }) + const discard = purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK) + expect(discard).not.toBe(retain) + finishRetain?.() + await Promise.all([retain, discard]) + + expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1) + expect(mockCancelRecorder).toHaveBeenCalledTimes(1) + expect(retainedAccountWork.current()).toBeNull() + }) +}) + +describe('stopLiveCaptureKeepingFile (redteam r3-16 #2)', () => { + it('stops an in-process capture and keeps its reattachable file', async () => { + const session = fakeSession() + await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) }) + expect(session.stop).toHaveBeenCalledTimes(1) + expect(session.cancel).not.toHaveBeenCalled() + expect(session.cleanup).not.toHaveBeenCalled() + }) + + it('reattaches a capture that outlived a JS restart before stopping it', async () => { + const session = fakeSession({ + stop: jest + .fn() + .mockRejectedValueOnce(new Error('Cannot stop recorder while it is idle')) + .mockResolvedValueOnce(snapshot('stopped', true).recording), + restore: jest + .fn() + .mockResolvedValueOnce(snapshot('recording', false)) + .mockResolvedValueOnce(snapshot('stopped', true)), + }) + await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) }) + expect(session.stop).toHaveBeenCalledTimes(2) + expect(session.cancel).not.toHaveBeenCalled() + }) + + it('falls back to discarding a capture the backend cannot hand back', async () => { + const session = fakeSession({ restore: jest.fn(async () => snapshot('idle', false)) }) + await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) }) + expect(session.cancel).toHaveBeenCalledTimes(1) + }) + + it('discards a transient Talk capture through its own owner session', async () => { + const talk = fakeSession({ owner: 'talk' }) + const acquire = jest.fn(async (owner: string) => { + if (owner === 'record') throw new RecorderBusyError('talk') + return talk + }) + await stopLiveCaptureKeepingFile({ acquire }) + expect(acquire).toHaveBeenLastCalledWith('talk') + expect(talk.cancel).toHaveBeenCalledTimes(1) + expect(talk.stop).not.toHaveBeenCalled() + }) +}) + +describe('retained work owner store (redteam r3-16 #2)', () => { + it('persists the owner across a restart and ignores a stale load', async () => { + const values = new Map() + let resolveRead: ((value: string | null) => void) | null = null + const storage = { + getItem: jest.fn((key: string) => { + const valueAtRead = values.get(key) ?? null + return new Promise((resolve) => { + resolveRead = () => resolve(valueAtRead) + }) + }), + setItem: jest.fn(async (key: string, value: string) => { values.set(key, value) }), + removeItem: jest.fn(async (key: string) => { values.delete(key) }), + } + const first = createRetainedWorkOwnerStore(storage) + await first.retain('owner-a') + + const restarted = createRetainedWorkOwnerStore(storage) + const loading = restarted.load() + resolveRead?.(null) + await expect(loading).resolves.toBe('owner-a') + expect(restarted.current()).toBe('owner-a') + + const staleLoad = createRetainedWorkOwnerStore(storage) + const pending = staleLoad.load() + await staleLoad.release() + resolveRead?.(null) + await expect(pending).resolves.toBeNull() + expect(staleLoad.current()).toBeNull() + }) +}) diff --git a/apps/mobile-rn/__tests__/auth-redirect.test.ts b/apps/mobile-rn/__tests__/auth-redirect.test.ts index 93b97e7..28d5f5d 100644 --- a/apps/mobile-rn/__tests__/auth-redirect.test.ts +++ b/apps/mobile-rn/__tests__/auth-redirect.test.ts @@ -102,19 +102,16 @@ describe('mobile auth redirect boundary', () => { expect(exchangeCodeForSession).toHaveBeenCalledTimes(2) }) - it('accepts a complete legacy token pair once and rejects partial credentials', async () => { + // redteam r3-16 #1: 토큰 쌍 콜백(implicit flow)은 로그인 CSRF 경로라 완전한 쌍이어도 세션으로 바꾸지 않는다. + // 이전 계약("완전한 레거시 쌍은 한 번 받아들인다")을 대체한다 — auth-redteam-r3-16.test.tsx 와 같은 계약. + it('rejects token-pair callbacks, complete or partial, without touching the session', async () => { const { operations, setSession } = createOperations() const complete = createAuthRedirectHandler(operations) - const url = 'd3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery' - await expect(complete(url)).resolves.toBe('recovery') - await expect(complete(url)).resolves.toBe('recovery') - expect(setSession).toHaveBeenCalledTimes(1) - expect(setSession).toHaveBeenCalledWith({ - access_token: 'access', - refresh_token: 'refresh', - }) + await expect(complete('d3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery')) + .rejects.toMatchObject({ code: 'invalid_callback' }) await expect(complete('d3ro-voice://auth-callback#access_token=partial')) .rejects.toMatchObject({ code: 'invalid_callback' }) + expect(setSession).not.toHaveBeenCalled() }) }) diff --git a/apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx b/apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx new file mode 100644 index 0000000..b8a7855 --- /dev/null +++ b/apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx @@ -0,0 +1,298 @@ +import React from 'react' +import { Linking } from 'react-native' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { AuthChangeEvent, Session } from '@supabase/supabase-js' + +const mockGetSession = jest.fn() +const mockOnAuthStateChange = jest.fn() +const mockStopAutoRefresh = jest.fn() +const mockStartAutoRefresh = jest.fn() +const mockSetSession = jest.fn() +const mockExchangeCodeForSession = jest.fn() +const mockPurgeAccountLocalData = jest.fn() +const mockClearSecureAuthStorage = jest.fn() + +jest.mock('../src/lib/supabase', () => ({ + isSupabaseConfigured: () => true, + supabase: { + auth: { + getSession: (...args: unknown[]) => mockGetSession(...args), + onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args), + stopAutoRefresh: (...args: unknown[]) => mockStopAutoRefresh(...args), + startAutoRefresh: (...args: unknown[]) => mockStartAutoRefresh(...args), + setSession: (...args: unknown[]) => mockSetSession(...args), + exchangeCodeForSession: (...args: unknown[]) => mockExchangeCodeForSession(...args), + }, + }, +})) +jest.mock('../src/lib/account-local-data', () => ({ + purgeAllAccountLocalData: (...args: unknown[]) => mockPurgeAccountLocalData(...args), +})) +jest.mock('../src/lib/secure-auth-storage', () => ({ + clearAllSecureAuthStorage: (...args: unknown[]) => mockClearSecureAuthStorage(...args), +})) + +import AsyncStorage from '@react-native-async-storage/async-storage' +import { AuthProvider, useAuth } from '../src/lib/auth-context' +import { createAuthRedirectHandler } from '../src/lib/auth-redirect' +import { + DISCARD_UNSYNCED_WORK, + planAuthTransition, + type AuthTransitionFacts, +} from '../src/lib/auth-transition-policy' +import { + retainedAccountWork, + retainedAccountWorkTestContract, +} from '../src/lib/retained-account-work' + +type AuthSnapshot = ReturnType +type AuthCallback = (event: AuthChangeEvent, session: Session | null) => void +type UrlListener = (event: { url: string }) => void + +const ATTACKER_LINK = 'd3ro-voice://auth-callback#access_token=attacker-access&refresh_token=attacker-refresh' + +let latest: AuthSnapshot +let authCallback: AuthCallback | null = null +let urlListener: UrlListener | null = null +let renderer: ReactTestRenderer | null = null +let storedSession: Session | null = null + +function session(userId: string): Session { + return { + access_token: `access-${userId}`, + token_type: 'bearer', + expires_in: 3600, + expires_at: 4_000_000_000, + refresh_token: `refresh-${userId}`, + user: { id: userId }, + } as unknown as Session +} + +function Probe(): null { + latest = useAuth() + return null +} + +async function flush(): Promise { + await act(async () => { + for (let index = 0; index < 12; index += 1) await Promise.resolve() + }) +} + +async function mount(restoredSession: Session | null): Promise { + storedSession = restoredSession + await act(async () => { + renderer = create() + }) + await flush() +} + +function emit(event: AuthChangeEvent, nextSession: Session | null): void { + storedSession = nextSession + if (authCallback === null) throw new Error('auth callback is not subscribed') + authCallback(event, nextSession) +} + +function facts(overrides: Partial): AuthTransitionFacts { + return { + previousUserId: null, + nextUserId: null, + retainedOwnerUserId: null, + forcePurge: false, + cleanupPending: false, + explicit: false, + ...overrides, + } +} + +describe('mobile login CSRF via implicit token callback (redteam r3-16 #1)', () => { + it('never turns a token-pair callback into a session', async () => { + const setSession = jest.fn(async () => ({ data: { session: null, user: null }, error: null })) + const exchangeCodeForSession = jest.fn() + const complete = createAuthRedirectHandler({ + exchangeCodeForSession, + setSession, + } as unknown as Parameters[0]) + + await expect(complete(ATTACKER_LINK)).rejects.toMatchObject({ code: 'invalid_callback' }) + await expect(complete(`${ATTACKER_LINK}&type=recovery`)) + .rejects.toMatchObject({ code: 'invalid_callback' }) + await expect(complete('d3ro-voice://auth-callback?code=c#access_token=a&refresh_token=r')) + .rejects.toMatchObject({ code: 'invalid_callback' }) + expect(setSession).not.toHaveBeenCalled() + expect(exchangeCodeForSession).not.toHaveBeenCalled() + }) + + it('keeps the signed-in victim and their local data when the link is opened', async () => { + authCallback = null + urlListener = null + mockGetSession.mockReset().mockImplementation(async () => ({ + data: { session: storedSession }, + error: null, + })) + mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => { + authCallback = callback + return { data: { subscription: { unsubscribe: jest.fn() } } } + }) + mockSetSession.mockReset().mockResolvedValue({ data: { session: null, user: null }, error: null }) + mockPurgeAccountLocalData.mockReset().mockResolvedValue(undefined) + jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null) + jest.spyOn(Linking, 'addEventListener').mockImplementation(((_type: string, listener: UrlListener) => { + urlListener = listener + return { remove: jest.fn() } + }) as unknown as typeof Linking.addEventListener) + + await mount(session('victim')) + expect(latest.user?.id).toBe('victim') + + await act(async () => { + urlListener?.({ url: ATTACKER_LINK }) + }) + await flush() + + expect(mockSetSession).not.toHaveBeenCalled() + expect(mockPurgeAccountLocalData).not.toHaveBeenCalled() + expect(latest.user?.id).toBe('victim') + expect(latest.authError).toBe('callback_failed') + + act(() => renderer?.unmount()) + renderer = null + jest.restoreAllMocks() + }) +}) + +describe('auth transition policy (redteam r3-16 #2)', () => { + it('retains the owner work on involuntary session loss', () => { + expect(planAuthTransition(facts({ previousUserId: 'a' }))).toEqual({ + purge: true, + unsyncedWork: { kind: 'retain', ownerUserId: 'a' }, + }) + }) + + it('discards on explicit logout and on a different account', () => { + expect(planAuthTransition(facts({ previousUserId: 'a', explicit: true, forcePurge: true }))) + .toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK }) + expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'b' }))) + .toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK }) + expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'b' }))) + .toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK }) + }) + + it('lets the owner come back without a purge and releases the marker', () => { + expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'a' }))) + .toEqual({ purge: false, releaseRetainedWork: true }) + expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'a' }))) + .toEqual({ purge: false, releaseRetainedWork: false }) + }) + + it('keeps retained work across a cold boot without a session, discards unowned work', () => { + expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', forcePurge: true }))) + .toEqual({ purge: true, unsyncedWork: { kind: 'retain', ownerUserId: 'a' } }) + expect(planAuthTransition(facts({ forcePurge: true }))) + .toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK }) + }) +}) + +describe('AuthProvider keeps unsynced recordings on involuntary sign-out (redteam r3-16 #2)', () => { + beforeEach(async () => { + authCallback = null + renderer = null + storedSession = null + await AsyncStorage.clear() + await retainedAccountWork.release() + mockGetSession.mockReset().mockImplementation(async () => ({ + data: { session: storedSession }, + error: null, + })) + mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => { + authCallback = callback + return { data: { subscription: { unsubscribe: jest.fn() } } } + }) + mockStopAutoRefresh.mockReset().mockResolvedValue(undefined) + mockStartAutoRefresh.mockReset().mockResolvedValue(undefined) + mockClearSecureAuthStorage.mockReset().mockResolvedValue(undefined) + // The real purge records the retained owner; mirror that contract here. + mockPurgeAccountLocalData.mockReset().mockImplementation(async (disposition?: { + kind: 'discard' | 'retain' + ownerUserId?: string + }) => { + if (disposition?.kind === 'retain' && disposition.ownerUserId !== undefined) { + await retainedAccountWork.retain(disposition.ownerUserId) + } else { + await retainedAccountWork.release() + } + }) + jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null) + }) + + afterEach(() => { + if (renderer !== null) act(() => renderer?.unmount()) + jest.restoreAllMocks() + }) + + it('retains A work on SIGNED_OUT and resumes it without a purge when A returns', async () => { + await mount(session('user-a')) + act(() => emit('SIGNED_OUT', null)) + await flush() + + expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1) + expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith({ kind: 'retain', ownerUserId: 'user-a' }) + expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1) + expect(latest.user).toBeNull() + expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe('user-a') + + act(() => emit('SIGNED_IN', session('user-a'))) + await flush() + expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1) + expect(latest.user?.id).toBe('user-a') + expect(retainedAccountWork.current()).toBeNull() + }) + + it('discards retained work before a different account is committed', async () => { + await mount(session('user-a')) + act(() => emit('SIGNED_OUT', null)) + await flush() + + let finishDiscard: (() => void) | null = null + mockPurgeAccountLocalData.mockImplementationOnce(() => new Promise((resolve) => { + finishDiscard = resolve + })) + act(() => emit('SIGNED_IN', session('user-b'))) + await flush() + expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK) + expect(latest.user).toBeNull() + + finishDiscard?.() + await flush() + expect(latest.user?.id).toBe('user-b') + }) + + it('keeps retained work across a cold boot with no session', async () => { + await retainedAccountWork.retain('user-a') + await mount(null) + expect(mockPurgeAccountLocalData).toHaveBeenCalledWith({ kind: 'retain', ownerUserId: 'user-a' }) + }) + + it('still discards everything on an explicit logout', async () => { + await mount(session('user-a')) + await act(async () => { + await latest.purgeLocalSession() + }) + expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK) + }) + + it('discards retained work when a racing newer session belongs to another account', async () => { + await mount(session('user-a')) + storedSession = session('user-b') + act(() => { + authCallback?.('SIGNED_OUT', null) + }) + await flush() + expect(mockPurgeAccountLocalData.mock.calls.map(([disposition]) => disposition)).toEqual([ + { kind: 'retain', ownerUserId: 'user-a' }, + DISCARD_UNSYNCED_WORK, + ]) + expect(latest.user?.id).toBe('user-b') + expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile-rn/__tests__/durable-queue-retention-redteam-r3-16.test.ts b/apps/mobile-rn/__tests__/durable-queue-retention-redteam-r3-16.test.ts new file mode 100644 index 0000000..8ba0210 --- /dev/null +++ b/apps/mobile-rn/__tests__/durable-queue-retention-redteam-r3-16.test.ts @@ -0,0 +1,139 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { FileSystem } from 'react-native-file-access' +import type { + AudioPipelineResult, + LocalAudioInput, +} from '../src/features/import/audio-import-types' + +const mockProcessAudioInput = jest.fn, [LocalAudioInput, unknown]>() + +jest.mock('../src/features/import/audio-transcription-service', () => ({ + processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options), +})) +jest.mock('../src/features/meetings/meetings-service', () => ({ + failMeetingRecording: jest.fn(async () => undefined), + markMeetingProcessingFailure: jest.fn(async () => undefined), + queueMeetingRecording: jest.fn(async () => undefined), +})) + +import { + clearQueuedAudioForUser, + durableQueueTestContract, + resumeQueuedAudioForUser, + retainQueuedAudioOnlyForUser, + type DurableQueueItem, +} from '../src/features/recording/durable-processing-queue' + +const START_MS = 1_700_000_000_000 +const USER_A = '11111111-1111-4111-8111-111111111111' +const USER_B = '22222222-2222-4222-8222-222222222222' + +const mockFileSystem = FileSystem as typeof FileSystem & { filesystem: Map } + +function queued( + userId: string, + suffix: string, + overrides: Partial = {}, +): DurableQueueItem { + const path = `${durableQueueTestContract.queueDirectory}/queued-${suffix}.wav` + return { + schemaVersion: 1, + id: suffix, + userId, + meetingId: null, + path, + uri: `file://${path}`, + fileName: `${suffix}.wav`, + mimeType: 'audio/wav', + sizeBytes: 5, + durationMs: 100, + source: 'recording', + languageCode: 'ko', + status: 'retry', + phase: null, + attempts: 1, + uploadedBytes: 0, + nextAttemptAtMs: START_MS + 5_000, + lastErrorCode: 'upload', + lastErrorMessage: null, + createdAtMs: 1, + updatedAtMs: 1, + ...overrides, + } +} + +function result(): AudioPipelineResult { + return { + historyId: null, + audioFileId: 'audio-file', + meetingId: null, + processingJobId: null, + transcript: 'ok', + provider: 'test', + language: 'ko', + durationSeconds: 1, + deduplicated: false, + } +} + +async function seed(items: DurableQueueItem[]): Promise { + for (const item of items) mockFileSystem.filesystem.set(item.path, 'audio') + await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify(items)) +} + +async function stored(): Promise { + return durableQueueTestContract.parseQueue( + await AsyncStorage.getItem(durableQueueTestContract.storageKey), + ) +} + +async function settle(): Promise { + for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0) +} + +describe('durable queue keeps the signed-out owner recordings (redteam r3-16 #2)', () => { + beforeEach(async () => { + jest.useFakeTimers({ now: START_MS }) + await AsyncStorage.clear() + mockFileSystem.filesystem.clear() + mockProcessAudioInput.mockReset().mockResolvedValue(result()) + }) + + afterEach(async () => { + for (const userId of [USER_A, USER_B]) { + await clearQueuedAudioForUser(userId).catch(() => undefined) + } + await settle() + jest.useRealTimers() + }) + + test('keeps the owner items and files, discards other accounts', async () => { + const ownerRetry = queued(USER_A, 'aaa001') + const ownerPending = queued(USER_A, 'aaa002', { status: 'pending', attempts: 0, nextAttemptAtMs: START_MS }) + const other = queued(USER_B, 'bbb001') + await seed([ownerRetry, ownerPending, other]) + + await retainQueuedAudioOnlyForUser(USER_A) + + expect((await stored()).map(item => item.id).sort()).toEqual(['aaa001', 'aaa002']) + expect(mockFileSystem.filesystem.has(ownerRetry.path)).toBe(true) + expect(mockFileSystem.filesystem.has(ownerPending.path)).toBe(true) + expect(mockFileSystem.filesystem.has(other.path)).toBe(false) + }) + + test('does not spend retries while the owner is signed out, resumes on sign-in', async () => { + const item = queued(USER_A, 'aaa003', { nextAttemptAtMs: START_MS }) + await seed([item]) + + await retainQueuedAudioOnlyForUser(USER_A) + await jest.advanceTimersByTimeAsync(60 * 60_000) + await settle() + expect(mockProcessAudioInput).not.toHaveBeenCalled() + expect((await stored())[0]?.attempts).toBe(1) + + await resumeQueuedAudioForUser(USER_A) + await settle() + expect(mockProcessAudioInput).toHaveBeenCalledTimes(1) + expect(await stored()).toEqual([]) + }) +}) diff --git a/apps/mobile-rn/__tests__/knowledge-realtime-redteam-r3-20.test.ts b/apps/mobile-rn/__tests__/knowledge-realtime-redteam-r3-20.test.ts new file mode 100644 index 0000000..bc99462 --- /dev/null +++ b/apps/mobile-rn/__tests__/knowledge-realtime-redteam-r3-20.test.ts @@ -0,0 +1,166 @@ +const mockRealtimeOn = jest.fn() +const mockRealtimeSubscribe = jest.fn() +const mockRemoveChannel = jest.fn(async () => 'ok') +const mockRealtimeChannel: Record = { + on: mockRealtimeOn, + subscribe: mockRealtimeSubscribe, +} +mockRealtimeOn.mockReturnValue(mockRealtimeChannel) +mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel) + +jest.mock('../src/lib/supabase', () => ({ + supabase: { + from: jest.fn(), + channel: jest.fn(() => mockRealtimeChannel), + removeChannel: (...args: unknown[]) => mockRemoveChannel(...(args as [])), + }, +})) + +import { + type ForegroundStatePort, + type IntervalPort, + KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS, + knowledgeRealtimeBindings, + startForegroundReconciliation, +} from '../src/features/knowledge/knowledge-realtime' +import { subscribeToKnowledgeDocuments } from '../src/features/knowledge/knowledge-service' + +const USER_A = '11111111-1111-4111-8111-111111111111' + +interface FakeForeground extends ForegroundStatePort { + set: (active: boolean) => void + listenerCount: () => number +} + +function fakeForeground(initial: boolean): FakeForeground { + let active = initial + const listeners = new Set<(active: boolean) => void>() + return { + isActive: () => active, + onChange: (listener) => { + listeners.add(listener) + return { remove: () => { listeners.delete(listener) } } + }, + set: (next) => { + active = next + for (const listener of listeners) listener(next) + }, + listenerCount: () => listeners.size, + } +} + +interface FakeInterval extends IntervalPort { + tick: () => void + cleared: () => boolean + lastMs: () => number | null +} + +function fakeInterval(): FakeInterval { + let callback: (() => void) | null = null + let cleared = false + let lastMs: number | null = null + const handle = 1 as unknown as ReturnType + return { + set: (cb, ms) => { callback = cb; lastMs = ms; return handle }, + clear: () => { cleared = true; callback = null }, + tick: () => { callback?.() }, + cleared: () => cleared, + lastMs: () => lastMs, + } +} + +function subscribedBindings(): Array<{ event: string, filter?: string, table: string }> { + return mockRealtimeOn.mock.calls.map((call) => call[1] as { + event: string + filter?: string + table: string + }) +} + +describe('knowledge realtime policy (redteam r3-20)', () => { + beforeEach(() => { + mockRealtimeOn.mockClear() + mockRealtimeSubscribe.mockClear() + mockRemoveChannel.mockClear() + }) + + it('never subscribes to unfiltered DELETE events on knowledge_documents', () => { + const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), { + reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() }, + }) + const events = subscribedBindings().map((binding) => binding.event) + expect(events).not.toContain('DELETE') + expect(events).not.toContain('*') + expect(events).toEqual(['INSERT', 'UPDATE']) + void subscription.unsubscribe() + }) + + it('narrows INSERT/UPDATE to the owner when a user id is supplied', () => { + const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), { + userId: USER_A, + reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() }, + }) + expect(subscribedBindings()).toEqual([ + expect.objectContaining({ event: 'INSERT', filter: `user_id=eq.${USER_A}` }), + expect.objectContaining({ event: 'UPDATE', filter: `user_id=eq.${USER_A}` }), + ]) + void subscription.unsubscribe() + }) + + it('rejects a malformed owner id instead of interpolating it into the filter', () => { + expect(() => subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), { + userId: 'x,user_id=neq.0', + })).toThrow() + expect(mockRealtimeOn).not.toHaveBeenCalled() + }) + + it('reconciles deletions by foreground polling and stops on unsubscribe', async () => { + const onChanged = jest.fn() + const foreground = fakeForeground(true) + const interval = fakeInterval() + const subscription = subscribeToKnowledgeDocuments(onChanged, jest.fn(), { + reconciliation: { foreground, interval }, + }) + expect(interval.lastMs()).toBe(KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS) + + interval.tick() + expect(onChanged).toHaveBeenCalledTimes(1) + + await subscription.unsubscribe() + expect(interval.cleared()).toBe(true) + expect(foreground.listenerCount()).toBe(0) + expect(mockRemoveChannel).toHaveBeenCalledWith(mockRealtimeChannel) + interval.tick() + foreground.set(false) + foreground.set(true) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it('pauses polling in the background and reconciles once on resume', () => { + const onReconcile = jest.fn() + const foreground = fakeForeground(true) + const interval = fakeInterval() + const reconciliation = startForegroundReconciliation(onReconcile, { foreground, interval }) + + foreground.set(false) + interval.tick() + expect(onReconcile).not.toHaveBeenCalled() + + foreground.set(true) + expect(onReconcile).toHaveBeenCalledTimes(1) + foreground.set(true) + expect(onReconcile).toHaveBeenCalledTimes(1) + + interval.tick() + expect(onReconcile).toHaveBeenCalledTimes(2) + reconciliation.stop() + reconciliation.stop() + }) + + it('builds bindings without DELETE and without a filter when no owner is given', () => { + expect(knowledgeRealtimeBindings()).toEqual([ + { event: 'INSERT', schema: 'public', table: 'knowledge_documents' }, + { event: 'UPDATE', schema: 'public', table: 'knowledge_documents' }, + ]) + }) +}) diff --git a/apps/mobile-rn/__tests__/knowledge-service.test.ts b/apps/mobile-rn/__tests__/knowledge-service.test.ts index 53598de..38f790e 100644 --- a/apps/mobile-rn/__tests__/knowledge-service.test.ts +++ b/apps/mobile-rn/__tests__/knowledge-service.test.ts @@ -235,9 +235,11 @@ describe('knowledge edge contracts', () => { })).rejects.toMatchObject({ code: 'invalid-response' }) }) - it('subscribes to document inserts, updates, and deletes through RLS realtime', () => { + // redteam r3-20: 필터가 걸린 postgres_changes 는 DELETE 를 보내지 않고, 필터 없는 DELETE 구독은 남의 행 id 를 흘린다. + // 삭제는 knowledge-realtime 의 다른 경로로 받는다 — knowledge-realtime-redteam-r3-20.test.ts 와 같은 계약. + it('subscribes to document inserts and updates through RLS realtime, never to DELETE', () => { subscribeToKnowledgeDocuments(jest.fn(), jest.fn()) const events = mockRealtimeOn.mock.calls.map((call) => (call[1] as { event: string }).event) - expect(events).toEqual(['INSERT', 'UPDATE', 'DELETE']) + expect(events).toEqual(['INSERT', 'UPDATE']) }) }) diff --git a/apps/mobile-rn/__tests__/meeting-rerecord-failure-redteam-r3-15.test.ts b/apps/mobile-rn/__tests__/meeting-rerecord-failure-redteam-r3-15.test.ts new file mode 100644 index 0000000..57dbe0a --- /dev/null +++ b/apps/mobile-rn/__tests__/meeting-rerecord-failure-redteam-r3-15.test.ts @@ -0,0 +1,142 @@ +jest.mock('../src/lib/supabase', () => ({ + supabase: { + rpc: jest.fn(), + }, +})); + +import type { Meeting, MeetingStatus } from '@d3ro/api-client'; +import { supabase } from '../src/lib/supabase'; +import { + beginMeetingRecording, + cancelMeetingRecording, + completeMeetingProcessing, + failMeetingRecording, + queueMeetingRecording, +} from '../src/features/meetings/meetings-service'; +import { + confirmsMeetingTransition, + expectedMeetingStates, +} from '../src/features/meetings/meeting-recording-state-policy'; + +const USER_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555'; +const MEETING_ID = '22222222-2222-4222-8222-222222222222'; +const AUDIO_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`; +const mockRpc = supabase.rpc as jest.Mock; + +function meeting(status: MeetingStatus, userId = USER_ID): Meeting { + return { + id: MEETING_ID, + user_id: userId, + team_id: null, + title: 'Re-recorded meeting', + status, + started_at: '2026-08-21T00:00:00.000Z', + ended_at: '2026-08-21T00:01:00.000Z', + duration_ms: 60_000, + raw_transcript: 'earlier transcript', + edited_transcript: null, + minutes_markdown: 'earlier minutes', + minutes_json: null, + stt_model: 'whisper', + llm_model: null, + stt_latency_ms: 100, + llm_latency_ms: null, + error_message: null, + audio_storage_key: `${USER_ID}/imports/earlier.wav`, + created_at: '2026-08-21T00:00:00.000Z', + updated_at: '2026-08-21T00:01:00.000Z', + }; +} + +describe('re-record failure paths (server restores a meeting with content to completed)', () => { + beforeEach(() => mockRpc.mockReset()); + + test('cancelMeetingRecording accepts the restored completed meeting', async () => { + mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null }); + await expect(cancelMeetingRecording(USER_ID, MEETING_ID)) + .resolves.toMatchObject({ status: 'completed', raw_transcript: 'earlier transcript' }); + expect(mockRpc).toHaveBeenCalledWith('mobile_cancel_meeting_recording', { p_meeting_id: MEETING_ID }); + }); + + test('failMeetingRecording accepts the restored completed meeting', async () => { + mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null }); + await expect(failMeetingRecording(USER_ID, MEETING_ID, 'Queued audio processing was cancelled')) + .resolves.toMatchObject({ status: 'completed' }); + expect(mockRpc).toHaveBeenCalledWith('mobile_fail_meeting_recording', { + p_meeting_id: MEETING_ID, + p_error_message: 'Queued audio processing was cancelled', + }); + }); + + test('a first recording still confirms the error outcome', async () => { + mockRpc + .mockResolvedValueOnce({ data: meeting('error'), error: null }) + .mockResolvedValueOnce({ data: meeting('error'), error: null }); + await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'error' }); + await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).resolves.toMatchObject({ status: 'error' }); + }); + + test.each(['recording', 'processing'])( + 'cancel/fail still reject a meeting left in %s', + async (status) => { + mockRpc + .mockResolvedValueOnce({ data: meeting(status), error: null }) + .mockResolvedValueOnce({ data: meeting(status), error: null }); + await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ + code: 'server', + message: 'Meeting cancellation was not confirmed', + }); + await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ + code: 'server', + message: 'Meeting error state was not confirmed', + }); + }, + ); + + test('cancel/fail still reject a row owned by another user', async () => { + mockRpc + .mockResolvedValueOnce({ data: meeting('completed', OTHER_USER_ID), error: null }) + .mockResolvedValueOnce({ data: meeting('error', OTHER_USER_ID), error: null }); + await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server' }); + await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server' }); + }); + + test('the other transitions keep their single confirmed state', async () => { + mockRpc + .mockResolvedValueOnce({ data: meeting('completed'), error: null }) + .mockResolvedValueOnce({ data: meeting('completed'), error: null }) + .mockResolvedValueOnce({ data: meeting('error'), error: null }); + await expect(beginMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ + message: 'Meeting recording state was not confirmed', + }); + await expect(queueMeetingRecording(USER_ID, MEETING_ID, 1_000)).rejects.toMatchObject({ + message: 'Queued meeting state was not confirmed', + }); + await expect(completeMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, { + transcript: 't', + language: 'ko', + provider: 'whisper', + durationMs: 1_000, + sttLatencyMs: 10, + })).rejects.toMatchObject({ message: 'Meeting completion was not confirmed' }); + }); +}); + +describe('meeting recording state policy', () => { + test('lists the states each transition may produce', () => { + expect(expectedMeetingStates('recording-started')).toEqual(['recording']); + expect(expectedMeetingStates('recording-queued')).toEqual(['processing']); + expect(expectedMeetingStates('processing-completed')).toEqual(['completed']); + expect(expectedMeetingStates('capture-abandoned')).toEqual(['error', 'completed']); + }); + + test('confirms only the caller-owned meeting in an expected state', () => { + expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'completed' }, USER_ID, 'capture-abandoned')).toBe(true); + expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(true); + expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'processing' }, USER_ID, 'capture-abandoned')).toBe(false); + expect(confirmsMeetingTransition({ user_id: OTHER_USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(false); + expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'processing-completed')).toBe(false); + }); +}); diff --git a/apps/mobile-rn/__tests__/preferences-resync-redteam-r3-18.test.tsx b/apps/mobile-rn/__tests__/preferences-resync-redteam-r3-18.test.tsx new file mode 100644 index 0000000..991d6cc --- /dev/null +++ b/apps/mobile-rn/__tests__/preferences-resync-redteam-r3-18.test.tsx @@ -0,0 +1,148 @@ +import React, { useEffect } from 'react' +import { AppState, type AppStateStatus } from 'react-native' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import AsyncStorage from '@react-native-async-storage/async-storage' + +const mockMaybeSingle = jest.fn(async () => ({ + data: null, + error: { message: 'network down', code: 'NETWORK' }, +})) + +jest.mock('../src/lib/auth-context', () => ({ + useAuth: () => ({ user: { id: '22222222-2222-4222-8222-222222222222' } }), +})) + +jest.mock('../src/lib/supabase', () => { + const channel = { + on: () => channel, + subscribe: () => channel, + } + const builder = { + select: () => builder, + eq: () => builder, + maybeSingle: () => mockMaybeSingle(), + } + return { + supabase: { + from: () => builder, + channel: () => channel, + removeChannel: jest.fn(async () => 'ok'), + }, + } +}) + +import { + MobilePreferencesProvider, + useMobilePreferences, +} from '../src/lib/preferences-context' + +type RetrySync = () => Promise + +const loadingHistory: boolean[] = [] +let screenMounts = 0 +let latestRetrySync: RetrySync | null = null +let latestSyncStatus: string | null = null + +function Screen(): React.ReactElement | null { + useEffect(() => { + screenMounts += 1 + }, []) + return null +} + +function Gate(): React.ReactElement | null { + const { loading, retrySync, syncStatus } = useMobilePreferences() + loadingHistory.push(loading) + latestRetrySync = retrySync + latestSyncStatus = syncStatus + // Mirrors App.tsx: while loading, the navigation tree is replaced by a boot screen. + return loading ? null : +} + +async function flushAsync(): Promise { + for (let index = 0; index < 20; index += 1) { + await Promise.resolve() + } + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('mobile preferences background resync (redteam r3-18)', () => { + let appStateListeners: Array<(state: AppStateStatus) => void> + let renderer: ReactTestRenderer | null + + beforeEach(async () => { + await AsyncStorage.clear() + loadingHistory.length = 0 + screenMounts = 0 + latestRetrySync = null + latestSyncStatus = null + renderer = null + appStateListeners = [] + jest.spyOn(AppState, 'addEventListener').mockImplementation((type, listener) => { + if (type === 'change') { + appStateListeners.push(listener as (state: AppStateStatus) => void) + } + return { + remove: () => { + appStateListeners = appStateListeners.filter((entry) => entry !== listener) + }, + } + }) + }) + + afterEach(() => { + act(() => { + renderer?.unmount() + }) + jest.restoreAllMocks() + }) + + async function renderOffline(): Promise { + await act(async () => { + renderer = create( + + + , + ) + await flushAsync() + }) + expect(latestSyncStatus).toBe('offline') + expect(loadingHistory[loadingHistory.length - 1]).toBe(false) + expect(screenMounts).toBe(1) + loadingHistory.length = 0 + } + + test('foreground resync after an offline start never unmounts the app tree', async () => { + await renderOffline() + + act(() => { + for (const listener of appStateListeners) listener('active') + }) + await act(async () => { + await flushAsync() + }) + + expect(mockMaybeSingle.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(loadingHistory).not.toContain(true) + expect(screenMounts).toBe(1) + expect(latestSyncStatus).toBe('offline') + }) + + test('Settings retry with no pending patch keeps the current screen mounted', async () => { + await renderOffline() + const retry = latestRetrySync + if (retry === null) throw new Error('retrySync missing') + + let pending: Promise = Promise.resolve() + act(() => { + pending = retry() + }) + await act(async () => { + await pending + await flushAsync() + }) + + expect(loadingHistory).not.toContain(true) + expect(screenMounts).toBe(1) + }) +}) diff --git a/apps/mobile-rn/__tests__/preferences-store.test.ts b/apps/mobile-rn/__tests__/preferences-store.test.ts new file mode 100644 index 0000000..94471f0 --- /dev/null +++ b/apps/mobile-rn/__tests__/preferences-store.test.ts @@ -0,0 +1,356 @@ +import { + createPreferencesStore, + preferencesOwnerFor, + getUserCacheKey, + INSTALLATION_CACHE_KEY, + type PreferencesRemote, + type PreferencesStorage, + type PreferencesStore, + type PreferencesStoreSnapshot, + type RemoteInsertResult, + type UserSettingsRowMutation, +} from '../src/lib/preferences-store' + +const USER_A = 'user-a' +const USER_B = 'user-b' +const NOW = '2026-09-28T00:00:00.000Z' + +function createMemoryStorage(): PreferencesStorage & { data: Map, failReads: boolean, failWrites: boolean } { + const data = new Map() + const storage = { + data, + failReads: false, + failWrites: false, + async multiGet(keys: readonly string[]) { + if (storage.failReads) throw new Error('read failed') + return keys.map((key) => [key, data.get(key) ?? null] as const) + }, + async setItem(key: string, value: string) { + if (storage.failWrites) throw new Error('write failed') + data.set(key, value) + }, + async multiSet(pairs: Array<[string, string]>) { + if (storage.failWrites) throw new Error('write failed') + for (const [key, value] of pairs) data.set(key, value) + }, + async getAllKeys() { + return [...data.keys()] + }, + async multiRemove(keys: readonly string[]) { + for (const key of keys) data.delete(key) + }, + } + return storage +} + +function row(userId: string, overrides: Partial = {}): UserSettingsRowMutation { + return { + user_id: userId, + theme_mode: 'dark', + locale: 'en', + haptic_enabled: true, + auto_polish_enabled: true, + preferred_stt_model: null, + preferred_llm_model: null, + onboarding_version: 1, + tutorial_completed_at: null, + revision: 1, + ...overrides, + } +} + +interface FakeRemote extends PreferencesRemote { + rows: Map + failFetch: boolean + fetchRow: jest.Mock, [string]> + insertRow: jest.Mock, [UserSettingsRowMutation]> + updateRowAtRevision: jest.Mock, [UserSettingsRowMutation, number]> +} + +function createFakeRemote(): FakeRemote { + const rows = new Map() + const remote: FakeRemote = { + rows, + failFetch: false, + fetchRow: jest.fn(async (userId: string) => { + if (remote.failFetch) throw new Error('offline') + return rows.get(userId) ?? null + }), + insertRow: jest.fn(async (value: UserSettingsRowMutation): Promise => { + if (rows.has(value.user_id)) return { status: 'duplicate' } + rows.set(value.user_id, value) + return { status: 'inserted', row: value } + }), + updateRowAtRevision: jest.fn(async (value: UserSettingsRowMutation, expected: number) => { + const current = rows.get(value.user_id) + if (current === undefined || current.revision !== expected) return null + rows.set(value.user_id, value) + return value + }), + } + return remote +} + +async function settle(): Promise { + for (let index = 0; index < 30; index += 1) await Promise.resolve() +} + +function recordPhases(store: PreferencesStore): PreferencesStoreSnapshot[] { + const history: PreferencesStoreSnapshot[] = [] + store.subscribe(() => history.push(store.getSnapshot())) + return history +} + +describe('preferences store', () => { + test('first load of an owner enters initial-loading, then becomes ready', async () => { + const storage = createMemoryStorage() + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A)) + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + const history = recordPhases(store) + + await store.load(preferencesOwnerFor(USER_A)) + + expect(history[0]?.phase).toBe('initial-loading') + expect(store.getSnapshot()).toMatchObject({ + ownerKey: `user:${USER_A}`, + phase: 'ready', + syncStatus: 'synced', + errorCode: null, + lastSyncedAt: NOW, + }) + expect(store.getSnapshot().preferences.themeMode).toBe('dark') + }) + + test('resync after an offline start never re-enters initial-loading', async () => { + const storage = createMemoryStorage() + const remote = createFakeRemote() + remote.failFetch = true + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'offline' }) + expect(store.needsForegroundResync()).toBe(true) + + const history = recordPhases(store) + await store.resync() + remote.failFetch = false + remote.rows.set(USER_A, row(USER_A, { theme_mode: 'light', revision: 4 })) + await store.resync() + + expect(history.length).toBeGreaterThan(0) + expect(history.every((entry) => entry.phase === 'ready')).toBe(true) + expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' }) + expect(store.getSnapshot().preferences.themeMode).toBe('light') + expect(store.needsForegroundResync()).toBe(false) + }) + + test('reloading the same ready owner is a background refresh', async () => { + const store = createPreferencesStore({ + storage: createMemoryStorage(), + remote: createFakeRemote(), + now: () => NOW, + }) + await store.load(preferencesOwnerFor(null)) + const history = recordPhases(store) + + await store.load(preferencesOwnerFor(null)) + + expect(history.every((entry) => entry.phase === 'ready')).toBe(true) + }) + + test('concurrent resync calls share one refresh', async () => { + const remote = createFakeRemote() + remote.failFetch = true + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + remote.fetchRow.mockClear() + + await Promise.all([store.resync(), store.resync(), store.resync()]) + + expect(remote.fetchRow).toHaveBeenCalledTimes(1) + }) + + test('resync with a pending patch flushes it instead of reloading', async () => { + const storage = createMemoryStorage() + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A, { revision: 2 })) + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + + remote.failFetch = true + const result = await store.commit({ hapticEnabled: false }) + expect(result).toEqual({ localSaved: true, serverSynced: false }) + expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_FAILED' }) + + remote.failFetch = false + await store.resync() + + expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false, revision: 3 }) + expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' }) + }) + + test('revision conflicts are retried against the latest row', async () => { + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A, { revision: 5 })) + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + + remote.updateRowAtRevision.mockImplementationOnce(async () => { + // Another device bumps the revision between our read and write. + remote.rows.set(USER_A, row(USER_A, { revision: 6, locale: 'ko' })) + return null + }) + + const result = await store.commit({ themeMode: 'light' }) + + expect(result.serverSynced).toBe(true) + expect(remote.rows.get(USER_A)).toMatchObject({ theme_mode: 'light', locale: 'ko', revision: 7 }) + }) + + test('repeated revision conflicts surface SYNC_CONFLICT', async () => { + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A)) + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + remote.updateRowAtRevision.mockImplementation(async () => null) + + const result = await store.commit({ themeMode: 'light' }) + + expect(result.serverSynced).toBe(false) + expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(3) + expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_CONFLICT' }) + }) + + test('an insert race (unique violation) falls back to the revision-checked update', async () => { + const remote = createFakeRemote() + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + remote.insertRow.mockImplementationOnce(async () => { + remote.rows.set(USER_A, row(USER_A, { revision: 3 })) + return { status: 'duplicate' } + }) + + await store.load(preferencesOwnerFor(USER_A)) + + expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(1) + expect(remote.rows.get(USER_A)?.revision).toBe(4) + expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' }) + }) + + test('a stale server response is dropped after the owner switches', async () => { + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' })) + remote.rows.set(USER_B, row(USER_B, { theme_mode: 'light' })) + let releaseA: () => void = () => undefined + remote.fetchRow.mockImplementationOnce(async (userId: string) => { + await new Promise((resolve) => { releaseA = resolve }) + return remote.rows.get(userId) ?? null + }) + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + + const loadA = store.load(preferencesOwnerFor(USER_A)) + await settle() + await store.load(preferencesOwnerFor(USER_B)) + releaseA() + await loadA + + expect(store.getSnapshot()).toMatchObject({ ownerKey: `user:${USER_B}`, phase: 'ready' }) + expect(store.getSnapshot().preferences.themeMode).toBe('light') + }) + + test('realtime rows are ignored while local edits are pending', async () => { + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' })) + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + remote.failFetch = true + await store.commit({ themeMode: 'light' }) + + store.applyRemote(USER_A, row(USER_A, { theme_mode: 'system', revision: 9 })) + expect(store.getSnapshot().preferences.themeMode).toBe('light') + + store.applyRemote(USER_B, row(USER_B, { theme_mode: 'system' })) + expect(store.getSnapshot().preferences.themeMode).toBe('light') + }) + + test('realtime rows apply when nothing is pending', async () => { + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' })) + const storage = createMemoryStorage() + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + + store.applyRemote(USER_A, row(USER_A, { theme_mode: 'light', revision: 2 })) + await settle() + + expect(store.getSnapshot()).toMatchObject({ syncStatus: 'synced', phase: 'ready' }) + expect(store.getSnapshot().preferences.themeMode).toBe('light') + expect(storage.data.get(getUserCacheKey(USER_A))).toContain('"themeMode":"light"') + }) + + test('cache read failure outranks the sync failure code', async () => { + const storage = createMemoryStorage() + storage.failReads = true + const remote = createFakeRemote() + remote.failFetch = true + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + + await store.load(preferencesOwnerFor(USER_A)) + + expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_READ_FAILED' }) + }) + + test('cache write failure outranks the sync failure code', async () => { + const storage = createMemoryStorage() + storage.failWrites = true + const remote = createFakeRemote() + remote.failFetch = true + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + + await store.load(preferencesOwnerFor(USER_A)) + + expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_WRITE_FAILED' }) + }) + + test('signed-out commits stay local and persist a pending installation patch', async () => { + const storage = createMemoryStorage() + const remote = createFakeRemote() + const store = createPreferencesStore({ storage, remote, now: () => NOW }) + await store.load(preferencesOwnerFor(null)) + + const result = await store.commit({ locale: 'en' }) + + expect(result).toEqual({ localSaved: true, serverSynced: false }) + expect(store.getSnapshot()).toMatchObject({ syncStatus: 'local', ownerKey: 'installation' }) + expect(storage.data.get(INSTALLATION_CACHE_KEY)).toContain('"pendingPatch":{"locale":"en"}') + expect(remote.fetchRow).not.toHaveBeenCalled() + expect(store.needsForegroundResync()).toBe(false) + }) + + test('a commit made during a background refresh stays pending on top of the server value', async () => { + const remote = createFakeRemote() + remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark', revision: 2 })) + const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW }) + await store.load(preferencesOwnerFor(USER_A)) + remote.failFetch = true + await store.resync() // -> offline, nothing pending + remote.failFetch = false + + let releaseFetch: () => void = () => undefined + remote.fetchRow.mockImplementationOnce(async (userId: string) => { + await new Promise((resolve) => { releaseFetch = resolve }) + return remote.rows.get(userId) ?? null + }) + const refresh = store.resync() + await settle() + const commit = store.commit({ hapticEnabled: false }) + await settle() + releaseFetch() + await refresh + const result = await commit + + expect(result.serverSynced).toBe(true) + expect(store.getSnapshot().preferences.hapticEnabled).toBe(false) + expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false }) + expect(store.getSnapshot().phase).toBe('ready') + }) +}) diff --git a/apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts b/apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts new file mode 100644 index 0000000..c2a5676 --- /dev/null +++ b/apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts @@ -0,0 +1,314 @@ +import { FileSystem } from 'react-native-file-access' +import { AudioPipelineError, type LocalAudioInput } from '../src/features/import/audio-import-types' +import { transcribeAudioLocally } from '../src/features/import/local-whisper-transcription' +import { classifySttResponse, parseSttResult } from '../src/features/import/stt-cloud-client' +import { transcribeWithLocalFallback } from '../src/features/import/stt-engine' +import { isRetryable, shouldFallBackToLocal } from '../src/features/import/stt-policy' +import { processAudioInput } from '../src/features/import/audio-transcription-service' +import { transcribeTalkRecording } from '../src/features/talk/talk-transcription-service' +import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input' +import { + beginMeetingProcessing, + completeMeetingProcessing, +} from '../src/features/meetings/meetings-service' + +jest.mock('../src/features/import/local-whisper-transcription', () => ({ + transcribeAudioLocally: jest.fn(), +})) +jest.mock('../src/features/import/recorded-audio-input', () => ({ + prepareRecordedAudio: jest.fn(), +})) +jest.mock('../src/features/import/resumable-audio-upload', () => ({ + uploadAudioResumably: jest.fn(), +})) +jest.mock('../src/features/meetings/meetings-service', () => ({ + beginMeetingProcessing: jest.fn(), + completeMeetingProcessing: jest.fn(), +})) +jest.mock('../src/lib/native-config', () => ({ + getMobileRuntimeConfig: () => ({ appVersion: '1.9.0' }), +})) + +interface RecordedQuery { + table: string + calls: Array<[string, unknown[]]> +} +type QueryResult = { data: unknown; error: unknown } + +const mockQueries: RecordedQuery[] = [] +let mockResolveQuery: (query: RecordedQuery) => QueryResult = () => ({ data: null, error: null }) + +function mockBuilder(table: string): Record { + const recorded: RecordedQuery = { table, calls: [] } + mockQueries.push(recorded) + const builder: Record = {} + for (const method of ['select', 'insert', 'update', 'delete', 'eq', 'neq', 'order', 'limit']) { + builder[method] = (...args: unknown[]) => { + recorded.calls.push([method, args]) + return builder + } + } + for (const terminal of ['maybeSingle', 'single']) { + builder[terminal] = async () => { + recorded.calls.push([terminal, []]) + return mockResolveQuery(recorded) + } + } + builder.then = ( + onFulfilled: (value: QueryResult) => unknown, + onRejected?: (reason: unknown) => unknown, + ) => Promise.resolve(mockResolveQuery(recorded)).then(onFulfilled, onRejected) + return builder +} + +jest.mock('../src/lib/supabase', () => ({ + supabase: { + auth: { + getSession: jest.fn(async () => ({ + data: { session: { user: { id: '00000000-0000-4000-8000-000000000001' }, access_token: 'user-token' } }, + error: null, + })), + }, + from: jest.fn((table: string) => mockBuilder(table)), + }, +})) + +const originalFetch = global.fetch +const SHA = 'a'.repeat(64) +const USER_ID = '00000000-0000-4000-8000-000000000001' + +function input(): LocalAudioInput { + return { + uri: 'file:///cache/meeting.m4a', + path: '/cache/meeting.m4a', + fileName: 'meeting.m4a', + mimeType: 'audio/mp4', + sizeBytes: 3, + durationMs: 1_500, + source: 'recording', + dispose: jest.fn().mockResolvedValue(undefined), + } +} + +function uploadedAudio(meetingId: string | null): Record { + return { + id: 'audio-1', + user_id: USER_ID, + storage_key: `${USER_ID}/imports/${SHA}/meeting.m4a`, + upload_status: 'uploaded', + history_id: null, + meeting_id: meetingId, + } +} + +function historyWrites(): RecordedQuery[] { + return mockQueries.filter(query => + query.table === 'history' + && query.calls.some(([method]) => method === 'insert' || method === 'update')) +} + +function writtenPayload(query: RecordedQuery): Record { + const call = query.calls.find(([method]) => method === 'insert' || method === 'update') + return (call?.[1][0] ?? {}) as Record +} + +beforeEach(() => { + jest.clearAllMocks() + mockQueries.length = 0 + ;(FileSystem.hash as jest.Mock).mockResolvedValue(SHA) + ;(FileSystem.readFile as jest.Mock).mockResolvedValue('AQID') +}) + +afterEach(() => { + global.fetch = originalFetch +}) + +describe('stt-proxy status classification (single contract table)', () => { + it.each([ + [200, null], + [400, 'transcription'], + [401, 'auth'], + [403, 'auth'], + [413, 'file-too-large'], + [415, 'transcription'], + [422, 'no-speech'], + [429, 'quota'], + [500, 'transcription'], + [502, 'provider-unavailable'], + [503, 'provider-unavailable'], + [504, 'provider-unavailable'], + ])('HTTP %i -> %s', (status, code) => { + const error = classifySttResponse(status, '{"error":"x"}') + expect(error === null ? null : error.code).toBe(code) + }) + + it('keeps the server error code in the message for diagnostics', () => { + expect(classifySttResponse(422, '{"error":"stt_no_speech","attempts":[]}')?.message) + .toContain('stt_no_speech') + }) + + it('accepts a result without confidence and rejects an empty transcript', () => { + expect(parseSttResult({ + transcript: ' hi ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', + }, 5)).toMatchObject({ text: 'hi', confidence: null }) + expect(() => parseSttResult({ + transcript: ' ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', confidence: 1, + }, 5)).toThrow(expect.objectContaining({ code: 'transcription' })) + }) +}) + +describe('stt policy', () => { + it('only lets provider/transport failures fall back to on-device whisper', () => { + expect(shouldFallBackToLocal('provider-unavailable')).toBe(true) + expect(shouldFallBackToLocal('transcription')).toBe(true) + for (const code of ['no-speech', 'auth', 'quota', 'file-too-large', 'cancelled', 'file-read'] as const) { + expect(shouldFallBackToLocal(code)).toBe(false) + } + }) + + it('keeps retry separate from fallback: auth retries but never falls back, no-speech does neither', () => { + expect(isRetryable('auth')).toBe(true) + expect(shouldFallBackToLocal('auth')).toBe(false) + expect(isRetryable('no-speech')).toBe(false) + expect(isRetryable('quota')).toBe(false) + }) +}) + +describe('transcribeWithLocalFallback', () => { + const signal = new AbortController().signal + + it('does not run the local engine when the cloud reports no speech', async () => { + const local = jest.fn() + await expect(transcribeWithLocalFallback({ + input: input(), + languageCode: 'ko', + signal, + cloud: async () => { throw new AudioPipelineError('no-speech', 'silent') }, + local, + })).rejects.toMatchObject({ code: 'no-speech' }) + expect(local).not.toHaveBeenCalled() + }) + + it('turns an empty local transcript into no-speech instead of a result', async () => { + const onFallback = jest.fn() + await expect(transcribeWithLocalFallback({ + input: input(), + languageCode: 'ko', + signal, + cloud: async () => { throw new AudioPipelineError('provider-unavailable', 'down') }, + local: async () => ({ + text: '', confidence: null, language: 'ko', durationSeconds: 1, provider: 'local', latencyMs: 1, + }), + onFallback, + })).rejects.toMatchObject({ code: 'no-speech' }) + expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ code: 'provider-unavailable' })) + }) +}) + +describe('file/meeting pipeline with stt-proxy 422 stt_no_speech', () => { + function respondNoSpeech(): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 422, + text: async () => JSON.stringify({ error: 'stt_no_speech', attempts: [] }), + }) + global.fetch = fetchMock + return fetchMock + } + + it('fails a history transcription as no-speech without local fallback and records the failure', async () => { + mockResolveQuery = query => { + if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) { + return { data: uploadedAudio(null), error: null } + } + if (query.table === 'history') return { data: { id: 'history-1' }, error: null } + return { data: null, error: null } + } + const fetchMock = respondNoSpeech() + + await expect(processAudioInput(input(), { + expectedUserId: USER_ID, + languageCode: 'ko', + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'no-speech' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(transcribeAudioLocally).not.toHaveBeenCalled() + const writes = historyWrites() + expect(writes).toHaveLength(1) + expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' }) + }) + + it('fails a meeting transcription as no-speech without completing it or running whisper', async () => { + mockResolveQuery = query => { + if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) { + return { data: uploadedAudio('meeting-1'), error: null } + } + return { data: null, error: null } + } + ;(beginMeetingProcessing as jest.Mock).mockResolvedValue({ id: 'job-1' }) + respondNoSpeech() + + await expect(processAudioInput(input(), { + expectedUserId: USER_ID, + languageCode: 'ko', + signal: new AbortController().signal, + meetingId: 'meeting-1', + })).rejects.toMatchObject({ code: 'no-speech' }) + + expect(transcribeAudioLocally).not.toHaveBeenCalled() + expect(completeMeetingProcessing).not.toHaveBeenCalled() + }) + + it('does not save an empty on-device transcript as a completed history', async () => { + mockResolveQuery = query => { + if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) { + return { data: uploadedAudio(null), error: null } + } + if (query.table === 'history') return { data: { id: 'history-1' }, error: null } + return { data: null, error: null } + } + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '' }) + ;(transcribeAudioLocally as jest.Mock).mockResolvedValue({ + text: '', confidence: null, language: 'ko', durationSeconds: 1.5, provider: 'whisper.cpp-tiny-local', latencyMs: 10, + }) + + await expect(processAudioInput(input(), { + expectedUserId: USER_ID, + languageCode: 'ko', + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'no-speech' }) + + const writes = historyWrites() + expect(writes).toHaveLength(1) + expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' }) + }) +}) + +describe('Talk with stt-proxy 422 stt_no_speech', () => { + it('reports no-speech without running on-device whisper', async () => { + const recorded = input() + ;(prepareRecordedAudio as jest.Mock).mockResolvedValue(recorded) + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 422, + text: async () => JSON.stringify({ error: 'stt_no_speech' }), + }) + + await expect(transcribeTalkRecording({ + uri: recorded.uri, + path: recorded.path, + fileName: recorded.fileName, + mimeType: recorded.mimeType, + size: recorded.sizeBytes, + durationMs: 1_500, + }, { + accessToken: 'user-token', + languageCode: 'ko', + signal: new AbortController().signal, + disposeRecording: jest.fn(), + })).rejects.toMatchObject({ code: 'no-speech' }) + expect(transcribeAudioLocally).not.toHaveBeenCalled() + expect(recorded.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile-rn/src/features/account/account-deletion-service.ts b/apps/mobile-rn/src/features/account/account-deletion-service.ts new file mode 100644 index 0000000..bfee593 --- /dev/null +++ b/apps/mobile-rn/src/features/account/account-deletion-service.ts @@ -0,0 +1,122 @@ +// src/features/account/account-deletion-service.ts — account deletion use case. +// +// The screen only renders; the server contract (account-delete Edge Function), +// the failure mapping and the side-effect ordering live here so they can be +// tested without rendering UI. +import { + edgeFailureField, + invokeEdgeFunction, + type EdgeFunctionFailure, + type EdgeFunctionInvoker, +} from '../../lib/edge-functions' + +export type AccountDeletionFailureCode = + | 'SERVER_ENDPOINT_UNAVAILABLE' + | 'REAUTHENTICATION_REQUIRED' + | 'ACTIVE_SUBSCRIPTION' + | 'REQUEST_FAILED' + +export type AccountDeletionResult = + | { ok: true } + | { ok: false; code: AccountDeletionFailureCode; message?: string } + +/** Server-owned boundary for deleting auth.users and its cascaded account data. */ +export interface AccountDeletionService { + deleteCurrentAccount(): Promise +} + +export const ACCOUNT_DELETE_FUNCTION = 'account-delete' +export const ACCOUNT_DELETE_CONFIRMATION = 'DELETE_MY_ACCOUNT' + +/** + * account-delete puts the machine code in `code` and an English diagnostic in + * `error`. The diagnostic is kept for logs only; UI copy comes from i18n. + */ +export function mapAccountDeletionFailure(failure: EdgeFunctionFailure): AccountDeletionResult { + const serverCode = edgeFailureField(failure, 'code') + const message = edgeFailureField(failure, 'error') ?? failure.message ?? undefined + if (serverCode === 'REAUTHENTICATION_REQUIRED') { + return { ok: false, code: 'REAUTHENTICATION_REQUIRED', message } + } + if (serverCode === 'ACTIVE_SUBSCRIPTION') { + return { ok: false, code: 'ACTIVE_SUBSCRIPTION', message } + } + if (failure.status === 404) { + return { ok: false, code: 'SERVER_ENDPOINT_UNAVAILABLE', message } + } + return { ok: false, code: 'REQUEST_FAILED', message } +} + +export function createEdgeAccountDeletionService( + invoke: EdgeFunctionInvoker = invokeEdgeFunction, +): AccountDeletionService { + return Object.freeze({ + async deleteCurrentAccount(): Promise { + const result = await invoke<{ success?: unknown; error?: unknown }>( + ACCOUNT_DELETE_FUNCTION, + { confirmation: ACCOUNT_DELETE_CONFIRMATION }, + ) + if (!result.ok) return mapAccountDeletionFailure(result) + if (result.data?.success !== true) { + const message = typeof result.data?.error === 'string' ? result.data.error : undefined + return { ok: false, code: 'REQUEST_FAILED', message } + } + return { ok: true } + }, + }) +} + +export const edgeAccountDeletionService: AccountDeletionService = createEdgeAccountDeletionService() + +export type AccountDeletionFailureMessageKey = + | 'mobile.account.deleteUnavailable' + | 'mobile.account.reauthenticationRequired' + | 'mobile.account.deleteFailed' + +/** i18n key for a deletion failure. Raw server/SDK text is never shown. */ +export function accountDeletionFailureMessageKey( + code: AccountDeletionFailureCode, +): AccountDeletionFailureMessageKey { + switch (code) { + case 'SERVER_ENDPOINT_UNAVAILABLE': + return 'mobile.account.deleteUnavailable' + case 'REAUTHENTICATION_REQUIRED': + return 'mobile.account.reauthenticationRequired' + default: + return 'mobile.account.deleteFailed' + } +} + +export interface AccountDeletionWorkflow { + deletionService: AccountDeletionService + /** + * Detaches this device's push registration. Runs only after the server has + * confirmed deletion, so a refused deletion (re-auth, active subscription) + * leaves the still-live account's notifications intact. + */ + detachPushRegistration: () => Promise + /** Removes every account-owned local artifact and the secure session. */ + purgeLocalSession: () => Promise +} + +/** + * Deletes the current account. Order: server deletion → push detach + * (best-effort, needs the session) → local purge. A failed deletion performs + * no side effect. A purge failure propagates to the caller. + */ +export async function runAccountDeletion( + workflow: AccountDeletionWorkflow, +): Promise { + const result = await workflow.deletionService.deleteCurrentAccount() + if (!result.ok) return result + // The account no longer exists, so a push-detach failure must not block the + // local privacy purge; detachPushRegistrationForLogout already tolerates a + // single-boundary failure and only throws when both boundaries fail. + try { + await workflow.detachPushRegistration() + } catch { + // Best-effort by design; see above. + } + await workflow.purgeLocalSession() + return result +} diff --git a/apps/mobile-rn/src/features/import/audio-transcription-service.ts b/apps/mobile-rn/src/features/import/audio-transcription-service.ts index 7f5f533..eaa39e5 100644 --- a/apps/mobile-rn/src/features/import/audio-transcription-service.ts +++ b/apps/mobile-rn/src/features/import/audio-transcription-service.ts @@ -21,16 +21,20 @@ import { countTranscriptWords, decodeBase64, } from './audio-validation'; -import { createMultipartAudioBody } from './multipart-audio'; +import { createLinkedDeadline } from './linked-deadline'; import { transcribeAudioLocally } from './local-whisper-transcription'; import { uploadAudioResumably } from './resumable-audio-upload'; import { beginMeetingProcessing, completeMeetingProcessing, } from '../meetings/meetings-service'; +import { transcribeCloud } from './stt-cloud-client'; +import { transcribeWithLocalFallback } from './stt-engine'; +import { createLogger } from '../../lib/logger'; const UPLOAD_TIMEOUT_MS = 120_000; const TRANSCRIPTION_TIMEOUT_MS = 300_000; +const log = createLogger('AudioPipeline'); interface OperationState { audioFile: AudioFile | null; @@ -41,12 +45,6 @@ interface OperationState { processingJobId: string | null; } -interface LinkedDeadline { - signal: AbortSignal; - timedOut: () => boolean; - dispose: () => void; -} - function database(): D3roSupabaseClient { return supabase as unknown as D3roSupabaseClient; } @@ -78,31 +76,6 @@ async function requireCurrentSession( return session; } -function createLinkedDeadline( - parentSignal: AbortSignal, - timeoutMs: number, -): LinkedDeadline { - const controller = new AbortController(); - let didTimeOut = false; - const abortFromParent = (): void => controller.abort(); - parentSignal.addEventListener('abort', abortFromParent, { once: true }); - const timeout = setTimeout(() => { - didTimeOut = true; - controller.abort(); - }, timeoutMs); - - if (parentSignal.aborted) controller.abort(); - - return { - signal: controller.signal, - timedOut: () => didTimeOut, - dispose: () => { - clearTimeout(timeout); - parentSignal.removeEventListener('abort', abortFromParent); - }, - }; -} - function encodedStoragePath(storageKey: string): string { return storageKey.split('/').map(encodeURIComponent).join('/'); } @@ -259,122 +232,6 @@ async function uploadAudio( } } -function parseTranscriptionResponse( - value: unknown, - latencyMs: number, -): MobileTranscriptionResult { - if (typeof value !== 'object' || value === null) { - throw new AudioPipelineError( - 'transcription', - 'The transcription service returned an invalid response', - ); - } - const candidate = value as Record; - if ( - typeof candidate.transcript !== 'string' || - typeof candidate.confidence !== 'number' || - !Number.isFinite(candidate.confidence) || - typeof candidate.language_code !== 'string' || - candidate.language_code.trim() === '' || - typeof candidate.duration_seconds !== 'number' || - !Number.isFinite(candidate.duration_seconds) || - candidate.duration_seconds < 0 || - typeof candidate.provider !== 'string' || - candidate.provider.trim() === '' - ) { - throw new AudioPipelineError( - 'transcription', - 'The transcription service returned incomplete audio results', - ); - } - return { - text: candidate.transcript, - confidence: Math.max(0, Math.min(1, candidate.confidence)), - language: candidate.language_code, - durationSeconds: candidate.duration_seconds, - provider: candidate.provider, - latencyMs, - }; -} - -async function transcribeAudioFile( - input: LocalAudioInput, - audioBytes: Uint8Array, - session: Session, - languageCode: string, - parentSignal: AbortSignal, -): Promise { - const multipart = createMultipartAudioBody(input, audioBytes, languageCode); - - const deadline = createLinkedDeadline(parentSignal, TRANSCRIPTION_TIMEOUT_MS); - const startedAt = Date.now(); - try { - const response = await fetch(`${SUPABASE_URL}/functions/v1/stt-proxy`, { - method: 'POST', - headers: { - apikey: SUPABASE_ANON_KEY, - Authorization: `Bearer ${session.access_token}`, - 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, - }, - body: multipart.body, - signal: deadline.signal, - }); - - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - throw new AudioPipelineError( - 'auth', - 'The transcription session is no longer authorized', - ); - } - if (response.status === 413) { - throw new AudioPipelineError( - 'file-too-large', - 'The transcription service rejected the file size', - ); - } - if (response.status === 429) { - throw new AudioPipelineError( - 'quota', - 'Your transcription quota has been reached', - ); - } - if (response.status === 503) { - throw new AudioPipelineError( - 'provider-unavailable', - 'No transcription provider is currently available', - ); - } - throw new AudioPipelineError( - 'transcription', - `Transcription failed with status ${response.status}`, - ); - } - - const body: unknown = await response.json(); - return parseTranscriptionResponse(body, Date.now() - startedAt); - } catch (error) { - if (error instanceof AudioPipelineError) throw error; - if (parentSignal.aborted) { - throw new AudioPipelineError('cancelled', 'Transcription was cancelled'); - } - if (deadline.timedOut()) { - throw new AudioPipelineError( - 'provider-unavailable', - 'The transcription service timed out', - error, - ); - } - throw new AudioPipelineError( - 'transcription', - 'The transcription service could not be reached', - error, - ); - } finally { - deadline.dispose(); - } -} - function historyTitle(fileName: string): string { const withoutExtension = fileName.replace(/\.[^.]+$/, '').trim(); return (withoutExtension === '' ? 'Audio transcription' : withoutExtension).slice(0, 160); @@ -741,52 +598,28 @@ async function processCore( options.signal, ); state.lastAccessToken = transcriptionSession.access_token; - let transcription: MobileTranscriptionResult; - try { - transcription = await transcribeAudioFile( + const transcription: MobileTranscriptionResult = await transcribeWithLocalFallback({ + input, + languageCode: options.languageCode, + signal: options.signal, + cloud: () => transcribeCloud({ input, - audioBytes, - transcriptionSession, - options.languageCode, - options.signal, - ); - } catch (cloudError) { - if ( - !(cloudError instanceof AudioPipelineError) || - (cloudError.code !== 'provider-unavailable' && - cloudError.code !== 'transcription') - ) { - throw cloudError; - } - - audioBytes = new Uint8Array(0); - try { - transcription = await transcribeAudioLocally( - input, - options.languageCode, - options.signal, + bytes: audioBytes, + accessToken: transcriptionSession.access_token, + languageCode: options.languageCode, + signal: options.signal, + timeoutMs: TRANSCRIPTION_TIMEOUT_MS, + }), + local: transcribeAudioLocally, + onFallback: cloudError => { + // Release the uploaded bytes before on-device Whisper decodes the file. + audioBytes = new Uint8Array(0); + log.warn( + 'cloud STT failed, falling back to on-device whisper:', + `${cloudError.code}: ${cloudError.message}`, ); - } catch (localError) { - if ( - localError instanceof AudioPipelineError && - localError.code === 'cancelled' - ) { - throw localError; - } - if (localError instanceof AudioPipelineError) { - throw new AudioPipelineError( - localError.code, - localError.message, - { cloudError, localError }, - ); - } - throw new AudioPipelineError( - 'transcription', - 'Cloud and on-device transcription both failed', - { cloudError, localError }, - ); - } - } + }, + }); options.onPhase?.('saving'); await requireCurrentSession(options.expectedUserId, options.signal); diff --git a/apps/mobile-rn/src/features/import/linked-deadline.ts b/apps/mobile-rn/src/features/import/linked-deadline.ts new file mode 100644 index 0000000..c895c20 --- /dev/null +++ b/apps/mobile-rn/src/features/import/linked-deadline.ts @@ -0,0 +1,35 @@ +/** + * An AbortSignal that fires when either the parent signal aborts or the + * deadline elapses. `timedOut()` tells the two apart so callers can map a + * deadline to a retryable provider error and a parent abort to cancellation. + */ +export interface LinkedDeadline { + signal: AbortSignal; + timedOut: () => boolean; + dispose: () => void; +} + +export function createLinkedDeadline( + parentSignal: AbortSignal, + timeoutMs: number, +): LinkedDeadline { + const controller = new AbortController(); + let didTimeOut = false; + const abortFromParent = (): void => controller.abort(); + parentSignal.addEventListener('abort', abortFromParent, { once: true }); + const timeout = setTimeout(() => { + didTimeOut = true; + controller.abort(); + }, timeoutMs); + + if (parentSignal.aborted) controller.abort(); + + return { + signal: controller.signal, + timedOut: () => didTimeOut, + dispose: () => { + clearTimeout(timeout); + parentSignal.removeEventListener('abort', abortFromParent); + }, + }; +} diff --git a/apps/mobile-rn/src/features/import/stt-cloud-client.ts b/apps/mobile-rn/src/features/import/stt-cloud-client.ts new file mode 100644 index 0000000..807a7b4 --- /dev/null +++ b/apps/mobile-rn/src/features/import/stt-cloud-client.ts @@ -0,0 +1,170 @@ +import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config'; +import { + AudioPipelineError, + type LocalAudioInput, + type MobileTranscriptionResult, +} from './audio-import-types'; +import { createLinkedDeadline } from './linked-deadline'; +import { createMultipartAudioBody } from './multipart-audio'; + +/** + * The one mobile client for the `stt-proxy` edge function. Talk and the + * file/meeting pipeline both go through here so the HTTP contract (status + * table, response shape, timeout/cancel mapping) is defined exactly once. + */ + +export const STT_PROXY_ENDPOINT = `${SUPABASE_URL}/functions/v1/stt-proxy`; + +const MAX_BODY_SNIPPET = 160; + +export interface CloudSttRequest { + input: LocalAudioInput; + bytes: Uint8Array; + accessToken: string; + languageCode: string; + signal: AbortSignal; + /** Talk waits 120 s, the file/meeting pipeline 300 s. */ + timeoutMs: number; +} + +function bodySnippet(bodyText: string): string { + return bodyText.replace(/\s+/g, ' ').trim().slice(0, MAX_BODY_SNIPPET); +} + +function describe(status: number, bodyText: string): string { + const snippet = bodySnippet(bodyText); + return snippet.length === 0 ? `HTTP ${status}` : `HTTP ${status}: ${snippet}`; +} + +/** + * Maps an stt-proxy HTTP status (and its body, for diagnostics) to the + * pipeline error it stands for. Returns null for a 2xx answer. + * + * stt-proxy contract (server/supabase/functions/stt-proxy/handler.ts): + * - 401/403 auth rejected -> 'auth' + * - 413 audio_too_large -> 'file-too-large' + * - 422 stt_no_speech (quota already billed) -> 'no-speech' + * - 429 quota_exceeded -> 'quota' + * - 502 stt_upstream_failed / 503 stt_provider_unavailable / 504 gateway + * -> 'provider-unavailable' + * - anything else (400, 415, 500, ...) -> 'transcription' + */ +export function classifySttResponse( + status: number, + bodyText = '', +): AudioPipelineError | null { + if (status >= 200 && status < 300) return null; + const detail = describe(status, bodyText); + switch (status) { + case 401: + case 403: + return new AudioPipelineError('auth', `The speech session is no longer authorized (${detail})`); + case 413: + return new AudioPipelineError('file-too-large', `The speech service rejected the file size (${detail})`); + case 422: + return new AudioPipelineError('no-speech', `No speech was detected in the recording (${detail})`); + case 429: + return new AudioPipelineError('quota', `The speech transcription quota is exhausted (${detail})`); + case 502: + case 503: + case 504: + return new AudioPipelineError('provider-unavailable', `Cloud STT unavailable (${detail})`); + default: + return new AudioPipelineError('transcription', `Speech transcription failed (${detail})`); + } +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Parses a 2xx stt-proxy body. `confidence` is optional (not every provider + * reports one) and an empty transcript is rejected: stt-proxy reports silence + * as 422, so an empty 2xx transcript is an unusable response, not a result. + */ +export function parseSttResult(value: unknown, latencyMs: number): MobileTranscriptionResult { + if (typeof value !== 'object' || value === null) { + throw new AudioPipelineError('transcription', 'The speech service returned invalid data'); + } + const body = value as Record; + if ( + !nonEmptyString(body.transcript) + || !nonEmptyString(body.language_code) + || typeof body.duration_seconds !== 'number' + || !Number.isFinite(body.duration_seconds) + || body.duration_seconds < 0 + || !nonEmptyString(body.provider) + ) { + throw new AudioPipelineError('transcription', 'The speech service returned incomplete data'); + } + + const confidence = typeof body.confidence === 'number' && Number.isFinite(body.confidence) + ? Math.max(0, Math.min(1, body.confidence)) + : null; + return { + text: body.transcript.trim(), + confidence, + language: body.language_code.trim(), + durationSeconds: body.duration_seconds, + provider: body.provider.trim(), + latencyMs, + }; +} + +/** Rejects a missing bearer token before any audio leaves the device. */ +export function requireSttAccessToken(accessToken: string): string { + const token = accessToken.trim(); + if (token.length === 0) { + throw new AudioPipelineError('auth', 'Sign in to transcribe speech'); + } + return token; +} + +async function readBodyText(response: Response): Promise { + try { + return typeof response.text === 'function' ? await response.text() : ''; + } catch { + return ''; + } +} + +export async function transcribeCloud(request: CloudSttRequest): Promise { + const token = requireSttAccessToken(request.accessToken); + if (request.signal.aborted) { + throw new AudioPipelineError('cancelled', 'Speech transcription was cancelled'); + } + const multipart = createMultipartAudioBody(request.input, request.bytes, request.languageCode); + const deadline = createLinkedDeadline(request.signal, request.timeoutMs); + const startedAt = Date.now(); + + try { + const response = await fetch(STT_PROXY_ENDPOINT, { + method: 'POST', + headers: { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${token}`, + 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, + }, + body: multipart.body, + signal: deadline.signal, + }); + if (!response.ok) { + const failure = classifySttResponse(response.status, await readBodyText(response)) + ?? new AudioPipelineError('transcription', `Speech transcription failed (HTTP ${response.status})`); + throw failure; + } + return parseSttResult(await response.json(), Date.now() - startedAt); + } catch (error) { + if (error instanceof AudioPipelineError) throw error; + if (request.signal.aborted) { + throw new AudioPipelineError('cancelled', 'Speech transcription was cancelled', error); + } + if (deadline.timedOut()) { + throw new AudioPipelineError('provider-unavailable', 'Cloud speech recognition timed out', error); + } + throw new AudioPipelineError('transcription', 'Cloud speech recognition could not be reached', error); + } finally { + deadline.dispose(); + } +} diff --git a/apps/mobile-rn/src/features/import/stt-engine.ts b/apps/mobile-rn/src/features/import/stt-engine.ts new file mode 100644 index 0000000..7d9b976 --- /dev/null +++ b/apps/mobile-rn/src/features/import/stt-engine.ts @@ -0,0 +1,74 @@ +import { + AudioPipelineError, + type LocalAudioInput, + type MobileTranscriptionResult, +} from './audio-import-types'; +import { shouldFallBackToLocal } from './stt-policy'; + +/** + * Port for anything that turns a local audio file into a transcript. The + * on-device Whisper runner (`transcribeAudioLocally`) satisfies it directly. + */ +export type SttEngine = ( + input: LocalAudioInput, + languageCode: string, + signal: AbortSignal, +) => Promise; + +export interface FallbackTranscriptionRequest { + input: LocalAudioInput; + languageCode: string; + signal: AbortSignal; + /** The primary (cloud) attempt, already bound to its credentials and bytes. */ + cloud: () => Promise; + /** The secondary engine, used only when stt-policy allows it. */ + local: SttEngine; + /** Observes the cloud failure right before the local engine runs. */ + onFallback?: (cloudError: AudioPipelineError) => void; +} + +/** + * Shared "cloud first, on-device second" orchestration for Talk and the + * file/meeting pipeline: + * + * 1. Any cloud error that stt-policy does not mark as fallback-eligible is + * rethrown untouched (auth, quota, no-speech, cancelled, ...). + * 2. A local failure keeps its own code and message and carries both errors + * as its cause; a local cancellation is rethrown as-is. + * 3. An empty local transcript is 'no-speech', never a completed result. + */ +export async function transcribeWithLocalFallback( + request: FallbackTranscriptionRequest, +): Promise { + let cloudError: AudioPipelineError; + try { + return await request.cloud(); + } catch (error) { + if (!(error instanceof AudioPipelineError) || !shouldFallBackToLocal(error.code)) { + throw error; + } + cloudError = error; + } + + request.onFallback?.(cloudError); + + let result: MobileTranscriptionResult; + try { + result = await request.local(request.input, request.languageCode, request.signal); + } catch (localError) { + if (localError instanceof AudioPipelineError) { + if (localError.code === 'cancelled') throw localError; + throw new AudioPipelineError(localError.code, localError.message, { cloudError, localError }); + } + throw new AudioPipelineError( + 'transcription', + 'Cloud and on-device speech recognition both failed', + { cloudError, localError }, + ); + } + + if (result.text.trim().length === 0) { + throw new AudioPipelineError('no-speech', 'No speech was detected in the recording', cloudError); + } + return result; +} diff --git a/apps/mobile-rn/src/features/import/stt-policy.ts b/apps/mobile-rn/src/features/import/stt-policy.ts new file mode 100644 index 0000000..bb379e4 --- /dev/null +++ b/apps/mobile-rn/src/features/import/stt-policy.ts @@ -0,0 +1,45 @@ +import type { AudioPipelineErrorCode } from './audio-import-types'; + +/** + * Single source of truth for how the mobile STT pipelines react to an + * AudioPipelineError code. Pure and dependency-free so the durable queue, the + * file/meeting pipeline and Talk can all share it without pulling in IO. + * + * The two predicates are deliberately independent: an expired session + * ('auth') is worth retrying later from the queue, but must never be + * "rescued" by running on-device Whisper, which would bypass authentication + * and the server quota. + */ + +/** + * Cloud failures that may be answered by on-device Whisper instead. + * + * - 'provider-unavailable': no provider configured, upstream outage, timeout. + * - 'transcription': unreachable service or an unusable response. + * + * Everything else is authoritative and must surface as-is. In particular + * 'no-speech' (stt-proxy 422 stt_no_speech) is a real, already billed answer: + * re-running a local model on silent audio only produces an empty or + * hallucinated transcript. + */ +const LOCAL_FALLBACK_CODES: ReadonlySet = new Set([ + 'provider-unavailable', + 'transcription', +]); + +/** Failures a durable queue item may be attempted again for. */ +const RETRYABLE_CODES: ReadonlySet = new Set([ + 'upload', + 'provider-unavailable', + 'transcription', + 'persist', + 'auth', +]); + +export function shouldFallBackToLocal(code: AudioPipelineErrorCode): boolean { + return LOCAL_FALLBACK_CODES.has(code); +} + +export function isRetryable(code: AudioPipelineErrorCode): boolean { + return RETRYABLE_CODES.has(code); +} diff --git a/apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts b/apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts new file mode 100644 index 0000000..951a3ca --- /dev/null +++ b/apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts @@ -0,0 +1,110 @@ +import { AppState } from 'react-native' + +/** + * Realtime policy for the mobile Knowledge screen. + * + * Supabase Postgres Changes does not apply RLS to DELETE events and cannot + * filter them, so an unfiltered DELETE subscription would deliver every + * account's deleted primary keys to every subscriber and make each client + * refetch on every deletion anywhere (N-client fan-out). Only INSERT/UPDATE are + * subscribed (RLS-scoped, optionally narrowed to the owner), and deletions are + * reconciled by foreground polling, mirroring use-history-sync.ts. + */ + +export type KnowledgeRealtimeEvent = 'INSERT' | 'UPDATE' + +export interface KnowledgeRealtimeBinding { + event: KnowledgeRealtimeEvent + schema: 'public' + table: 'knowledge_documents' + filter?: string +} + +export const KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS = 45_000 + +const SUBSCRIBED_EVENTS: readonly KnowledgeRealtimeEvent[] = ['INSERT', 'UPDATE'] + +/** + * Pure: the postgres_changes bindings the Knowledge screen may open. DELETE is + * intentionally never included. `ownerId` must already be validated by the + * caller because it is interpolated into the realtime filter. + */ +export function knowledgeRealtimeBindings(ownerId?: string): KnowledgeRealtimeBinding[] { + return SUBSCRIBED_EVENTS.map((event) => ({ + event, + schema: 'public', + table: 'knowledge_documents', + ...(ownerId === undefined ? {} : { filter: `user_id=eq.${ownerId}` }), + })) +} + +/** Port: whether the app is in the foreground, and changes to that state. */ +export interface ForegroundStatePort { + isActive: () => boolean + onChange: (listener: (active: boolean) => void) => { remove: () => void } +} + +/** Port: interval scheduling, injectable for tests. */ +export interface IntervalPort { + set: (callback: () => void, ms: number) => ReturnType + clear: (handle: ReturnType) => void +} + +export interface ForegroundReconciliationDeps { + foreground?: ForegroundStatePort + interval?: IntervalPort + intervalMs?: number +} + +export interface ForegroundReconciliation { + stop: () => void +} + +export const reactNativeForegroundState: ForegroundStatePort = { + isActive: () => AppState.currentState === 'active', + onChange: (listener) => { + const subscription = AppState.addEventListener('change', (state) => { + listener(state === 'active') + }) + return { remove: () => subscription.remove() } + }, +} + +const systemInterval: IntervalPort = { + set: (callback, ms) => setInterval(callback, ms), + clear: (handle) => clearInterval(handle), +} + +/** + * Calls `onReconcile` periodically while the app is in the foreground and once + * whenever it returns to the foreground, so remote deletions are picked up + * without a DELETE realtime subscription. + */ +export function startForegroundReconciliation( + onReconcile: () => void, + deps: ForegroundReconciliationDeps = {}, +): ForegroundReconciliation { + const foreground = deps.foreground ?? reactNativeForegroundState + const interval = deps.interval ?? systemInterval + const intervalMs = deps.intervalMs ?? KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS + let stopped = false + let active = foreground.isActive() + + const stateSubscription = foreground.onChange((nextActive) => { + const resumed = nextActive && !active + active = nextActive + if (resumed && !stopped) onReconcile() + }) + const handle = interval.set(() => { + if (active && !stopped) onReconcile() + }, intervalMs) + + return { + stop: () => { + if (stopped) return + stopped = true + interval.clear(handle) + stateSubscription.remove() + }, + } +} diff --git a/apps/mobile-rn/src/features/knowledge/knowledge-service.ts b/apps/mobile-rn/src/features/knowledge/knowledge-service.ts index 882a433..5a6357f 100644 --- a/apps/mobile-rn/src/features/knowledge/knowledge-service.ts +++ b/apps/mobile-rn/src/features/knowledge/knowledge-service.ts @@ -7,6 +7,11 @@ import type { import { SUPABASE_URL } from '@d3ro/core/supabase-config' import { supabase } from '../../lib/supabase' import { createUuidV4 } from '../../lib/random-id' +import { + type ForegroundReconciliationDeps, + knowledgeRealtimeBindings, + startForegroundReconciliation, +} from './knowledge-realtime' export type MobileKnowledgeFileType = Extract @@ -607,31 +612,40 @@ export async function searchKnowledge( } } +export interface KnowledgeDocumentSubscriptionOptions { + /** + * Owner whose documents the list shows. When given, INSERT/UPDATE events are + * narrowed to that owner so teammates' shared-document writes do not trigger + * list refetches for a list that only shows owned documents. + */ + userId?: string + /** Injectable foreground/interval ports for deletion reconciliation. */ + reconciliation?: ForegroundReconciliationDeps +} + +/** + * Live updates for the Knowledge list. Only INSERT/UPDATE are subscribed: + * Postgres Changes cannot filter DELETE events and does not apply RLS to them, + * so a DELETE subscription would leak other accounts' document ids and fan out + * a refetch to every client on every deletion. Deletions are reconciled by + * foreground polling instead (same policy as use-history-sync.ts). + */ export function subscribeToKnowledgeDocuments( onChanged: () => void, onStatus: (connected: boolean) => void, + options: KnowledgeDocumentSubscriptionOptions = {}, ): KnowledgeDocumentSubscription { - const channel: RealtimeChannel = supabase - .channel(`mobile-knowledge-${createUuidV4()}`) - .on('postgres_changes', { - event: 'INSERT', - schema: 'public', - table: 'knowledge_documents', - }, onChanged) - .on('postgres_changes', { - event: 'UPDATE', - schema: 'public', - table: 'knowledge_documents', - }, onChanged) - .on('postgres_changes', { - event: 'DELETE', - schema: 'public', - table: 'knowledge_documents', - }, onChanged) - .subscribe((status) => onStatus(status === 'SUBSCRIBED')) + if (options.userId !== undefined) requireUuid(options.userId, 'knowledge owner id') + let channel: RealtimeChannel = supabase.channel(`mobile-knowledge-${createUuidV4()}`) + for (const binding of knowledgeRealtimeBindings(options.userId)) { + channel = channel.on('postgres_changes', binding, onChanged) + } + channel = channel.subscribe((status) => onStatus(status === 'SUBSCRIBED')) + const reconciliation = startForegroundReconciliation(onChanged, options.reconciliation) return { unsubscribe: async () => { + reconciliation.stop() await supabase.removeChannel(channel) }, } diff --git a/apps/mobile-rn/src/features/meetings/meeting-recording-state-policy.ts b/apps/mobile-rn/src/features/meetings/meeting-recording-state-policy.ts new file mode 100644 index 0000000..86da40b --- /dev/null +++ b/apps/mobile-rn/src/features/meetings/meeting-recording-state-policy.ts @@ -0,0 +1,39 @@ +import type { Meeting, MeetingStatus } from '@d3ro/api-client' + +/** + * Meeting states the server may answer with for each recording lifecycle + * transition. Pure policy: the RPC wrappers in meetings-service only decide + * whether a returned row confirms the transition they asked for. + * + * A capture that ends without a new transcript (cancelled, failed after + * upload, discarded while queued) leaves a meeting that still holds content + * from an earlier recording 'completed' (migrations 20260929002700, + * 20260929040000); only a meeting without earlier content becomes 'error'. + */ +export type MeetingRecordingTransition = + | 'recording-started' + | 'recording-queued' + | 'processing-completed' + | 'capture-abandoned' + +const EXPECTED_STATES: Readonly> = { + 'recording-started': ['recording'], + 'recording-queued': ['processing'], + 'processing-completed': ['completed'], + 'capture-abandoned': ['error', 'completed'], +} + +export function expectedMeetingStates( + transition: MeetingRecordingTransition, +): readonly MeetingStatus[] { + return EXPECTED_STATES[transition] +} + +/** True when `meeting` belongs to `userId` and is in a state `transition` may produce. */ +export function confirmsMeetingTransition( + meeting: Pick, + userId: string, + transition: MeetingRecordingTransition, +): boolean { + return meeting.user_id === userId && EXPECTED_STATES[transition].includes(meeting.status) +} diff --git a/apps/mobile-rn/src/features/meetings/meetings-service.ts b/apps/mobile-rn/src/features/meetings/meetings-service.ts index 4cc19a5..1265639 100644 --- a/apps/mobile-rn/src/features/meetings/meetings-service.ts +++ b/apps/mobile-rn/src/features/meetings/meetings-service.ts @@ -17,6 +17,10 @@ import { type LinkedAudioRef, type LinkedAudioStore, } from '../audio/linked-audio-cleanup' +import { + confirmsMeetingTransition, + type MeetingRecordingTransition, +} from './meeting-recording-state-policy' export interface MeetingListOptions { userId: string @@ -267,6 +271,18 @@ function assertMeetingRow(row: Meeting | null, expectedId?: string): Meeting { return row } +function assertMeetingTransition( + meeting: Meeting, + userId: string, + transition: MeetingRecordingTransition, + message: string, +): Meeting { + if (!confirmsMeetingTransition(meeting, userId, transition)) { + throw new MeetingServiceError('server', message) + } + return meeting +} + function assertProcessingJobRow(row: ProcessingJob | null): ProcessingJob { if (row === null) { throw new MeetingServiceError('server', 'Processing job was not returned') @@ -801,11 +817,12 @@ export async function beginMeetingRecording( 'mobile_begin_meeting_recording', { p_meeting_id: meetingId }, ) - const meeting = assertMeetingRow(row, meetingId) - if (meeting.user_id !== userId || meeting.status !== 'recording') { - throw new MeetingServiceError('server', 'Meeting recording state was not confirmed') - } - return meeting + return assertMeetingTransition( + assertMeetingRow(row, meetingId), + userId, + 'recording-started', + 'Meeting recording state was not confirmed', + ) } catch (error) { throw toMeetingServiceError(error) } @@ -859,11 +876,12 @@ export async function queueMeetingRecording( 'mobile_queue_meeting_recording', { p_meeting_id: meetingId, p_duration_ms: Math.round(durationMs) }, ) - const meeting = assertMeetingRow(row, meetingId) - if (meeting.user_id !== userId || meeting.status !== 'processing') { - throw new MeetingServiceError('server', 'Queued meeting state was not confirmed') - } - return meeting + return assertMeetingTransition( + assertMeetingRow(row, meetingId), + userId, + 'recording-queued', + 'Queued meeting state was not confirmed', + ) } catch (error) { throw toMeetingServiceError(error) } @@ -899,11 +917,12 @@ export async function completeMeetingProcessing( p_stt_latency_ms: Math.round(result.sttLatencyMs), }, ) - const meeting = assertMeetingRow(row, meetingId) - if (meeting.user_id !== userId || meeting.status !== 'completed') { - throw new MeetingServiceError('server', 'Meeting completion was not confirmed') - } - return meeting + return assertMeetingTransition( + assertMeetingRow(row, meetingId), + userId, + 'processing-completed', + 'Meeting completion was not confirmed', + ) } catch (error) { throw toMeetingServiceError(error) } @@ -952,11 +971,13 @@ export async function cancelMeetingRecording( 'mobile_cancel_meeting_recording', { p_meeting_id: meetingId }, ) - const meeting = assertMeetingRow(row, meetingId) - if (meeting.user_id !== userId || meeting.status !== 'error') { - throw new MeetingServiceError('server', 'Meeting cancellation was not confirmed') - } - return meeting + // A re-record of a meeting with earlier content returns to 'completed'. + return assertMeetingTransition( + assertMeetingRow(row, meetingId), + userId, + 'capture-abandoned', + 'Meeting cancellation was not confirmed', + ) } catch (error) { throw toMeetingServiceError(error) } @@ -978,11 +999,13 @@ export async function failMeetingRecording( 'mobile_fail_meeting_recording', { p_meeting_id: meetingId, p_error_message: safeMessage }, ) - const meeting = assertMeetingRow(row, meetingId) - if (meeting.user_id !== userId || meeting.status !== 'error') { - throw new MeetingServiceError('server', 'Meeting error state was not confirmed') - } - return meeting + // A re-record of a meeting with earlier content returns to 'completed'. + return assertMeetingTransition( + assertMeetingRow(row, meetingId), + userId, + 'capture-abandoned', + 'Meeting error state was not confirmed', + ) } catch (error) { throw toMeetingServiceError(error) } diff --git a/apps/mobile-rn/src/features/recording/durable-processing-queue.ts b/apps/mobile-rn/src/features/recording/durable-processing-queue.ts index 36e23c4..da57def 100644 --- a/apps/mobile-rn/src/features/recording/durable-processing-queue.ts +++ b/apps/mobile-rn/src/features/recording/durable-processing-queue.ts @@ -77,6 +77,12 @@ const listeners = new Set<(event: DurableQueueEvent) => void>(); const runningControllers = new Map(); const runningItemPromises = new Map>(); const discardedItems = new Set(); +/** + * Owners whose session was lost while their items stay parked on the device. + * Timer-driven passes skip them so a signed-out owner's retries are not spent + * on certain auth failures; resumeQueuedAudioForUser (sign-in) lifts it. + */ +const suspendedUsers = new Set(); function emit(event: DurableQueueEvent): void { for (const listener of listeners) listener(event); @@ -458,11 +464,13 @@ export async function processQueuedAudioNow( * overwritten from a stale snapshot. */ async function runResumePass(userId: string): Promise { + if (suspendedUsers.has(userId)) return; const order = (await readQueue()) .filter(item => item.userId === userId) .sort((left, right) => left.createdAtMs - right.createdAtMs) .map(item => item.id); for (const itemId of order) { + if (suspendedUsers.has(userId)) return; const item = await repository.get(itemId); if (item === undefined || item.userId !== userId) continue; if (item.status === 'terminal') continue; @@ -493,6 +501,7 @@ async function runResumePass(userId: string): Promise { * pass after it, and the returned promise settles when that pass is done. */ export function resumeQueuedAudioForUser(userId: string): Promise { + suspendedUsers.delete(userId); return scheduler.requestRun(userId); } @@ -545,6 +554,28 @@ export async function clearQueuedAudioForUser(userId: string): Promise { if (firstRemovalError !== null) throw firstRemovalError; } +/** + * Involuntary session loss: keeps every item of `ownerUserId` (files and + * entries, whatever their status) and suspends its retries until that owner + * signs back in; items of any other account are discarded as usual. + */ +export async function retainQueuedAudioOnlyForUser(ownerUserId: string): Promise { + suspendedUsers.add(ownerUserId); + scheduler.cancel(ownerUserId); + const otherUserIds = [...new Set((await readQueue()) + .map(item => item.userId) + .filter(userId => userId !== ownerUserId))]; + let firstError: unknown = null; + for (const userId of otherUserIds) { + try { + await clearQueuedAudioForUser(userId); + } catch (error) { + firstError ??= error; + } + } + if (firstError !== null) throw firstError; +} + export async function clearAllQueuedAudio(): Promise { const userIds = [...new Set((await readQueue()).map(item => item.userId))]; let firstError: unknown = null; diff --git a/apps/mobile-rn/src/features/talk/talk-transcription-service.ts b/apps/mobile-rn/src/features/talk/talk-transcription-service.ts index a711dc0..67d8d7b 100644 --- a/apps/mobile-rn/src/features/talk/talk-transcription-service.ts +++ b/apps/mobile-rn/src/features/talk/talk-transcription-service.ts @@ -1,15 +1,16 @@ -import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config' import { FileSystem } from 'react-native-file-access' import type { RecordedAudio } from '../../lib/audio-recorder' +import { createLogger } from '../../lib/logger' import { AudioPipelineError, type LocalAudioInput, type MobileTranscriptionResult, } from '../import/audio-import-types' import { decodeBase64 } from '../import/audio-validation' -import { createMultipartAudioBody } from '../import/multipart-audio' import { prepareRecordedAudio } from '../import/recorded-audio-input' import { transcribeAudioLocally } from '../import/local-whisper-transcription' +import { requireSttAccessToken, transcribeCloud } from '../import/stt-cloud-client' +import { transcribeWithLocalFallback } from '../import/stt-engine' interface TalkTranscriptionOptions { accessToken: string @@ -20,32 +21,7 @@ interface TalkTranscriptionOptions { } const DEFAULT_STT_TIMEOUT_MS = 120_000 - -interface LinkedDeadline { - signal: AbortSignal - didTimeOut: () => boolean - dispose: () => void -} - -function linkedDeadline(parent: AbortSignal, timeoutMs: number): LinkedDeadline { - const controller = new AbortController() - let timedOut = false - const abortFromParent = (): void => controller.abort() - parent.addEventListener('abort', abortFromParent, { once: true }) - if (parent.aborted) controller.abort() - const timer = setTimeout(() => { - timedOut = true - controller.abort() - }, timeoutMs) - return { - signal: controller.signal, - didTimeOut: () => timedOut, - dispose: () => { - clearTimeout(timer) - parent.removeEventListener('abort', abortFromParent) - }, - } -} +const log = createLogger('Talk') function ensureNotCancelled(signal: AbortSignal): void { if (signal.aborted) { @@ -53,38 +29,8 @@ function ensureNotCancelled(signal: AbortSignal): void { } } -function parseCloudResult(value: unknown, latencyMs: number): MobileTranscriptionResult { - if (typeof value !== 'object' || value === null) { - throw new AudioPipelineError('transcription', 'The speech service returned invalid data') - } - const body = value as Record - if ( - typeof body.transcript !== 'string' - || body.transcript.trim().length === 0 - || typeof body.language_code !== 'string' - || body.language_code.trim().length === 0 - || typeof body.duration_seconds !== 'number' - || !Number.isFinite(body.duration_seconds) - || body.duration_seconds < 0 - || typeof body.provider !== 'string' - || body.provider.trim().length === 0 - ) { - throw new AudioPipelineError('transcription', 'The speech service returned incomplete data') - } - - const confidence = typeof body.confidence === 'number' && Number.isFinite(body.confidence) - ? Math.max(0, Math.min(1, body.confidence)) - : null - return { - text: body.transcript.trim(), - confidence, - language: body.language_code.trim(), - durationSeconds: body.duration_seconds, - provider: body.provider.trim(), - latencyMs, - } -} - +// Talk reads the recording itself so the "file changed under us" size check +// stays next to the read, before any byte is sent to the cloud. async function readBytes(input: LocalAudioInput): Promise { try { const bytes = decodeBase64(await FileSystem.readFile(input.path, 'base64')) @@ -98,70 +44,22 @@ async function readBytes(input: LocalAudioInput): Promise { } } -async function transcribeCloud( +async function transcribeTalkCloud( input: LocalAudioInput, - accessToken: string, - languageCode: string, - signal: AbortSignal, - timeoutMs: number, + options: TalkTranscriptionOptions, ): Promise { - const token = accessToken.trim() - if (token.length === 0) throw new AudioPipelineError('auth', 'Sign in to transcribe speech') - ensureNotCancelled(signal) + const accessToken = requireSttAccessToken(options.accessToken) + ensureNotCancelled(options.signal) const bytes = await readBytes(input) - ensureNotCancelled(signal) - const multipart = createMultipartAudioBody(input, bytes, languageCode) - const deadline = linkedDeadline(signal, timeoutMs) - const startedAt = Date.now() - - try { - const response = await fetch(`${SUPABASE_URL}/functions/v1/stt-proxy`, { - method: 'POST', - headers: { - apikey: SUPABASE_ANON_KEY, - Authorization: `Bearer ${token}`, - 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, - }, - body: multipart.body, - signal: deadline.signal, - }) - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - throw new AudioPipelineError('auth', 'The speech session is no longer authorized') - } - if (response.status === 413) { - throw new AudioPipelineError('file-too-large', 'The recording is too large') - } - if (response.status === 429) { - throw new AudioPipelineError('quota', 'The speech transcription quota is exhausted') - } - if (response.status === 502 || response.status === 503 || response.status === 504) { - // 진단: 엣지함수 본문(stt_provider_unavailable=시크릿 없음 vs - // stt_upstream_failed=제공자 실패)을 로그에서 구분할 수 있게 남긴다. - const bodyText = await response.text().catch(() => '') - throw new AudioPipelineError( - 'provider-unavailable', - `Cloud STT unavailable (HTTP ${response.status}: ${bodyText.replace(/\s+/g, ' ').slice(0, 160)})`, - ) - } - throw new AudioPipelineError( - 'transcription', - `Speech transcription failed with status ${response.status}`, - ) - } - return parseCloudResult(await response.json(), Date.now() - startedAt) - } catch (error) { - if (error instanceof AudioPipelineError) throw error - if (signal.aborted) { - throw new AudioPipelineError('cancelled', 'Talk transcription was cancelled', error) - } - if (deadline.didTimeOut()) { - throw new AudioPipelineError('provider-unavailable', 'Cloud speech recognition timed out', error) - } - throw new AudioPipelineError('transcription', 'Cloud speech recognition could not be reached', error) - } finally { - deadline.dispose() - } + ensureNotCancelled(options.signal) + return transcribeCloud({ + input, + bytes, + accessToken, + languageCode: options.languageCode, + signal: options.signal, + timeoutMs: options.timeoutMs ?? DEFAULT_STT_TIMEOUT_MS, + }) } export async function transcribeTalkRecording( @@ -172,59 +70,18 @@ export async function transcribeTalkRecording( let primaryError: unknown = null try { - try { - return await transcribeCloud( - input, - options.accessToken, - options.languageCode, - options.signal, - options.timeoutMs ?? DEFAULT_STT_TIMEOUT_MS, - ) - } catch (cloudError) { - if ( - !(cloudError instanceof AudioPipelineError) - || cloudError.code === 'auth' - || cloudError.code === 'cancelled' - || cloudError.code === 'quota' - || cloudError.code === 'file-too-large' - ) { - throw cloudError - } + return await transcribeWithLocalFallback({ + input, + languageCode: options.languageCode, + signal: options.signal, + cloud: () => transcribeTalkCloud(input, options), + local: transcribeAudioLocally, // 진단: 클라우드가 실패해 기기 whisper로 폴백하는 이유를 남긴다. - console.warn( - '[Talk] cloud STT failed, falling back to on-device whisper:', - `${(cloudError as AudioPipelineError).code}: ${cloudError.message}`, - ) - - try { - const result = await transcribeAudioLocally( - input, - options.languageCode, - options.signal, - ) - if (result.text.trim().length === 0) { - // 기기 whisper는 실행됐지만 한국어 등을 인식하지 못한 경우 — - // "모두 실패"가 아니다. 클라우드 실패 원인도 원인 체인에 남긴다. - throw new AudioPipelineError( - 'no-speech', - 'No speech was detected in the recording', - cloudError, - ) - } - return result - } catch (localError) { - if (localError instanceof AudioPipelineError && localError.code === 'cancelled') { - throw localError - } - throw new AudioPipelineError( - localError instanceof AudioPipelineError ? localError.code : 'transcription', - localError instanceof Error - ? localError.message - : 'Cloud and on-device speech recognition both failed', - { cloudError, localError }, - ) - } - } + onFallback: cloudError => log.warn( + 'cloud STT failed, falling back to on-device whisper:', + `${cloudError.code}: ${cloudError.message}`, + ), + }) } catch (error) { primaryError = error throw error diff --git a/apps/mobile-rn/src/features/teams/team-service.ts b/apps/mobile-rn/src/features/teams/team-service.ts index 9ca90cf..45c2202 100644 --- a/apps/mobile-rn/src/features/teams/team-service.ts +++ b/apps/mobile-rn/src/features/teams/team-service.ts @@ -2,6 +2,7 @@ import type { RealtimeChannel } from '@supabase/supabase-js' import type { Meeting, Team, TeamRole } from '@d3ro/api-client' import { PUBLIC_SITE_ORIGIN, SITE_URLS } from '@d3ro/core/web-urls' import { supabase } from '../../lib/supabase' +import { invokeEdgeFunction, type EdgeFunctionFailure } from '../../lib/edge-functions' export interface TeamSummary extends Team { role: TeamRole @@ -407,23 +408,26 @@ async function invokeRpc(name: string, params: Record): Promise } } -async function readFunctionFailure(error: unknown): Promise { - const context = (error as { context?: unknown })?.context - if (context instanceof Response) { - try { - const payload: unknown = await context.clone().json() - if (isRecord(payload)) { - const code = typeof payload.error === 'string' ? payload.error : '' - const message = typeof payload.message === 'string' - ? payload.message - : code || `Team function returned ${context.status}` - return toTeamServiceError({ code, message }) - } - } catch { - return toTeamServiceError({ message: `Team function returned ${context.status}` }) +/** + * Team Edge Functions put the machine code in `error` and a human string in + * `message`. The transport decoding lives in lib/edge-functions; this keeps + * only the team-specific field selection and fallbacks. + */ +function readFunctionFailure(failure: EdgeFunctionFailure): TeamServiceError { + if (failure.status !== null && failure.body !== null) { + if (!failure.body.readable) { + return toTeamServiceError({ message: `Team function returned ${failure.status}` }) + } + const payload = failure.body.payload + if (isRecord(payload)) { + const code = typeof payload.error === 'string' ? payload.error : '' + const message = typeof payload.message === 'string' + ? payload.message + : code || `Team function returned ${failure.status}` + return toTeamServiceError({ code, message }) } } - return toTeamServiceError(error) + return toTeamServiceError(failure.cause) } export async function listTeams(userId: string): Promise { @@ -705,10 +709,9 @@ export async function createTeamInvite( throw new TeamServiceError('validation', 'Invite role is invalid') } try { - const { data, error } = await supabase.functions.invoke('team-invite', { - body: { team_id: teamId, email, role }, - }) - if (error !== null) throw await readFunctionFailure(error) + const result = await invokeEdgeFunction('team-invite', { team_id: teamId, email, role }) + if (!result.ok) throw readFunctionFailure(result) + const data = result.data if (!isRecord(data)) { throw new TeamServiceError('invalid-response', 'Invite response is not an object') } @@ -746,10 +749,9 @@ export async function cancelTeamInvite(teamId: string, inviteId: string): Promis export async function acceptTeamInvite(tokenValue: string): Promise { const token = normalizeInviteToken(tokenValue) try { - const { data, error } = await supabase.functions.invoke('team-accept', { - body: { token }, - }) - if (error !== null) throw await readFunctionFailure(error) + const result = await invokeEdgeFunction('team-accept', { token }) + if (!result.ok) throw readFunctionFailure(result) + const data = result.data if (!isRecord(data)) { throw new TeamServiceError('invalid-response', 'Accept response is not an object') } diff --git a/apps/mobile-rn/src/lib/account-local-data.ts b/apps/mobile-rn/src/lib/account-local-data.ts index 1586714..a8d0cf3 100644 --- a/apps/mobile-rn/src/lib/account-local-data.ts +++ b/apps/mobile-rn/src/lib/account-local-data.ts @@ -1,11 +1,21 @@ import { clearAllActionHistories } from '../features/actions/action-service' import { clearAllHistoryCaches } from '../features/history/history-cache' import { deleteNativePushRegistration } from '../features/notifications/notification-native' -import { clearAllQueuedAudio } from '../features/recording/durable-processing-queue' +import { + clearAllQueuedAudio, + retainQueuedAudioOnlyForUser, +} from '../features/recording/durable-processing-queue' import { clearEveryGenerationIdempotencyKey } from '../features/templates' import { audioRecorder } from './audio-recorder' +import { + DISCARD_UNSYNCED_WORK, + unsyncedWorkDispositionKey, + type UnsyncedWorkDisposition, +} from './auth-transition-policy' import { clearAllEntitlementCaches } from './entitlement-context' import { clearAllUserPreferenceCaches } from './preferences-context' +import { stopLiveCaptureKeepingFile } from './retain-live-capture' +import { retainedAccountWork } from './retained-account-work' export class AccountLocalDataPurgeError extends Error { readonly code = 'account_local_data_purge_failed' @@ -16,34 +26,88 @@ export class AccountLocalDataPurgeError extends Error { } } -let purgeInFlight: Promise | null = null +type PurgeTask = () => Promise + +/** Account-scoped caches: always removed, they can be rebuilt from the server. */ +const ACCOUNT_CACHE_TASKS: readonly PurgeTask[] = [ + clearAllHistoryCaches, + clearAllEntitlementCaches, + clearAllUserPreferenceCaches, + clearAllActionHistories, + clearEveryGenerationIdempotencyKey, + deleteNativePushRegistration, +] /** - * Removes every account-scoped local artifact before an auth boundary opens. + * Unsynced user-owned work (the live capture and durable-queue recordings) is + * the only copy of that audio. It is deleted only for a discard boundary; on a + * retain boundary the microphone is stopped, the owner's files are kept and + * the owner is recorded so a different account can still never see them. + */ +function unsyncedWorkTasks(disposition: UnsyncedWorkDisposition): PurgeTask[] { + if (disposition.kind === 'discard') { + return [ + () => audioRecorder.cancel(), + clearAllQueuedAudio, + () => retainedAccountWork.release(), + ] + } + const owner = disposition.ownerUserId + return [ + () => retainedAccountWork.retain(owner), + () => stopLiveCaptureKeepingFile(audioRecorder), + () => retainQueuedAudioOnlyForUser(owner), + ] +} + +function startTask(task: PurgeTask): Promise { + try { + return Promise.resolve(task()) + } catch (error) { + return Promise.reject(error) + } +} + +async function runPurge(disposition: UnsyncedWorkDisposition): Promise { + const results = await Promise.allSettled( + [...ACCOUNT_CACHE_TASKS, ...unsyncedWorkTasks(disposition)].map(startTask), + ) + if (results.some((result) => result.status === 'rejected')) { + throw new AccountLocalDataPurgeError() + } +} + +interface PurgeRun { + key: string + promise: Promise +} + +let purgeInFlight: PurgeRun | null = null + +/** + * Removes account-scoped local artifacts before an auth boundary opens. * All tasks are allowed to settle so an early failure cannot skip deletion of * unrelated caches or raw audio; callers receive only a stable public code. + * + * Concurrent calls with the same disposition share one run; a call with a + * different disposition (for example a sign-out immediately replaced by + * another account) runs after the current one so its stricter cleanup is + * never swallowed by the earlier, weaker one. */ -export function purgeAllAccountLocalData(): Promise { - if (purgeInFlight !== null) return purgeInFlight +export function purgeAllAccountLocalData( + unsyncedWork: UnsyncedWorkDisposition = DISCARD_UNSYNCED_WORK, +): Promise { + const key = unsyncedWorkDispositionKey(unsyncedWork) + const previous = purgeInFlight + if (previous !== null && previous.key === key) return previous.promise - const operation = (async (): Promise => { - const results = await Promise.allSettled([ - clearAllHistoryCaches(), - clearAllEntitlementCaches(), - clearAllUserPreferenceCaches(), - audioRecorder.cancel(), - clearAllQueuedAudio(), - clearAllActionHistories(), - clearEveryGenerationIdempotencyKey(), - deleteNativePushRegistration(), - ]) - if (results.some((result) => result.status === 'rejected')) { - throw new AccountLocalDataPurgeError() - } - })().finally(() => { - if (purgeInFlight === operation) purgeInFlight = null + const run = previous === null + ? runPurge(unsyncedWork) + : previous.promise.catch(() => undefined).then(() => runPurge(unsyncedWork)) + const promise: Promise = run.finally(() => { + if (purgeInFlight?.promise === promise) purgeInFlight = null }) - purgeInFlight = operation - return operation + purgeInFlight = { key, promise } + return promise } diff --git a/apps/mobile-rn/src/lib/auth-context.tsx b/apps/mobile-rn/src/lib/auth-context.tsx index b6e449c..e47fb42 100644 --- a/apps/mobile-rn/src/lib/auth-context.tsx +++ b/apps/mobile-rn/src/lib/auth-context.tsx @@ -14,6 +14,8 @@ import { supabase, isSupabaseConfigured } from './supabase' import { completeAuthRedirect, isAuthRedirectUrl } from './auth-redirect' import { clearAllSecureAuthStorage } from './secure-auth-storage' import { purgeAllAccountLocalData } from './account-local-data' +import { DISCARD_UNSYNCED_WORK, planAuthTransition } from './auth-transition-policy' +import { retainedAccountWork } from './retained-account-work' export type AuthPrivacyCleanupState = 'ready' | 'purging' | 'failed' @@ -106,12 +108,19 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React ): Promise => { const generation = ++transitionGenerationRef.current const previousUserId = committedSessionRef.current?.user.id ?? null - const nextUserId = transition.session?.user.id ?? null - const needsPurge = transition.forcePurge - || privacyCleanupStateRef.current !== 'ready' - || (previousUserId !== null && previousUserId !== nextUserId) + const plan = planAuthTransition({ + previousUserId, + nextUserId: transition.session?.user.id ?? null, + retainedOwnerUserId: retainedAccountWork.current(), + forcePurge: transition.forcePurge, + cleanupPending: privacyCleanupStateRef.current !== 'ready', + explicit: transition.explicit, + }) - if (!needsPurge) { + if (!plan.purge) { + // The owner of work kept by an earlier session loss is back: the queue + // hook and Record screen resume it, so the retention marker can go. + if (plan.releaseRetainedWork) void retainedAccountWork.release().catch(() => undefined) pendingTransitionRef.current = null commitSession(transition.session, transition.event) updatePrivacyCleanupState('ready') @@ -122,7 +131,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React updatePrivacyCleanupState('purging') try { - await purgeAllAccountLocalData() + await purgeAllAccountLocalData(plan.unsyncedWork) if (generation !== transitionGenerationRef.current) return false if (transition.clearSecureAuth) { @@ -130,21 +139,31 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React // A SIGNED_OUT notification can race with a direct A -> B account // replacement. Preserve a newer different-account session that the // auth client has already committed to secure storage. + let replacementSession: Session | null = null try { const { data } = await supabase.auth.getSession() if (generation !== transitionGenerationRef.current) return false const latestSession = data.session const latestUserId = latestSession?.user.id ?? null if (latestSession !== null && latestUserId !== previousUserId) { - pendingTransitionRef.current = null - commitSession(latestSession, 'SIGNED_IN') - updatePrivacyCleanupState('ready') - return true + replacementSession = latestSession } } catch { // If the auth client cannot prove that a newer session exists, // fail closed by removing the signed-out account's secure data. } + if (replacementSession !== null) { + if (plan.unsyncedWork.kind === 'retain') { + // Work kept for the signed-out account must not stay on a + // device that another account has already taken over. + await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK) + if (generation !== transitionGenerationRef.current) return false + } + pendingTransitionRef.current = null + commitSession(replacementSession, 'SIGNED_IN') + updatePrivacyCleanupState('ready') + return true + } } try { @@ -228,6 +247,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React } try { + // The retained-work owner must be known before the first boundary + // decides between keeping and discarding unsynced recordings. + await retainedAccountWork.load().catch(() => null) const initialUrl = await Linking.getInitialURL() if (initialUrl !== null) await handleUrl(initialUrl) const { data: { session: restoredSession }, error } = await supabase.auth.getSession() diff --git a/apps/mobile-rn/src/lib/auth-redirect.ts b/apps/mobile-rn/src/lib/auth-redirect.ts index 7bf475a..278bf8e 100644 --- a/apps/mobile-rn/src/lib/auth-redirect.ts +++ b/apps/mobile-rn/src/lib/auth-redirect.ts @@ -6,22 +6,19 @@ export const AUTH_REDIRECT_URL = 'd3ro-voice://auth-callback' export type AuthRedirectType = 'recovery' | 'signed-in' | 'cancelled' | 'ignored' +/** + * The only auth operation a callback may drive. A session is created solely by + * a PKCE code exchange, which the auth server binds to the code verifier this + * device stored when it started the flow. + */ type AuthRedirectOperations = { exchangeCodeForSession: typeof supabase.auth.exchangeCodeForSession - setSession: typeof supabase.auth.setSession } type ParsedAuthRedirect = | { kind: 'ignored' } | { kind: 'cancelled' } | { kind: 'code'; code: string; recovery: boolean; replayKey: string } - | { - kind: 'tokens' - accessToken: string - refreshToken: string - recovery: boolean - replayKey: string - } export class AuthRedirectError extends Error { readonly code: 'invalid_callback' | 'provider_error' @@ -46,8 +43,8 @@ function hasAuthMaterial(parsedUrl: URL): boolean { || fragment.has('error_description') } -function replayFingerprint(kind: 'code' | 'tokens', secret: string): string { - return `${kind}:${bytesToHex(sha256(utf8ToBytes(secret)))}` +function replayFingerprint(secret: string): string { + return `code:${bytesToHex(sha256(utf8ToBytes(secret)))}` } export function isAuthRedirectUrl(url: string): boolean { @@ -89,34 +86,23 @@ function parseAuthRedirect(url: string): ParsedAuthRedirect { throw new AuthRedirectError('provider_error') } - const code = query.get('code') - const accessToken = fragment.get('access_token') - const refreshToken = fragment.get('refresh_token') - const hasCode = typeof code === 'string' && code.length > 0 - const hasAccessToken = typeof accessToken === 'string' && accessToken.length > 0 - const hasRefreshToken = typeof refreshToken === 'string' && refreshToken.length > 0 + // Implicit-flow session material (#access_token/#refresh_token) is never + // accepted. The client runs every flow as PKCE, so a legitimate callback + // carries only ?code=. A bare token pair is bound to nothing this device + // started: any web page or app could open it and sign the device into + // another account (login CSRF), which would also trigger the account-switch + // purge of this user's local data. + if (fragment.has('access_token') || fragment.has('refresh_token')) { + throw new AuthRedirectError('invalid_callback') + } - if (hasCode && (hasAccessToken || hasRefreshToken)) { - throw new AuthRedirectError('invalid_callback') - } - if (hasAccessToken !== hasRefreshToken) { - throw new AuthRedirectError('invalid_callback') - } - if (hasCode) { + const code = query.get('code') + if (typeof code === 'string' && code.length > 0) { return { kind: 'code', code, recovery: query.get('type') === 'recovery', - replayKey: replayFingerprint('code', code), - } - } - if (hasAccessToken && hasRefreshToken) { - return { - kind: 'tokens', - accessToken, - refreshToken, - recovery: fragment.get('type') === 'recovery', - replayKey: replayFingerprint('tokens', `${accessToken}\u0000${refreshToken}`), + replayKey: replayFingerprint(code), } } return { kind: 'ignored' } @@ -146,21 +132,12 @@ export function createAuthRedirectHandler( if (existing !== undefined) return existing const operation = (async (): Promise => { - if (parsed.kind === 'code') { - const { data, error } = await auth.exchangeCodeForSession(parsed.code) - if (error !== null) throw error - const redirectData = data as typeof data & { redirectType?: string | null } - return parsed.recovery || redirectData.redirectType === 'recovery' - ? 'recovery' - : 'signed-in' - } - - const { error } = await auth.setSession({ - access_token: parsed.accessToken, - refresh_token: parsed.refreshToken, - }) + const { data, error } = await auth.exchangeCodeForSession(parsed.code) if (error !== null) throw error - return parsed.recovery ? 'recovery' : 'signed-in' + const redirectData = data as typeof data & { redirectType?: string | null } + return parsed.recovery || redirectData.redirectType === 'recovery' + ? 'recovery' + : 'signed-in' })() inFlight.set(parsed.replayKey, operation) diff --git a/apps/mobile-rn/src/lib/auth-transition-policy.ts b/apps/mobile-rn/src/lib/auth-transition-policy.ts new file mode 100644 index 0000000..9149054 --- /dev/null +++ b/apps/mobile-rn/src/lib/auth-transition-policy.ts @@ -0,0 +1,69 @@ +/** + * Pure policy for the mobile auth privacy boundary: given who was signed in, + * who is signing in, and whose unsynced work is parked on the device, decide + * whether local account data must be purged and what happens to the user's + * unsynced recordings (durable-queue items and the live capture file). + * + * Caches are always purged when the boundary runs. Unsynced work is different: + * it is the only copy of the user's audio, and queue items are already keyed + * by their owner, so it is kept across an involuntary session loss (a revoked + * refresh token, a password change on another device, a cold boot without a + * session) and deleted only when another account takes the device or the user + * signs out on purpose. + */ + +export type UnsyncedWorkDisposition = + | { readonly kind: 'discard' } + | { readonly kind: 'retain'; readonly ownerUserId: string } + +export const DISCARD_UNSYNCED_WORK: UnsyncedWorkDisposition = Object.freeze({ kind: 'discard' }) + +export interface AuthTransitionFacts { + /** Account whose session is currently committed to the UI. */ + previousUserId: string | null + /** Account the transition would commit, or null for a signed-out state. */ + nextUserId: string | null + /** Account whose unsynced work was kept by an earlier session loss. */ + retainedOwnerUserId: string | null + /** The caller requires the boundary even for an unchanged account. */ + forcePurge: boolean + /** An earlier boundary has not completed yet. */ + cleanupPending: boolean + /** The user asked to sign out (logout, account deletion, device revoke). */ + explicit: boolean +} + +export type AuthTransitionPlan = + | { + readonly purge: false + /** The retained work's owner is back; the retention marker can go. */ + readonly releaseRetainedWork: boolean + } + | { readonly purge: true; readonly unsyncedWork: UnsyncedWorkDisposition } + +export function planAuthTransition(facts: AuthTransitionFacts): AuthTransitionPlan { + const workOwner = facts.previousUserId ?? facts.retainedOwnerUserId + const accountLeaves = facts.previousUserId !== null && facts.previousUserId !== facts.nextUserId + const otherAccountArrives = facts.nextUserId !== null + && workOwner !== null + && workOwner !== facts.nextUserId + + if (!facts.forcePurge && !facts.cleanupPending && !accountLeaves && !otherAccountArrives) { + return { + purge: false, + releaseRetainedWork: facts.retainedOwnerUserId !== null + && facts.retainedOwnerUserId === facts.nextUserId, + } + } + + const involuntarySessionLoss = facts.nextUserId === null && !facts.explicit + if (involuntarySessionLoss && workOwner !== null) { + return { purge: true, unsyncedWork: { kind: 'retain', ownerUserId: workOwner } } + } + return { purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK } +} + +/** Stable key so concurrent boundaries with the same disposition share one run. */ +export function unsyncedWorkDispositionKey(disposition: UnsyncedWorkDisposition): string { + return disposition.kind === 'retain' ? `retain:${disposition.ownerUserId}` : 'discard' +} diff --git a/apps/mobile-rn/src/lib/billing-context.tsx b/apps/mobile-rn/src/lib/billing-context.tsx index 0fd64e2..c6d3c8d 100644 --- a/apps/mobile-rn/src/lib/billing-context.tsx +++ b/apps/mobile-rn/src/lib/billing-context.tsx @@ -27,7 +27,12 @@ import { type EntitlementSnapshot, type MobileSubscriptionTier, } from './entitlement-context' -import { supabase } from './supabase' +import { + invokeEdgeFunction, + readEdgeFunctionHttpFailure, + type EdgeFunctionFailure, + type EdgeFunctionHttpBody, +} from './edge-functions' import { getMobileRuntimeConfig } from './native-config' export const GOOGLE_PLAY_SUBSCRIPTION_IDS = [ @@ -256,21 +261,37 @@ export async function restoreGooglePlayPurchaseSet( return refreshed } -async function functionErrorCode(candidate: unknown): Promise { - if (candidate && typeof candidate === 'object' && 'context' in candidate) { - const context = (candidate as { context?: unknown }).context - if (context instanceof Response) { - try { - const body = await context.clone().json() as { error?: unknown } - if (typeof body.error === 'string') return body.error - } catch { - return 'purchase_verification_failed' - } - } +const DEFAULT_VERIFICATION_ERROR = 'purchase_verification_failed' + +/** + * Billing Edge Functions put the machine code in `error`. Returns null when + * the body carries no usable code so callers fall back to the Error message. + */ +function billingCodeFromBody(body: EdgeFunctionHttpBody): string | null { + if (!body.readable || body.payload === null) return DEFAULT_VERIFICATION_ERROR + const payload = body.payload + if (typeof payload === 'object') { + const code = (payload as { error?: unknown }).error + if (typeof code === 'string') return code } + return null +} + +function errorMessageOrDefault(candidate: unknown): string { return candidate instanceof Error && candidate.message ? candidate.message - : 'purchase_verification_failed' + : DEFAULT_VERIFICATION_ERROR +} + +function billingFailureCode(failure: EdgeFunctionFailure): string { + const code = failure.body === null ? null : billingCodeFromBody(failure.body) + return code ?? errorMessageOrDefault(failure.cause) +} + +async function functionErrorCode(candidate: unknown): Promise { + const http = await readEdgeFunctionHttpFailure(candidate) + const code = http === null ? null : billingCodeFromBody(http.body) + return code ?? errorMessageOrDefault(candidate) } export function BillingProvider({ children }: { children: ReactNode }): React.ReactElement { @@ -306,14 +327,13 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re setOperation(purchase.purchaseState === 'pending' ? 'pending' : 'verifying') setErrorCode(null) try { - const { data, error } = await supabase.functions.invoke('iap-verify', { - body: { - platform: 'google_play', - productId: purchase.productId, - purchaseToken: purchase.purchaseToken, - }, + const result = await invokeEdgeFunction('iap-verify', { + platform: 'google_play', + productId: purchase.productId, + purchaseToken: purchase.purchaseToken, }) - if (error) throw new Error(await functionErrorCode(error)) + if (!result.ok) throw new Error(billingFailureCode(result)) + const data = result.data const verifiedProduct = verifiedRestorableProduct(data, purchase.productId) const verification = data && isRecord(data.verification) ? data.verification : null const refreshed = await entitlement.refresh() @@ -509,14 +529,13 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re restoredPurchases, async (restoredPurchase) => { if (userIdRef.current !== currentUserId) throw new Error('restore_session_changed') - const { data, error } = await supabase.functions.invoke('iap-verify', { - body: { - platform: 'google_play', - productId: restoredPurchase.productId, - purchaseToken: restoredPurchase.purchaseToken, - }, + const result = await invokeEdgeFunction('iap-verify', { + platform: 'google_play', + productId: restoredPurchase.productId, + purchaseToken: restoredPurchase.purchaseToken, }) - if (error) throw new Error(await functionErrorCode(error)) + if (!result.ok) throw new Error(billingFailureCode(result)) + const data = result.data return data }, async () => { diff --git a/apps/mobile-rn/src/lib/edge-functions.ts b/apps/mobile-rn/src/lib/edge-functions.ts new file mode 100644 index 0000000..b965202 --- /dev/null +++ b/apps/mobile-rn/src/lib/edge-functions.ts @@ -0,0 +1,129 @@ +// src/lib/edge-functions.ts — typed transport adapter for Supabase Edge Functions. +// +// functions-js returns `{ data: null, error: FunctionsHttpError }` for every +// non-2xx response and keeps the server's JSON body only inside +// `error.context` (a fetch Response). Callers must never inspect `data` to +// find a failure code. This adapter decodes that transport detail once so +// feature services only see a discriminated result. +// +// Edge functions do not share one error-body convention (team/billing put the +// code in `error`, account-delete puts it in `code` and a human string in +// `error`), so the adapter deliberately does NOT pick a code field. It hands +// back the parsed payload and each caller keeps its own field selection. +import { supabase } from './supabase' + +/** Decoded body of a non-2xx Edge Function response. */ +export type EdgeFunctionHttpBody = + | { readable: true; payload: unknown } + | { readable: false } + +export interface EdgeFunctionHttpFailure { + status: number + body: EdgeFunctionHttpBody +} + +export interface EdgeFunctionSuccess { + ok: true + data: T | null +} + +export interface EdgeFunctionFailure { + ok: false + /** HTTP status when the function responded; null for fetch/relay failures. */ + status: number | null + /** Parsed response body when the function responded; null otherwise. */ + body: EdgeFunctionHttpBody | null + /** Untranslated transport message. For diagnostics only, never for UI. */ + message: string | null + /** The original SDK error (or thrown value), for callers that map it further. */ + cause: unknown +} + +export type EdgeFunctionResult = EdgeFunctionSuccess | EdgeFunctionFailure + +/** Minimal port of the Supabase client this adapter depends on. */ +export interface EdgeFunctionsClient { + functions: { + invoke( + name: string, + options: { body: Record }, + ): Promise<{ data: unknown; error: unknown }> + } +} + +/** Invocation port that feature services depend on instead of the SDK. */ +export type EdgeFunctionInvoker = ( + name: string, + body: Record, +) => Promise> + +function errorMessage(error: unknown): string | null { + if (error instanceof Error) return error.message || null + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message?: unknown }).message + return typeof message === 'string' && message ? message : null + } + return null +} + +/** + * Reads the HTTP response carried by a FunctionsHttpError. Returns null when + * the error has no Response context (fetch/relay errors, thrown values). + */ +export async function readEdgeFunctionHttpFailure( + error: unknown, +): Promise { + if (typeof error !== 'object' || error === null || !('context' in error)) return null + const context = (error as { context?: unknown }).context + if (!(context instanceof Response)) return null + try { + const payload: unknown = await context.clone().json() + return { status: context.status, body: { readable: true, payload } } + } catch { + return { status: context.status, body: { readable: false } } + } +} + +/** Converts an SDK `{ data, error }` pair (or thrown value) into a typed result. */ +export async function toEdgeFunctionResult( + data: unknown, + error: unknown, +): Promise> { + if (error === null || error === undefined) { + return { ok: true, data: (data ?? null) as T | null } + } + const http = await readEdgeFunctionHttpFailure(error) + return { + ok: false, + status: http?.status ?? null, + body: http?.body ?? null, + message: errorMessage(error), + cause: error, + } +} + +export async function invokeEdgeFunction( + name: string, + body: Record, + client: EdgeFunctionsClient = supabase, +): Promise> { + let response: { data: unknown; error: unknown } + try { + response = await client.functions.invoke(name, { body }) + } catch (thrown) { + return toEdgeFunctionResult(null, thrown ?? new Error('edge_function_invoke_failed')) + } + return toEdgeFunctionResult(response.data, response.error) +} + +/** Reads a string field from a decoded failure payload, if present. */ +export function edgeFailureField( + failure: Pick, + field: string, +): string | null { + if (failure.body === null || !failure.body.readable) return null + const payload = failure.body.payload + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null + const value = (payload as Record)[field] + return typeof value === 'string' ? value : null +} diff --git a/apps/mobile-rn/src/lib/logger.ts b/apps/mobile-rn/src/lib/logger.ts new file mode 100644 index 0000000..590825e --- /dev/null +++ b/apps/mobile-rn/src/lib/logger.ts @@ -0,0 +1,26 @@ +/** + * Minimal scoped logger for the React Native app. It is the single sink that + * talks to the JS console (Metro / logcat / Xcode), so feature code never + * calls `console.*` directly and the output format stays consistent. + */ +export interface Logger { + info: (message: string, ...details: unknown[]) => void; + warn: (message: string, ...details: unknown[]) => void; + error: (message: string, ...details: unknown[]) => void; +} + +type LogLevel = keyof Logger; + +type ConsoleSink = Pick; + +export function createLogger(scope: string, sink: ConsoleSink = console): Logger { + const prefix = `[${scope}]`; + const write = (level: LogLevel) => (message: string, ...details: unknown[]): void => { + sink[level](`${prefix} ${message}`, ...details); + }; + return { + info: write('info'), + warn: write('warn'), + error: write('error'), + }; +} diff --git a/apps/mobile-rn/src/lib/preferences-adapters.ts b/apps/mobile-rn/src/lib/preferences-adapters.ts new file mode 100644 index 0000000..a21fc56 --- /dev/null +++ b/apps/mobile-rn/src/lib/preferences-adapters.ts @@ -0,0 +1,81 @@ +// Production adapters for the preferences store ports (AsyncStorage + Supabase). +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { SupabaseClient } from '@supabase/supabase-js' +import { supabase } from './supabase' +import { + USER_SETTINGS_COLUMNS, + type PreferencesRemote, + type PreferencesStorage, +} from './preferences-store' + +const UNIQUE_VIOLATION = '23505' + +export const asyncStoragePreferencesStorage: PreferencesStorage = { + multiGet: (keys) => AsyncStorage.multiGet(keys), + setItem: (key, value) => AsyncStorage.setItem(key, value), + multiSet: (pairs) => AsyncStorage.multiSet(pairs), + getAllKeys: () => AsyncStorage.getAllKeys(), + multiRemove: (keys) => AsyncStorage.multiRemove(keys), +} + +export function createSupabasePreferencesRemote( + client: SupabaseClient = supabase, +): PreferencesRemote { + return { + async fetchRow(userId) { + const { data, error } = await client + .from('user_settings') + .select(USER_SETTINGS_COLUMNS) + .eq('user_id', userId) + .maybeSingle() + if (error) throw error + return data + }, + async insertRow(row) { + const { data, error } = await client + .from('user_settings') + .insert(row) + .select(USER_SETTINGS_COLUMNS) + .single() + if (error?.code === UNIQUE_VIOLATION) return { status: 'duplicate' } + if (error) throw error + return { status: 'inserted', row: data } + }, + async updateRowAtRevision(row, expectedRevision) { + const { data, error } = await client + .from('user_settings') + .update(row) + .eq('user_id', row.user_id) + .eq('revision', expectedRevision) + .select(USER_SETTINGS_COLUMNS) + .maybeSingle() + if (error) throw error + return data + }, + } +} + +/** Subscribes to UPDATEs of the user's settings row; returns an unsubscribe function. */ +export function subscribeToUserSettingsChanges( + userId: string, + onRow: (row: unknown) => void, + client: SupabaseClient = supabase, +): () => void { + const channel = client + .channel(`mobile-user-settings-${userId}`) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'user_settings', + filter: `user_id=eq.${userId}`, + }, + (payload) => onRow(payload.new), + ) + .subscribe() + + return () => { + void client.removeChannel(channel) + } +} diff --git a/apps/mobile-rn/src/lib/preferences-context.tsx b/apps/mobile-rn/src/lib/preferences-context.tsx index 33101b3..d758a3c 100644 --- a/apps/mobile-rn/src/lib/preferences-context.tsx +++ b/apps/mobile-rn/src/lib/preferences-context.tsx @@ -1,4 +1,3 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' import { AppState, useColorScheme, @@ -9,84 +8,49 @@ import { useContext, useEffect, useMemo, - useRef, useState, + useSyncExternalStore, } from 'react' import type { ReactNode } from 'react' import { useAuth } from './auth-context' -import { supabase } from './supabase' +import { + asyncStoragePreferencesStorage, + createSupabasePreferencesRemote, + subscribeToUserSettingsChanges, +} from './preferences-adapters' +import { + CURRENT_ONBOARDING_VERSION, + clearUserPreferenceCaches, + createPreferencesStore, + preferencesOwnerFor, + type MobilePreferences, + type MobilePreferencesPatch, + type PreferenceMutationResult, + type PreferencesErrorCode, + type PreferencesStore, + type PreferencesSyncStatus, +} from './preferences-store' import { getMobileThemePalette, - isMobileThemeMode, resolveEffectiveTheme, type EffectiveMobileTheme, - type MobileThemeMode, type MobileThemePalette, } from '../theme/mobile-theme' -export const CURRENT_ONBOARDING_VERSION = 1 - -const CACHE_SCHEMA_VERSION = 1 -const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1' -const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/' -const USER_SETTINGS_COLUMNS = [ - 'user_id', - 'theme_mode', - 'locale', - 'haptic_enabled', - 'auto_polish_enabled', - 'preferred_stt_model', - 'preferred_llm_model', - 'onboarding_version', - 'tutorial_completed_at', - 'revision', - 'updated_at', -].join(',') - -export type SupportedMobileLocale = 'ko' | 'en' -export type PreferencesSyncStatus = - | 'loading' - | 'local' - | 'saving' - | 'synced' - | 'offline' - | 'error' - -export type PreferencesErrorCode = - | 'CACHE_READ_FAILED' - | 'CACHE_WRITE_FAILED' - | 'SYNC_FAILED' - | 'SYNC_CONFLICT' - -export interface MobilePreferences { - themeMode: MobileThemeMode - locale: SupportedMobileLocale - hapticEnabled: boolean - autoPolishEnabled: boolean - preferredSttModel: string | null - preferredLlmModel: string | null - onboardingVersion: number - tutorialCompletedAt: string | null - revision: number - updatedAt: string | null -} - -export type MobilePreferencesPatch = Partial> - -export interface PreferenceMutationResult { - localSaved: boolean - serverSynced: boolean -} + MobilePreferencesPatch, + PreferenceMutationResult, + PreferencesErrorCode, + PreferencesSyncStatus, + SupportedMobileLocale, +} from './preferences-store' interface PreferencesContextValue { preferences: MobilePreferences @@ -104,758 +68,70 @@ interface PreferencesContextValue { retrySync: () => Promise } -interface CacheEnvelope { - schemaVersion: number - preferences: MobilePreferences - pendingPatch: MobilePreferencesPatch - lastSyncedAt: string | null -} - -interface SettingsRow { - user_id: string - theme_mode: unknown - locale: unknown - haptic_enabled: unknown - auto_polish_enabled: unknown - preferred_stt_model: unknown - preferred_llm_model: unknown - onboarding_version: unknown - tutorial_completed_at: unknown - revision: unknown - updated_at: unknown -} - -interface ProviderSnapshot { - ownerKey: string - preferences: MobilePreferences - loading: boolean - syncStatus: PreferencesSyncStatus - errorCode: PreferencesErrorCode | null - lastSyncedAt: string | null -} - -const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({ - themeMode: 'system', - locale: 'ko', - hapticEnabled: true, - autoPolishEnabled: true, - preferredSttModel: null, - preferredLlmModel: null, - onboardingVersion: 0, - tutorialCompletedAt: null, - revision: 1, - updatedAt: null, -}) - const PreferencesContext = createContext(null) -export async function clearAllUserPreferenceCaches(): Promise { - const keys = await AsyncStorage.getAllKeys() - const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX)) - if (userKeys.length > 0) await AsyncStorage.multiRemove(userKeys) +export function clearAllUserPreferenceCaches(): Promise { + return clearUserPreferenceCaches(asyncStoragePreferencesStorage) } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function normalizeLocale(value: unknown): SupportedMobileLocale { - return value === 'en' ? 'en' : 'ko' -} - -function normalizeNullableString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 - ? value.trim() - : null -} - -function normalizeNonNegativeInteger(value: unknown, fallback: number): number { - const numeric = typeof value === 'number' ? value : Number(value) - return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback -} - -function normalizePositiveInteger(value: unknown, fallback: number): number { - const numeric = typeof value === 'number' ? value : Number(value) - return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback -} - -function normalizeIsoDate(value: unknown): string | null { - if (typeof value !== 'string') return null - const timestamp = Date.parse(value) - return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null -} - -export function normalizePreferences( - value: unknown, - fallback: MobilePreferences = DEFAULT_PREFERENCES, -): MobilePreferences { - if (!isRecord(value)) return { ...fallback } - - return { - themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode, - locale: normalizeLocale(value.locale ?? fallback.locale), - hapticEnabled: typeof value.hapticEnabled === 'boolean' - ? value.hapticEnabled - : fallback.hapticEnabled, - autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean' - ? value.autoPolishEnabled - : fallback.autoPolishEnabled, - preferredSttModel: value.preferredSttModel === undefined - ? fallback.preferredSttModel - : normalizeNullableString(value.preferredSttModel), - preferredLlmModel: value.preferredLlmModel === undefined - ? fallback.preferredLlmModel - : normalizeNullableString(value.preferredLlmModel), - onboardingVersion: normalizeNonNegativeInteger( - value.onboardingVersion, - fallback.onboardingVersion, - ), - tutorialCompletedAt: value.tutorialCompletedAt === undefined - ? fallback.tutorialCompletedAt - : normalizeIsoDate(value.tutorialCompletedAt), - revision: normalizePositiveInteger(value.revision, fallback.revision), - updatedAt: value.updatedAt === undefined - ? fallback.updatedAt - : normalizeIsoDate(value.updatedAt), - } -} - -export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch { - if (!isRecord(value)) return {} - const patch: MobilePreferencesPatch = {} - - if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode - if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale - if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled - if (typeof value.autoPolishEnabled === 'boolean') { - patch.autoPolishEnabled = value.autoPolishEnabled - } - if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') { - patch.preferredSttModel = normalizeNullableString(value.preferredSttModel) - } - if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') { - patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel) - } - if (value.onboardingVersion !== undefined) { - patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0) - } - if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') { - patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt) - } - - return patch -} - -export function applyPreferencesPatch( - preferences: MobilePreferences, - patch: MobilePreferencesPatch, -): MobilePreferences { - return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences) -} - -function parseSettingsRow(value: unknown): MobilePreferences | null { - if (!isRecord(value) || typeof value.user_id !== 'string') return null - const row = value as unknown as SettingsRow - - return normalizePreferences({ - themeMode: row.theme_mode, - locale: row.locale, - hapticEnabled: row.haptic_enabled, - autoPolishEnabled: row.auto_polish_enabled, - preferredSttModel: row.preferred_stt_model, - preferredLlmModel: row.preferred_llm_model, - onboardingVersion: row.onboarding_version, - tutorialCompletedAt: row.tutorial_completed_at, - revision: row.revision, - updatedAt: row.updated_at, +function createDefaultStore(): PreferencesStore { + return createPreferencesStore({ + storage: asyncStoragePreferencesStorage, + remote: createSupabasePreferencesRemote(), }) } -function parseCacheEnvelope(raw: string | null): CacheEnvelope | null { - if (raw === null) return null - const parsed: unknown = JSON.parse(raw) - if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) { - throw new Error('Unsupported mobile preferences cache schema') - } - - return { - schemaVersion: CACHE_SCHEMA_VERSION, - preferences: normalizePreferences(parsed.preferences), - pendingPatch: normalizePreferencesPatch(parsed.pendingPatch), - lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt), - } -} - -function createEnvelope( - preferences: MobilePreferences, - pendingPatch: MobilePreferencesPatch, - lastSyncedAt: string | null, -): CacheEnvelope { - return { - schemaVersion: CACHE_SCHEMA_VERSION, - preferences, - pendingPatch: normalizePreferencesPatch(pendingPatch), - lastSyncedAt, - } -} - -function toInstallationPreferences( - preferences: MobilePreferences, -): MobilePreferences { - return { - ...DEFAULT_PREFERENCES, - themeMode: preferences.themeMode, - locale: preferences.locale, - hapticEnabled: preferences.hapticEnabled, - onboardingVersion: preferences.onboardingVersion, - tutorialCompletedAt: preferences.tutorialCompletedAt, - } -} - -function getUserCacheKey(userId: string): string { - return `${USER_CACHE_PREFIX}${userId}` -} - -function serializeEnvelope(envelope: CacheEnvelope): string { - return JSON.stringify(envelope) -} - -async function writeCaches( - userId: string | null, - preferences: MobilePreferences, - userPendingPatch: MobilePreferencesPatch, - lastSyncedAt: string | null, - installationPendingPatch: MobilePreferencesPatch = {}, -): Promise { - const installationEnvelope = createEnvelope( - toInstallationPreferences(preferences), - installationPendingPatch, - lastSyncedAt, - ) - - if (userId === null) { - await AsyncStorage.setItem( - INSTALLATION_CACHE_KEY, - serializeEnvelope(installationEnvelope), - ) - return - } - - const userEnvelope = createEnvelope( - preferences, - userPendingPatch, - lastSyncedAt, - ) - await AsyncStorage.multiSet([ - [INSTALLATION_CACHE_KEY, serializeEnvelope(installationEnvelope)], - [getUserCacheKey(userId), serializeEnvelope(userEnvelope)], - ]) -} - -function toRowMutation( - userId: string, - preferences: MobilePreferences, -): Record { - return { - user_id: userId, - theme_mode: preferences.themeMode, - locale: preferences.locale, - haptic_enabled: preferences.hapticEnabled, - auto_polish_enabled: preferences.autoPolishEnabled, - preferred_stt_model: preferences.preferredSttModel, - preferred_llm_model: preferences.preferredLlmModel, - onboarding_version: preferences.onboardingVersion, - tutorial_completed_at: preferences.tutorialCompletedAt, - revision: preferences.revision, - } -} - -async function fetchServerPreferences(userId: string): Promise { - const { data, error } = await supabase - .from('user_settings') - .select(USER_SETTINGS_COLUMNS) - .eq('user_id', userId) - .maybeSingle() - - if (error) throw error - return parseSettingsRow(data) -} - -async function writeServerPreferences( - userId: string, - fallback: MobilePreferences, - patch: MobilePreferencesPatch, -): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - const remote = await fetchServerPreferences(userId) - - if (remote === null) { - const desired = applyPreferencesPatch(fallback, patch) - const insertValue: MobilePreferences = { - ...desired, - revision: Math.max(1, desired.revision), - } - const { data, error } = await supabase - .from('user_settings') - .insert(toRowMutation(userId, insertValue)) - .select(USER_SETTINGS_COLUMNS) - .single() - - if (error?.code === '23505') continue - if (error) throw error - const inserted = parseSettingsRow(data) - if (inserted === null) throw new Error('Invalid user settings insert response') - return inserted - } - - const desired: MobilePreferences = { - ...applyPreferencesPatch(remote, patch), - revision: remote.revision + 1, - } - const { data, error } = await supabase - .from('user_settings') - .update(toRowMutation(userId, desired)) - .eq('user_id', userId) - .eq('revision', remote.revision) - .select(USER_SETTINGS_COLUMNS) - .maybeSingle() - - if (error) throw error - const updated = parseSettingsRow(data) - if (updated !== null) return updated - } - - const conflict = new Error('User settings changed repeatedly on another device') - conflict.name = 'PreferencesSyncConflictError' - throw conflict -} - -function isPatchEmpty(patch: MobilePreferencesPatch): boolean { - return Object.keys(patch).length === 0 -} - -function removeCommittedPatch( - current: MobilePreferencesPatch, - committed: MobilePreferencesPatch, -): MobilePreferencesPatch { - const remaining: MobilePreferencesPatch = { ...current } - for (const key of Object.keys(committed) as Array) { - if (remaining[key] === committed[key]) { - delete remaining[key] - } - } - return remaining -} - -function errorCodeForSync(error: unknown): PreferencesErrorCode { - return error instanceof Error && error.name === 'PreferencesSyncConflictError' - ? 'SYNC_CONFLICT' - : 'SYNC_FAILED' -} - export function MobilePreferencesProvider({ children, + store: injectedStore, }: { children: ReactNode + /** Test seam: defaults to the AsyncStorage + Supabase backed store. */ + store?: PreferencesStore }): React.ReactElement { const { user } = useAuth() const systemScheme = useColorScheme() const userId = user?.id ?? null - const ownerKey = userId === null ? 'installation' : `user:${userId}` + const ownerKey = preferencesOwnerFor(userId).ownerKey - const [snapshot, setSnapshot] = useState({ - ownerKey: 'uninitialized', - preferences: { ...DEFAULT_PREFERENCES }, - loading: true, - syncStatus: 'loading', - errorCode: null, - lastSyncedAt: null, - }) - - const ownerKeyRef = useRef(ownerKey) - const userIdRef = useRef(userId) - const preferencesRef = useRef({ ...DEFAULT_PREFERENCES }) - const pendingPatchRef = useRef({}) - const lastSyncedAtRef = useRef(null) - const loadGenerationRef = useRef(0) - const serverQueueRef = useRef>(Promise.resolve()) - const localQueueRef = useRef>(Promise.resolve()) - - ownerKeyRef.current = ownerKey - userIdRef.current = userId - - const flushPendingPatch = useCallback(async (targetUserId: string): Promise => { - if (userIdRef.current !== targetUserId) return false - const committedPatch = { ...pendingPatchRef.current } - if (isPatchEmpty(committedPatch)) return true - - setSnapshot((current) => current.ownerKey === `user:${targetUserId}` - ? { ...current, syncStatus: 'saving', errorCode: null } - : current) - - try { - const saved = await writeServerPreferences( - targetUserId, - preferencesRef.current, - committedPatch, - ) - if (userIdRef.current !== targetUserId) return false - - const remainingPatch = removeCommittedPatch( - pendingPatchRef.current, - committedPatch, - ) - const nextPreferences = applyPreferencesPatch(saved, remainingPatch) - const syncedAt = new Date().toISOString() - preferencesRef.current = nextPreferences - pendingPatchRef.current = remainingPatch - lastSyncedAtRef.current = syncedAt - - try { - await writeCaches( - targetUserId, - nextPreferences, - remainingPatch, - syncedAt, - ) - setSnapshot({ - ownerKey: `user:${targetUserId}`, - preferences: nextPreferences, - loading: false, - syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving', - errorCode: null, - lastSyncedAt: syncedAt, - }) - } catch { - setSnapshot({ - ownerKey: `user:${targetUserId}`, - preferences: nextPreferences, - loading: false, - syncStatus: 'error', - errorCode: 'CACHE_WRITE_FAILED', - lastSyncedAt: syncedAt, - }) - } - return isPatchEmpty(remainingPatch) - } catch (error) { - if (userIdRef.current !== targetUserId) return false - setSnapshot((current) => current.ownerKey === `user:${targetUserId}` - ? { - ...current, - loading: false, - syncStatus: 'offline', - errorCode: errorCodeForSync(error), - } - : current) - return false - } - }, []) - - const queueServerFlush = useCallback((targetUserId: string): Promise => { - const run = serverQueueRef.current.then( - () => flushPendingPatch(targetUserId), - () => flushPendingPatch(targetUserId), - ) - serverQueueRef.current = run.then(() => undefined, () => undefined) - return run - }, [flushPendingPatch]) - - const loadPreferences = useCallback(async ( - targetOwnerKey: string, - targetUserId: string | null, - ): Promise => { - const generation = ++loadGenerationRef.current - setSnapshot((current) => ({ - ...current, - ownerKey: targetOwnerKey, - loading: true, - syncStatus: 'loading', - errorCode: null, - })) - - let installationEnvelope: CacheEnvelope | null = null - let userEnvelope: CacheEnvelope | null = null - let cacheReadFailed = false - let cacheWriteFailed = false - - try { - const keys = targetUserId === null - ? [INSTALLATION_CACHE_KEY] - : [INSTALLATION_CACHE_KEY, getUserCacheKey(targetUserId)] - const entries = await AsyncStorage.multiGet(keys) - installationEnvelope = parseCacheEnvelope(entries[0]?.[1] ?? null) - if (targetUserId !== null) { - userEnvelope = parseCacheEnvelope(entries[1]?.[1] ?? null) - } - } catch { - cacheReadFailed = true - } - - if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { - return - } - - if (targetUserId === null) { - const localPreferences = installationEnvelope?.preferences ?? { ...DEFAULT_PREFERENCES } - const localPending = installationEnvelope?.pendingPatch ?? {} - preferencesRef.current = localPreferences - pendingPatchRef.current = localPending - lastSyncedAtRef.current = installationEnvelope?.lastSyncedAt ?? null - setSnapshot({ - ownerKey: targetOwnerKey, - preferences: localPreferences, - loading: false, - syncStatus: cacheReadFailed ? 'error' : 'local', - errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null, - lastSyncedAt: installationEnvelope?.lastSyncedAt ?? null, - }) - return - } - - const cachedPreferences = userEnvelope?.preferences - ?? installationEnvelope?.preferences - ?? { ...DEFAULT_PREFERENCES } - const stagedPatch: MobilePreferencesPatch = { - ...(userEnvelope?.pendingPatch ?? {}), - ...(installationEnvelope?.pendingPatch ?? {}), - } - const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch) - preferencesRef.current = cachedWithPending - pendingPatchRef.current = stagedPatch - lastSyncedAtRef.current = userEnvelope?.lastSyncedAt - ?? installationEnvelope?.lastSyncedAt - ?? null - - setSnapshot({ - ownerKey: targetOwnerKey, - preferences: cachedWithPending, - loading: false, - syncStatus: 'loading', - errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null, - lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null, - }) - - try { - await writeCaches( - targetUserId, - cachedWithPending, - stagedPatch, - userEnvelope?.lastSyncedAt ?? null, - ) - } catch { - cacheWriteFailed = true - } - - let resolved: MobilePreferences - try { - const remote = await fetchServerPreferences(targetUserId) - if (remote === null || !isPatchEmpty(stagedPatch)) { - resolved = await writeServerPreferences( - targetUserId, - remote ?? cachedWithPending, - stagedPatch, - ) - } else { - resolved = remote - } - - if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { - return - } - } catch (error) { - if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { - return - } - setSnapshot({ - ownerKey: targetOwnerKey, - preferences: cachedWithPending, - loading: false, - syncStatus: 'offline', - errorCode: cacheReadFailed - ? 'CACHE_READ_FAILED' - : cacheWriteFailed - ? 'CACHE_WRITE_FAILED' - : errorCodeForSync(error), - lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null, - }) - return - } - - if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { - return - } - - const syncedAt = new Date().toISOString() - try { - await writeCaches(targetUserId, resolved, {}, syncedAt) - } catch { - cacheWriteFailed = true - } - - preferencesRef.current = resolved - pendingPatchRef.current = {} - lastSyncedAtRef.current = syncedAt - setSnapshot({ - ownerKey: targetOwnerKey, - preferences: resolved, - loading: false, - syncStatus: cacheReadFailed || cacheWriteFailed ? 'error' : 'synced', - errorCode: cacheReadFailed - ? 'CACHE_READ_FAILED' - : cacheWriteFailed - ? 'CACHE_WRITE_FAILED' - : null, - lastSyncedAt: syncedAt, - }) - }, []) + const [store] = useState(() => injectedStore ?? createDefaultStore()) + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot) + // Owner change → first load (the store alone decides initial vs background). useEffect(() => { - void loadPreferences(ownerKey, userId) - }, [loadPreferences, ownerKey, userId]) + void store.load(preferencesOwnerFor(userId)) + }, [store, userId]) + // Realtime rows from other devices. useEffect(() => { if (userId === null) return undefined + return subscribeToUserSettingsChanges(userId, (row) => { + store.applyRemote(userId, row) + }) + }, [store, userId]) - const channel = supabase - .channel(`mobile-user-settings-${userId}`) - .on( - 'postgres_changes', - { - event: 'UPDATE', - schema: 'public', - table: 'user_settings', - filter: `user_id=eq.${userId}`, - }, - (payload) => { - if (userIdRef.current !== userId || !isPatchEmpty(pendingPatchRef.current)) return - const remote = parseSettingsRow(payload.new) - if (remote === null) return + // Foreground → background resync; never re-enters the initial-loading phase. + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state !== 'active' || !store.needsForegroundResync()) return + void store.resync() + }) + return () => subscription.remove() + }, [store]) - const syncedAt = new Date().toISOString() - preferencesRef.current = remote - lastSyncedAtRef.current = syncedAt - setSnapshot({ - ownerKey: `user:${userId}`, - preferences: remote, - loading: false, - syncStatus: 'synced', - errorCode: null, - lastSyncedAt: syncedAt, - }) - void writeCaches(userId, remote, {}, syncedAt).catch(() => { - if (userIdRef.current !== userId) return - setSnapshot((current) => ({ - ...current, - syncStatus: 'error', - errorCode: 'CACHE_WRITE_FAILED', - })) - }) - }, - ) - .subscribe() - - return () => { - void supabase.removeChannel(channel) - } - }, [userId]) - - const commitPatch = useCallback(async ( - requestedPatch: MobilePreferencesPatch, - ): Promise => { - const targetOwnerKey = ownerKeyRef.current - const targetUserId = userIdRef.current - const patch = normalizePreferencesPatch(requestedPatch) - if (isPatchEmpty(patch)) { - return { - localSaved: true, - serverSynced: targetUserId === null || isPatchEmpty(pendingPatchRef.current), - } - } - - const nextPreferences = applyPreferencesPatch(preferencesRef.current, patch) - const nextPendingPatch = { - ...pendingPatchRef.current, - ...patch, - } - - try { - await writeCaches( - targetUserId, - nextPreferences, - nextPendingPatch, - lastSyncedAtRef.current, - targetUserId === null ? nextPendingPatch : {}, - ) - } catch { - if (ownerKeyRef.current === targetOwnerKey) { - setSnapshot((current) => ({ - ...current, - syncStatus: 'error', - errorCode: 'CACHE_WRITE_FAILED', - })) - } - return { localSaved: false, serverSynced: false } - } - - if (ownerKeyRef.current !== targetOwnerKey) { - return { localSaved: true, serverSynced: false } - } - - preferencesRef.current = nextPreferences - pendingPatchRef.current = nextPendingPatch - setSnapshot((current) => ({ - ...current, - ownerKey: targetOwnerKey, - preferences: nextPreferences, - loading: false, - syncStatus: targetUserId === null ? 'local' : 'saving', - errorCode: null, - })) - - if (targetUserId === null) { - return { localSaved: true, serverSynced: false } - } - - const serverSynced = await queueServerFlush(targetUserId) - return { localSaved: true, serverSynced } - }, [queueServerFlush]) - - const updatePreferences = useCallback(( - patch: MobilePreferencesPatch, - ): Promise => { - const run = localQueueRef.current.then( - () => commitPatch(patch), - () => commitPatch(patch), - ) - localQueueRef.current = run.then(() => undefined, () => undefined) - return run - }, [commitPatch]) + const updatePreferences = useCallback( + (patch: MobilePreferencesPatch): Promise => store.commit(patch), + [store], + ) const completeOnboarding = useCallback(( outcome: 'completed' | 'skipped', - ): Promise => updatePreferences({ + ): Promise => store.commit({ onboardingVersion: CURRENT_ONBOARDING_VERSION, tutorialCompletedAt: outcome === 'completed' ? new Date().toISOString() : null, - }), [updatePreferences]) + }), [store]) - const retrySync = useCallback(async (): Promise => { - const targetOwnerKey = ownerKeyRef.current - const targetUserId = userIdRef.current - if (targetUserId !== null && !isPatchEmpty(pendingPatchRef.current)) { - await queueServerFlush(targetUserId) - return - } - await loadPreferences(targetOwnerKey, targetUserId) - }, [loadPreferences, queueServerFlush]) - - useEffect(() => { - const subscription = AppState.addEventListener('change', (state) => { - if (state !== 'active' || userIdRef.current === null) return - if (isPatchEmpty(pendingPatchRef.current) && snapshot.syncStatus !== 'offline') return - void retrySync() - }) - return () => subscription.remove() - }, [retrySync, snapshot.syncStatus]) + const retrySync = useCallback((): Promise => store.resync(), [store]) const effectiveTheme = resolveEffectiveTheme( snapshot.preferences.themeMode, @@ -865,7 +141,7 @@ export function MobilePreferencesProvider({ () => getMobileThemePalette(effectiveTheme), [effectiveTheme], ) - const loading = snapshot.loading || snapshot.ownerKey !== ownerKey + const loading = snapshot.phase === 'initial-loading' || snapshot.ownerKey !== ownerKey const contextValue = useMemo(() => ({ preferences: snapshot.preferences, diff --git a/apps/mobile-rn/src/lib/preferences-store.ts b/apps/mobile-rn/src/lib/preferences-store.ts new file mode 100644 index 0000000..5966238 --- /dev/null +++ b/apps/mobile-rn/src/lib/preferences-store.ts @@ -0,0 +1,898 @@ +// Mobile preferences store — framework-free sync core. +// +// Owns the cache envelope codec, pending-patch bookkeeping, the revision-checked +// server writer and the owner/generation guards. IO is injected through the +// PreferencesStorage and PreferencesRemote ports so the whole sync flow can be +// unit-tested without React, AsyncStorage or Supabase. The React provider only +// subscribes to snapshots and forwards triggers (owner change, foreground, +// realtime rows, Settings retry). +import { isMobileThemeMode, type MobileThemeMode } from '../theme/mobile-theme' + +export const CURRENT_ONBOARDING_VERSION = 1 + +const CACHE_SCHEMA_VERSION = 1 +export const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1' +export const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/' +export const USER_SETTINGS_COLUMNS = [ + 'user_id', + 'theme_mode', + 'locale', + 'haptic_enabled', + 'auto_polish_enabled', + 'preferred_stt_model', + 'preferred_llm_model', + 'onboarding_version', + 'tutorial_completed_at', + 'revision', + 'updated_at', +].join(',') + +const MAX_WRITE_ATTEMPTS = 3 + +export type SupportedMobileLocale = 'ko' | 'en' +export type PreferencesSyncStatus = + | 'loading' + | 'local' + | 'saving' + | 'synced' + | 'offline' + | 'error' + +export type PreferencesErrorCode = + | 'CACHE_READ_FAILED' + | 'CACHE_WRITE_FAILED' + | 'SYNC_FAILED' + | 'SYNC_CONFLICT' + +export interface MobilePreferences { + themeMode: MobileThemeMode + locale: SupportedMobileLocale + hapticEnabled: boolean + autoPolishEnabled: boolean + preferredSttModel: string | null + preferredLlmModel: string | null + onboardingVersion: number + tutorialCompletedAt: string | null + revision: number + updatedAt: string | null +} + +export type MobilePreferencesPatch = Partial> + +export interface PreferenceMutationResult { + localSaved: boolean + serverSynced: boolean +} + +/** + * 'initial-loading' is entered only when the store starts serving a new owner. + * Background resyncs of an already-loaded owner keep the phase at 'ready' so + * the app tree that depends on preferences never unmounts. + */ +export type PreferencesPhase = 'initial-loading' | 'ready' + +export interface PreferencesOwner { + ownerKey: string + userId: string | null +} + +export interface PreferencesStoreSnapshot { + readonly ownerKey: string + readonly phase: PreferencesPhase + readonly preferences: MobilePreferences + readonly syncStatus: PreferencesSyncStatus + readonly errorCode: PreferencesErrorCode | null + readonly lastSyncedAt: string | null +} + +export type UserSettingsRowMutation = { + user_id: string + theme_mode: MobileThemeMode + locale: SupportedMobileLocale + haptic_enabled: boolean + auto_polish_enabled: boolean + preferred_stt_model: string | null + preferred_llm_model: string | null + onboarding_version: number + tutorial_completed_at: string | null + revision: number +} + +/** Local key-value persistence port (AsyncStorage in production). */ +export interface PreferencesStorage { + multiGet(keys: readonly string[]): Promise> + setItem(key: string, value: string): Promise + multiSet(pairs: Array<[string, string]>): Promise + getAllKeys(): Promise + multiRemove(keys: readonly string[]): Promise +} + +export type RemoteInsertResult = + | { status: 'inserted', row: unknown } + | { status: 'duplicate' } + +/** user_settings persistence port (Supabase in production). Every method throws on failure. */ +export interface PreferencesRemote { + /** Returns the raw row, or null when the user has no settings row yet. */ + fetchRow(userId: string): Promise + /** Inserts a row; a unique-violation race is reported as 'duplicate'. */ + insertRow(row: UserSettingsRowMutation): Promise + /** Updates only when the stored revision still equals expectedRevision; null otherwise. */ + updateRowAtRevision(row: UserSettingsRowMutation, expectedRevision: number): Promise +} + +export interface PreferencesStoreDeps { + storage: PreferencesStorage + remote: PreferencesRemote + now?: () => string +} + +export interface PreferencesStore { + getSnapshot(): PreferencesStoreSnapshot + subscribe(listener: () => void): () => void + /** Switches to (or re-reads) an owner. Only an owner change enters 'initial-loading'. */ + load(owner: PreferencesOwner): Promise + /** Background resync of the current owner. Never re-enters 'initial-loading'. */ + resync(): Promise + /** Whether returning to the foreground should trigger resync(). */ + needsForegroundResync(): boolean + /** Serialized local-first commit followed by a server flush for signed-in owners. */ + commit(patch: MobilePreferencesPatch): Promise + /** Applies a realtime user_settings row unless local edits are still pending. */ + applyRemote(userId: string, row: unknown): void +} + +interface CacheEnvelope { + schemaVersion: number + preferences: MobilePreferences + pendingPatch: MobilePreferencesPatch + lastSyncedAt: string | null +} + +interface SettingsRow { + user_id: string + theme_mode: unknown + locale: unknown + haptic_enabled: unknown + auto_polish_enabled: unknown + preferred_stt_model: unknown + preferred_llm_model: unknown + onboarding_version: unknown + tutorial_completed_at: unknown + revision: unknown + updated_at: unknown +} + +export const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({ + themeMode: 'system', + locale: 'ko', + hapticEnabled: true, + autoPolishEnabled: true, + preferredSttModel: null, + preferredLlmModel: null, + onboardingVersion: 0, + tutorialCompletedAt: null, + revision: 1, + updatedAt: null, +}) + +// ── Pure helpers ───────────────────────────────────────────────────────────── + +export function preferencesOwnerFor(userId: string | null): PreferencesOwner { + return { + ownerKey: userId === null ? 'installation' : `user:${userId}`, + userId, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizeLocale(value: unknown): SupportedMobileLocale { + return value === 'en' ? 'en' : 'ko' +} + +function normalizeNullableString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : null +} + +function normalizeNonNegativeInteger(value: unknown, fallback: number): number { + const numeric = typeof value === 'number' ? value : Number(value) + return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback +} + +function normalizePositiveInteger(value: unknown, fallback: number): number { + const numeric = typeof value === 'number' ? value : Number(value) + return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback +} + +function normalizeIsoDate(value: unknown): string | null { + if (typeof value !== 'string') return null + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null +} + +export function normalizePreferences( + value: unknown, + fallback: MobilePreferences = DEFAULT_PREFERENCES, +): MobilePreferences { + if (!isRecord(value)) return { ...fallback } + + return { + themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode, + locale: normalizeLocale(value.locale ?? fallback.locale), + hapticEnabled: typeof value.hapticEnabled === 'boolean' + ? value.hapticEnabled + : fallback.hapticEnabled, + autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean' + ? value.autoPolishEnabled + : fallback.autoPolishEnabled, + preferredSttModel: value.preferredSttModel === undefined + ? fallback.preferredSttModel + : normalizeNullableString(value.preferredSttModel), + preferredLlmModel: value.preferredLlmModel === undefined + ? fallback.preferredLlmModel + : normalizeNullableString(value.preferredLlmModel), + onboardingVersion: normalizeNonNegativeInteger( + value.onboardingVersion, + fallback.onboardingVersion, + ), + tutorialCompletedAt: value.tutorialCompletedAt === undefined + ? fallback.tutorialCompletedAt + : normalizeIsoDate(value.tutorialCompletedAt), + revision: normalizePositiveInteger(value.revision, fallback.revision), + updatedAt: value.updatedAt === undefined + ? fallback.updatedAt + : normalizeIsoDate(value.updatedAt), + } +} + +export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch { + if (!isRecord(value)) return {} + const patch: MobilePreferencesPatch = {} + + if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode + if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale + if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled + if (typeof value.autoPolishEnabled === 'boolean') { + patch.autoPolishEnabled = value.autoPolishEnabled + } + if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') { + patch.preferredSttModel = normalizeNullableString(value.preferredSttModel) + } + if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') { + patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel) + } + if (value.onboardingVersion !== undefined) { + patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0) + } + if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') { + patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt) + } + + return patch +} + +export function applyPreferencesPatch( + preferences: MobilePreferences, + patch: MobilePreferencesPatch, +): MobilePreferences { + return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences) +} + +export function parseSettingsRow(value: unknown): MobilePreferences | null { + if (!isRecord(value) || typeof value.user_id !== 'string') return null + const row = value as unknown as SettingsRow + + return normalizePreferences({ + themeMode: row.theme_mode, + locale: row.locale, + hapticEnabled: row.haptic_enabled, + autoPolishEnabled: row.auto_polish_enabled, + preferredSttModel: row.preferred_stt_model, + preferredLlmModel: row.preferred_llm_model, + onboardingVersion: row.onboarding_version, + tutorialCompletedAt: row.tutorial_completed_at, + revision: row.revision, + updatedAt: row.updated_at, + }) +} + +function parseCacheEnvelope(raw: string | null): CacheEnvelope | null { + if (raw === null) return null + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) { + throw new Error('Unsupported mobile preferences cache schema') + } + + return { + schemaVersion: CACHE_SCHEMA_VERSION, + preferences: normalizePreferences(parsed.preferences), + pendingPatch: normalizePreferencesPatch(parsed.pendingPatch), + lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt), + } +} + +function serializeEnvelope( + preferences: MobilePreferences, + pendingPatch: MobilePreferencesPatch, + lastSyncedAt: string | null, +): string { + const envelope: CacheEnvelope = { + schemaVersion: CACHE_SCHEMA_VERSION, + preferences, + pendingPatch: normalizePreferencesPatch(pendingPatch), + lastSyncedAt, + } + return JSON.stringify(envelope) +} + +function toInstallationPreferences( + preferences: MobilePreferences, +): MobilePreferences { + return { + ...DEFAULT_PREFERENCES, + themeMode: preferences.themeMode, + locale: preferences.locale, + hapticEnabled: preferences.hapticEnabled, + onboardingVersion: preferences.onboardingVersion, + tutorialCompletedAt: preferences.tutorialCompletedAt, + } +} + +export function getUserCacheKey(userId: string): string { + return `${USER_CACHE_PREFIX}${userId}` +} + +export function toRowMutation( + userId: string, + preferences: MobilePreferences, +): UserSettingsRowMutation { + return { + user_id: userId, + theme_mode: preferences.themeMode, + locale: preferences.locale, + haptic_enabled: preferences.hapticEnabled, + auto_polish_enabled: preferences.autoPolishEnabled, + preferred_stt_model: preferences.preferredSttModel, + preferred_llm_model: preferences.preferredLlmModel, + onboarding_version: preferences.onboardingVersion, + tutorial_completed_at: preferences.tutorialCompletedAt, + revision: preferences.revision, + } +} + +export function isPatchEmpty(patch: MobilePreferencesPatch): boolean { + return Object.keys(patch).length === 0 +} + +export function removeCommittedPatch( + current: MobilePreferencesPatch, + committed: MobilePreferencesPatch, +): MobilePreferencesPatch { + const remaining: MobilePreferencesPatch = { ...current } + for (const key of Object.keys(committed) as Array) { + if (remaining[key] === committed[key]) { + delete remaining[key] + } + } + return remaining +} + +export function errorCodeForSync(error: unknown): PreferencesErrorCode { + return error instanceof Error && error.name === 'PreferencesSyncConflictError' + ? 'SYNC_CONFLICT' + : 'SYNC_FAILED' +} + +/** Cache failures outrank sync failures: CACHE_READ_FAILED > CACHE_WRITE_FAILED > sync. */ +function resolveErrorCode( + cacheReadFailed: boolean, + cacheWriteFailed: boolean, + syncError: PreferencesErrorCode | null, +): PreferencesErrorCode | null { + if (cacheReadFailed) return 'CACHE_READ_FAILED' + if (cacheWriteFailed) return 'CACHE_WRITE_FAILED' + return syncError +} + +// ── IO helpers over the ports ──────────────────────────────────────────────── + +export async function clearUserPreferenceCaches(storage: PreferencesStorage): Promise { + const keys = await storage.getAllKeys() + const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX)) + if (userKeys.length > 0) await storage.multiRemove(userKeys) +} + +async function writeCaches( + storage: PreferencesStorage, + userId: string | null, + preferences: MobilePreferences, + userPendingPatch: MobilePreferencesPatch, + lastSyncedAt: string | null, + installationPendingPatch: MobilePreferencesPatch = {}, +): Promise { + const installationValue = serializeEnvelope( + toInstallationPreferences(preferences), + installationPendingPatch, + lastSyncedAt, + ) + + if (userId === null) { + await storage.setItem(INSTALLATION_CACHE_KEY, installationValue) + return + } + + await storage.multiSet([ + [INSTALLATION_CACHE_KEY, installationValue], + [getUserCacheKey(userId), serializeEnvelope(preferences, userPendingPatch, lastSyncedAt)], + ]) +} + +async function fetchServerPreferences( + remote: PreferencesRemote, + userId: string, +): Promise { + return parseSettingsRow(await remote.fetchRow(userId)) +} + +/** + * Revision-checked write: re-reads the row, applies the patch on top and + * updates only if nobody else bumped the revision in between. Retries a few + * times, then reports a PreferencesSyncConflictError. + */ +export async function writeServerPreferences( + remote: PreferencesRemote, + userId: string, + fallback: MobilePreferences, + patch: MobilePreferencesPatch, +): Promise { + for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt += 1) { + const current = await fetchServerPreferences(remote, userId) + + if (current === null) { + const desired = applyPreferencesPatch(fallback, patch) + const insertValue: MobilePreferences = { + ...desired, + revision: Math.max(1, desired.revision), + } + const result = await remote.insertRow(toRowMutation(userId, insertValue)) + if (result.status === 'duplicate') continue + const inserted = parseSettingsRow(result.row) + if (inserted === null) throw new Error('Invalid user settings insert response') + return inserted + } + + const desired: MobilePreferences = { + ...applyPreferencesPatch(current, patch), + revision: current.revision + 1, + } + const updated = parseSettingsRow( + await remote.updateRowAtRevision(toRowMutation(userId, desired), current.revision), + ) + if (updated !== null) return updated + } + + const conflict = new Error('User settings changed repeatedly on another device') + conflict.name = 'PreferencesSyncConflictError' + throw conflict +} + +interface CacheReadResult { + installation: CacheEnvelope | null + user: CacheEnvelope | null + failed: boolean +} + +async function readCaches( + storage: PreferencesStorage, + userId: string | null, +): Promise { + try { + const keys = userId === null + ? [INSTALLATION_CACHE_KEY] + : [INSTALLATION_CACHE_KEY, getUserCacheKey(userId)] + const entries = await storage.multiGet(keys) + return { + installation: parseCacheEnvelope(entries[0]?.[1] ?? null), + user: userId === null ? null : parseCacheEnvelope(entries[1]?.[1] ?? null), + failed: false, + } + } catch { + return { installation: null, user: null, failed: true } + } +} + +// ── Store ──────────────────────────────────────────────────────────────────── + +type RefreshMode = 'initial' | 'background' + +export function createPreferencesStore(deps: PreferencesStoreDeps): PreferencesStore { + const { storage, remote } = deps + const now = deps.now ?? (() => new Date().toISOString()) + + let owner: PreferencesOwner = { ownerKey: 'uninitialized', userId: null } + let snapshot: PreferencesStoreSnapshot = Object.freeze({ + ownerKey: 'uninitialized', + phase: 'initial-loading', + preferences: { ...DEFAULT_PREFERENCES }, + syncStatus: 'loading', + errorCode: null, + lastSyncedAt: null, + }) + let preferences: MobilePreferences = { ...DEFAULT_PREFERENCES } + let pendingPatch: MobilePreferencesPatch = {} + let lastSyncedAt: string | null = null + let loadGeneration = 0 + let serverQueue: Promise = Promise.resolve() + let localQueue: Promise = Promise.resolve() + let resyncInFlight: Promise | null = null + const listeners = new Set<() => void>() + + function setSnapshot( + next: + | PreferencesStoreSnapshot + | ((current: PreferencesStoreSnapshot) => PreferencesStoreSnapshot), + ): void { + const resolved = typeof next === 'function' ? next(snapshot) : next + if (resolved === snapshot) return + snapshot = Object.freeze({ ...resolved }) + for (const listener of [...listeners]) listener() + } + + function isCurrentLoad(generation: number, ownerKey: string): boolean { + return generation === loadGeneration && owner.ownerKey === ownerKey + } + + function enqueue( + queue: 'server' | 'local', + task: () => Promise, + ): Promise { + const tail = queue === 'server' ? serverQueue : localQueue + const run = tail.then(task, task) + const settled = run.then(() => undefined, () => undefined) + if (queue === 'server') serverQueue = settled + else localQueue = settled + return run + } + + async function flushPendingPatch(targetUserId: string): Promise { + if (owner.userId !== targetUserId) return false + const committedPatch = { ...pendingPatch } + if (isPatchEmpty(committedPatch)) return true + const targetOwnerKey = preferencesOwnerFor(targetUserId).ownerKey + + setSnapshot((current) => current.ownerKey === targetOwnerKey + ? { ...current, syncStatus: 'saving', errorCode: null } + : current) + + try { + const saved = await writeServerPreferences( + remote, + targetUserId, + preferences, + committedPatch, + ) + if (owner.userId !== targetUserId) return false + + const remainingPatch = removeCommittedPatch(pendingPatch, committedPatch) + const nextPreferences = applyPreferencesPatch(saved, remainingPatch) + const syncedAt = now() + preferences = nextPreferences + pendingPatch = remainingPatch + lastSyncedAt = syncedAt + + try { + await writeCaches(storage, targetUserId, nextPreferences, remainingPatch, syncedAt) + setSnapshot({ + ownerKey: targetOwnerKey, + phase: 'ready', + preferences: nextPreferences, + syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving', + errorCode: null, + lastSyncedAt: syncedAt, + }) + } catch { + setSnapshot({ + ownerKey: targetOwnerKey, + phase: 'ready', + preferences: nextPreferences, + syncStatus: 'error', + errorCode: 'CACHE_WRITE_FAILED', + lastSyncedAt: syncedAt, + }) + } + return isPatchEmpty(remainingPatch) + } catch (error) { + if (owner.userId !== targetUserId) return false + setSnapshot((current) => current.ownerKey === targetOwnerKey + ? { + ...current, + phase: 'ready', + syncStatus: 'offline', + errorCode: errorCodeForSync(error), + } + : current) + return false + } + } + + function queueServerFlush(targetUserId: string): Promise { + return enqueue('server', () => flushPendingPatch(targetUserId)) + } + + async function refresh(target: PreferencesOwner, mode: RefreshMode): Promise { + if (mode === 'background' && owner.ownerKey !== target.ownerKey) return + const targetOwnerKey = target.ownerKey + const targetUserId = target.userId + const generation = ++loadGeneration + + setSnapshot((current) => { + if (mode === 'initial') { + return { + ...current, + ownerKey: targetOwnerKey, + phase: 'initial-loading', + syncStatus: 'loading', + errorCode: null, + } + } + // Background: keep the phase so dependent UI stays mounted. + return current.ownerKey === targetOwnerKey + ? { ...current, syncStatus: 'loading', errorCode: null } + : current + }) + + const cache = await readCaches(storage, targetUserId) + const cacheReadFailed = cache.failed + let cacheWriteFailed = false + + if (!isCurrentLoad(generation, targetOwnerKey)) return + + if (targetUserId === null) { + const localPreferences = cache.installation?.preferences ?? { ...DEFAULT_PREFERENCES } + preferences = localPreferences + pendingPatch = cache.installation?.pendingPatch ?? {} + lastSyncedAt = cache.installation?.lastSyncedAt ?? null + setSnapshot({ + ownerKey: targetOwnerKey, + phase: 'ready', + preferences: localPreferences, + syncStatus: cacheReadFailed ? 'error' : 'local', + errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null, + lastSyncedAt, + }) + return + } + + const cachedPreferences = cache.user?.preferences + ?? cache.installation?.preferences + ?? { ...DEFAULT_PREFERENCES } + const stagedPatch: MobilePreferencesPatch = { + ...(cache.user?.pendingPatch ?? {}), + ...(cache.installation?.pendingPatch ?? {}), + } + const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch) + const cachedSyncedAt = cache.user?.lastSyncedAt ?? cache.installation?.lastSyncedAt ?? null + preferences = cachedWithPending + pendingPatch = stagedPatch + lastSyncedAt = cachedSyncedAt + + setSnapshot({ + ownerKey: targetOwnerKey, + phase: 'ready', + preferences: cachedWithPending, + syncStatus: 'loading', + errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null, + lastSyncedAt: cachedSyncedAt, + }) + + try { + await writeCaches( + storage, + targetUserId, + cachedWithPending, + stagedPatch, + cache.user?.lastSyncedAt ?? null, + ) + } catch { + cacheWriteFailed = true + } + + let resolved: MobilePreferences + try { + const server = await fetchServerPreferences(remote, targetUserId) + resolved = server === null || !isPatchEmpty(stagedPatch) + ? await writeServerPreferences( + remote, + targetUserId, + server ?? cachedWithPending, + stagedPatch, + ) + : server + } catch (error) { + if (!isCurrentLoad(generation, targetOwnerKey)) return + setSnapshot({ + ownerKey: targetOwnerKey, + phase: 'ready', + preferences: cachedWithPending, + syncStatus: 'offline', + errorCode: resolveErrorCode(cacheReadFailed, cacheWriteFailed, errorCodeForSync(error)), + lastSyncedAt: cachedSyncedAt, + }) + return + } + + if (!isCurrentLoad(generation, targetOwnerKey)) return + + // Edits committed while this refresh was in flight stay pending on top of + // the server value instead of being dropped. + const remainingPatch = removeCommittedPatch(pendingPatch, stagedPatch) + const nextPreferences = applyPreferencesPatch(resolved, remainingPatch) + const syncedAt = now() + preferences = nextPreferences + pendingPatch = remainingPatch + lastSyncedAt = syncedAt + try { + await writeCaches(storage, targetUserId, nextPreferences, remainingPatch, syncedAt) + } catch { + cacheWriteFailed = true + } + if (!isCurrentLoad(generation, targetOwnerKey)) return + + // Read the in-memory state again: a commit may have landed during the cache write. + const errorCode = resolveErrorCode(cacheReadFailed, cacheWriteFailed, null) + setSnapshot({ + ownerKey: targetOwnerKey, + phase: 'ready', + preferences, + syncStatus: errorCode !== null + ? 'error' + : isPatchEmpty(pendingPatch) ? 'synced' : 'saving', + errorCode, + lastSyncedAt: syncedAt, + }) + } + + function load(target: PreferencesOwner): Promise { + const sameOwnerReady = snapshot.ownerKey === target.ownerKey && snapshot.phase === 'ready' + owner = { ...target } + return refresh(owner, sameOwnerReady ? 'background' : 'initial') + } + + function resync(): Promise { + const target = owner + if (target.userId !== null && !isPatchEmpty(pendingPatch)) { + return queueServerFlush(target.userId).then(() => undefined) + } + if (resyncInFlight !== null) return resyncInFlight + const run = enqueue('server', () => refresh(target, 'background')) + const tracked = run.finally(() => { + if (resyncInFlight === tracked) resyncInFlight = null + }) + resyncInFlight = tracked + return tracked + } + + function needsForegroundResync(): boolean { + if (owner.userId === null) return false + return !isPatchEmpty(pendingPatch) || snapshot.syncStatus === 'offline' + } + + async function commitNow( + requestedPatch: MobilePreferencesPatch, + ): Promise { + const targetOwnerKey = owner.ownerKey + const targetUserId = owner.userId + const patch = normalizePreferencesPatch(requestedPatch) + if (isPatchEmpty(patch)) { + return { + localSaved: true, + serverSynced: targetUserId === null || isPatchEmpty(pendingPatch), + } + } + + const nextPreferences = applyPreferencesPatch(preferences, patch) + const nextPendingPatch = { ...pendingPatch, ...patch } + + try { + await writeCaches( + storage, + targetUserId, + nextPreferences, + nextPendingPatch, + lastSyncedAt, + targetUserId === null ? nextPendingPatch : {}, + ) + } catch { + if (owner.ownerKey === targetOwnerKey) { + setSnapshot((current) => ({ + ...current, + syncStatus: 'error', + errorCode: 'CACHE_WRITE_FAILED', + })) + } + return { localSaved: false, serverSynced: false } + } + + if (owner.ownerKey !== targetOwnerKey) { + return { localSaved: true, serverSynced: false } + } + + preferences = nextPreferences + pendingPatch = nextPendingPatch + setSnapshot((current) => ({ + ...current, + ownerKey: targetOwnerKey, + phase: 'ready', + preferences: nextPreferences, + syncStatus: targetUserId === null ? 'local' : 'saving', + errorCode: null, + })) + + if (targetUserId === null) { + return { localSaved: true, serverSynced: false } + } + + const serverSynced = await queueServerFlush(targetUserId) + return { localSaved: true, serverSynced } + } + + function commit(patch: MobilePreferencesPatch): Promise { + return enqueue('local', () => commitNow(patch)) + } + + function applyRemote(userId: string, row: unknown): void { + if (owner.userId !== userId || !isPatchEmpty(pendingPatch)) return + const parsed = parseSettingsRow(row) + if (parsed === null) return + + const syncedAt = now() + preferences = parsed + lastSyncedAt = syncedAt + setSnapshot({ + ownerKey: preferencesOwnerFor(userId).ownerKey, + phase: 'ready', + preferences: parsed, + syncStatus: 'synced', + errorCode: null, + lastSyncedAt: syncedAt, + }) + void writeCaches(storage, userId, parsed, {}, syncedAt).catch(() => { + if (owner.userId !== userId) return + setSnapshot((current) => ({ + ...current, + syncStatus: 'error', + errorCode: 'CACHE_WRITE_FAILED', + })) + }) + } + + return { + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + load, + resync, + needsForegroundResync, + commit, + applyRemote, + } +} diff --git a/apps/mobile-rn/src/lib/retain-live-capture.ts b/apps/mobile-rn/src/lib/retain-live-capture.ts new file mode 100644 index 0000000..409e62b --- /dev/null +++ b/apps/mobile-rn/src/lib/retain-live-capture.ts @@ -0,0 +1,57 @@ +import { RecorderBusyError } from './recorder/recorder-errors' +import type { + RecorderOwner, + RecorderSession, + RecordingRuntimeSnapshot, +} from './recorder/recorder-types' + +/** The part of the shared recorder facade this policy needs. */ +export interface CaptureRecorderPort { + acquire(owner: RecorderOwner): Promise +} + +function isLive(snapshot: RecordingRuntimeSnapshot): boolean { + return snapshot.state === 'recording' || snapshot.state === 'paused' +} + +function isReattachable(snapshot: RecordingRuntimeSnapshot): boolean { + return (snapshot.state === 'stopped' || snapshot.state === 'recoverable') + && snapshot.recording !== null +} + +/** + * Stops the microphone when the signed-in session is lost involuntarily, but + * keeps the meeting capture's file so its owner can process it after signing + * back in (Record restores a stopped capture as a recoverable recording). + * + * - A Talk turn is transient conversation input, not durable work: discarded. + * - A capture the backend cannot hand back after the screen unmounts (the + * in-process iOS recorder reports no reattachable snapshot) is discarded as + * before, because a stranded stopped capture would block every new + * recording. + */ +export async function stopLiveCaptureKeepingFile(recorder: CaptureRecorderPort): Promise { + let session: RecorderSession + try { + session = await recorder.acquire('record') + } catch (error) { + if (!(error instanceof RecorderBusyError)) throw error + const holder = await recorder.acquire(error.holder) + await holder.cancel() + return + } + + try { + await session.stop() + } catch { + // Nothing is driven by this JS process (idle, already stopped, or a + // foreground-service capture that outlived a restart): inspect it below. + } + let snapshot = await session.restore() + if (isLive(snapshot)) { + await session.stop() + snapshot = await session.restore() + } + if (isReattachable(snapshot)) return + await session.cancel() +} diff --git a/apps/mobile-rn/src/lib/retained-account-work.ts b/apps/mobile-rn/src/lib/retained-account-work.ts new file mode 100644 index 0000000..907ccfd --- /dev/null +++ b/apps/mobile-rn/src/lib/retained-account-work.ts @@ -0,0 +1,77 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const STORAGE_KEY = '@d3ro/account-retained-work/v1' +const MAX_USER_ID_LENGTH = 128 + +/** Storage port; AsyncStorage in the app, an in-memory fake in tests. */ +export interface RetainedWorkStoragePort { + getItem(key: string): Promise + setItem(key: string, value: string): Promise + removeItem(key: string): Promise +} + +/** + * Persisted marker naming the account whose unsynced recordings were kept on + * this device when its session was lost involuntarily. It survives a restart + * so the next boundary can tell "the owner came back" (keep and resume) from + * "another account arrived" (discard before that account sees the device). + * + * `current()` is synchronous so the auth boundary can decide without an extra + * await; `load()` must have settled once before the first decision. + */ +export interface RetainedWorkOwnerStore { + load(): Promise + current(): string | null + retain(userId: string): Promise + release(): Promise +} + +function parseOwner(raw: string | null): string | null { + if (raw === null) return null + const trimmed = raw.trim() + return trimmed.length > 0 && trimmed.length <= MAX_USER_ID_LENGTH ? trimmed : null +} + +export function createRetainedWorkOwnerStore( + storage: RetainedWorkStoragePort, +): RetainedWorkOwnerStore { + let cached: string | null = null + let version = 0 + let loading: Promise | null = null + + return { + load(): Promise { + if (loading !== null) return loading + const loadVersion = version + const operation = storage.getItem(STORAGE_KEY).then((raw) => { + // A retain/release issued while the read was in flight is newer. + if (version === loadVersion) cached = parseOwner(raw) + return cached + }) + loading = operation + operation.catch(() => { + if (loading === operation) loading = null + }) + return operation + }, + current(): string | null { + return cached + }, + async retain(userId: string): Promise { + const owner = parseOwner(userId) + if (owner === null) throw new Error('retained_work_owner_invalid') + version += 1 + cached = owner + await storage.setItem(STORAGE_KEY, owner) + }, + async release(): Promise { + version += 1 + cached = null + await storage.removeItem(STORAGE_KEY) + }, + } +} + +export const retainedAccountWork: RetainedWorkOwnerStore = createRetainedWorkOwnerStore(AsyncStorage) + +export const retainedAccountWorkTestContract = { storageKey: STORAGE_KEY } diff --git a/apps/mobile-rn/src/screens/AccountScreen.tsx b/apps/mobile-rn/src/screens/AccountScreen.tsx index 6f0dd49..0144145 100644 --- a/apps/mobile-rn/src/screens/AccountScreen.tsx +++ b/apps/mobile-rn/src/screens/AccountScreen.tsx @@ -41,6 +41,21 @@ import { import { useMobilePreferences } from '../lib/preferences-context' import { useDevice } from '../lib/device-context' import { detachPushRegistrationForLogout } from '../features/notifications/notification-service' +import { + accountDeletionFailureMessageKey, + edgeAccountDeletionService, + runAccountDeletion, + type AccountDeletionService, +} from '../features/account/account-deletion-service' + +// Public surface kept for existing importers; the use case now lives in +// features/account so it is testable without rendering this screen. +export { + edgeAccountDeletionService, + type AccountDeletionFailureCode, + type AccountDeletionResult, + type AccountDeletionService, +} from '../features/account/account-deletion-service' const PROFILE_COLUMNS = 'id, name, avatar_url, locale, tier, created_at, updated_at' const HTTP_URL_PATTERN = /^https?:\/\//i @@ -57,42 +72,6 @@ type Profile = { updated_at: string } -export type AccountDeletionFailureCode = - | 'SERVER_ENDPOINT_UNAVAILABLE' - | 'REAUTHENTICATION_REQUIRED' - | 'REQUEST_FAILED' - -export type AccountDeletionResult = - | { ok: true } - | { ok: false; code: AccountDeletionFailureCode; message?: string } - -/** Server-owned boundary for deleting auth.users and its cascaded account data. */ -export interface AccountDeletionService { - deleteCurrentAccount(): Promise -} - -export const edgeAccountDeletionService: AccountDeletionService = Object.freeze({ - async deleteCurrentAccount(): Promise { - const { data, error } = await supabase.functions.invoke('account-delete', { - body: { confirmation: 'DELETE_MY_ACCOUNT' }, - }) - - const response = data as { success?: boolean; code?: string; error?: string } | null - if (error || response?.success !== true) { - if (response?.code === 'REAUTHENTICATION_REQUIRED') { - return { ok: false, code: 'REAUTHENTICATION_REQUIRED', message: response.error } - } - return { - ok: false, - code: 'REQUEST_FAILED', - message: response?.error ?? error?.message, - } - } - - return { ok: true } - }, -}) - export interface AccountScreenProps { deletionService?: AccountDeletionService identityService?: IdentityManagementService @@ -413,26 +392,25 @@ export default function AccountScreen({ setErrorMessage(null) setSuccessMessage(null) + const userId = user?.id ?? null + const deviceId = currentDevice?.id ?? null try { - if (user?.id && currentDevice?.id) { - await detachPushRegistrationForLogout(user.id, currentDevice.id).catch(() => undefined) - } - const result = await deletionService.deleteCurrentAccount() + // Push is detached only after the server confirms auth.users deletion, + // then the central privacy boundary removes every account-owned local + // artifact and the secure Supabase session before Login can render. + const result = await runAccountDeletion({ + deletionService, + detachPushRegistration: async () => { + if (userId && deviceId) await detachPushRegistrationForLogout(userId, deviceId) + }, + purgeLocalSession, + }) if (!result.ok) { - const message = result.code === 'SERVER_ENDPOINT_UNAVAILABLE' - ? t('mobile.account.deleteUnavailable') - : result.code === 'REAUTHENTICATION_REQUIRED' - ? t('mobile.account.reauthenticationRequired') - : result.message ?? t('mobile.account.deleteFailed') + const message = t(accountDeletionFailureMessageKey(result.code)) setErrorMessage(message) Alert.alert(t('mobile.auth.errorTitle'), message) return } - - // Only reachable after the server confirms auth.users deletion. The - // central privacy boundary removes every account-owned local artifact - // and the secure Supabase session before Login can render. - await purgeLocalSession() } catch { const message = t('mobile.auth.localDataCleanupBody') setErrorMessage(message) diff --git a/packages/api-client/__tests__/public-surface-r2-35.test.ts b/packages/api-client/__tests__/public-surface-r2-35.test.ts index f33bd77..a512651 100644 --- a/packages/api-client/__tests__/public-surface-r2-35.test.ts +++ b/packages/api-client/__tests__/public-surface-r2-35.test.ts @@ -2,10 +2,65 @@ // 루트 barrel은 공유 계약(타입 + 클라이언트 팩토리)만 공개해야 한다. // 앱마다 따로 소유하는 저장소·쿼터·STT 호출 헬퍼가 '정본처럼' 다시 노출되지 않도록 고정한다. +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import * as apiClient from '../src/index' import type { TranscribeAudioResult } from '../src/index' +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +interface ExportConditions { + types?: string + default?: string +} + +interface PackageManifest { + main?: string + types?: string + exports: Record +} + +function readManifest(): PackageManifest { + return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')) as PackageManifest +} + +function exportTargets(entry: ExportConditions | string): string[] { + return typeof entry === 'string' ? [entry] : Object.values(entry).filter((v): v is string => typeof v === 'string') +} + +describe('@d3ro/api-client exports map', () => { + it('publishes exactly the shared-contract subpaths', () => { + expect(Object.keys(readManifest().exports).sort()).toEqual([ + '.', + './client', + './supabase-browser', + './supabase-server', + ]) + }) + + it('does not publish app-owned runtime policy subpaths', () => { + const subpaths = Object.keys(readManifest().exports) + for (const removed of ['./auth', './meetings', './history', './usage', './transcribe']) { + expect(subpaths, removed).not.toContain(removed) + } + }) + + it('points every declared target (exports, main, types) at an existing file', () => { + const manifest = readManifest() + const targets = [ + ...Object.values(manifest.exports).flatMap(exportTargets), + ...(manifest.main ? [manifest.main] : []), + ...(manifest.types ? [manifest.types] : []), + ] + expect(targets.length).toBeGreaterThan(0) + for (const target of targets) { + expect(existsSync(resolve(PACKAGE_ROOT, target)), target).toBe(true) + } + }) +}) + describe('@d3ro/api-client root surface', () => { it('exposes only the client factory runtime values', () => { expect(Object.keys(apiClient).sort()).toEqual([ diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 472f2ce..46ad0c0 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -19,22 +19,6 @@ "types": "./src/client.ts", "default": "./src/client.ts" }, - "./auth": { - "types": "./src/auth.ts", - "default": "./src/auth.ts" - }, - "./meetings": { - "types": "./src/meetings.ts", - "default": "./src/meetings.ts" - }, - "./history": { - "types": "./src/history.ts", - "default": "./src/history.ts" - }, - "./usage": { - "types": "./src/usage.ts", - "default": "./src/usage.ts" - }, "./supabase-browser": { "types": "./src/supabase-browser.ts", "default": "./src/supabase-browser.ts" diff --git a/packages/api-client/src/auth.ts b/packages/api-client/src/auth.ts deleted file mode 100644 index f530c40..0000000 --- a/packages/api-client/src/auth.ts +++ /dev/null @@ -1,58 +0,0 @@ -// packages/api-client/src/auth.ts -// Supabase Auth 래퍼. OAuth, session, 로그아웃. - -import type { Session, User, Provider } from '@supabase/supabase-js' -import type { D3roSupabaseClient } from './client' - -export type { Session, User } - -export interface SignInOptions { - provider: Extract - redirectTo: string -} - -export async function signInWithOAuth( - client: D3roSupabaseClient, - options: SignInOptions -): Promise<{ url: string | null; error: string | null }> { - const { data, error } = await client.auth.signInWithOAuth({ - provider: options.provider, - options: { - redirectTo: options.redirectTo - } - }) - return { - url: data?.url ?? null, - error: error?.message ?? null - } -} - -export async function signOut(client: D3roSupabaseClient): Promise<{ error: string | null }> { - const { error } = await client.auth.signOut() - return { error: error?.message ?? null } -} - -export async function getSession(client: D3roSupabaseClient): Promise { - const { data } = await client.auth.getSession() - return data.session ?? null -} - -export async function getUser(client: D3roSupabaseClient): Promise { - const { data } = await client.auth.getUser() - return data.user ?? null -} - -/** - * 세션 변경 구독. 반환된 함수를 호출하면 구독 해제. - */ -export function onAuthStateChange( - client: D3roSupabaseClient, - callback: (session: Session | null) => void -): () => void { - const { data } = client.auth.onAuthStateChange((_event, session) => { - callback(session) - }) - return () => { - data.subscription.unsubscribe() - } -} diff --git a/packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts b/packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts new file mode 100644 index 0000000..a246982 --- /dev/null +++ b/packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts @@ -0,0 +1,113 @@ +// packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts +// 사전 쓰기 정책이 서버 트리거(normalize_dictionary_word, 마이그레이션 20260821000005)의 +// 길이 제한(단어 120·발음 200 코드 포인트)을 로컬에서 먼저 거부하는지 검증한다. + +import { describe, expect, it } from 'vitest' +import { D3ROError, ErrorCode } from '../src/errors' +import { + DICTIONARY_LIMITS, + buildDictionaryUpdatePatch, + dictionaryWriteProblemError, + validateDictionaryDraft +} from '../src/dictionary-policy' + +describe('DICTIONARY_LIMITS', () => { + it('서버 트리거와 같은 값이다', () => { + expect(DICTIONARY_LIMITS).toEqual({ word: 120, pronunciation: 200 }) + }) +}) + +describe('validateDictionaryDraft', () => { + it('정상 입력을 정규화한다', () => { + expect(validateDictionaryDraft({ word: ' GPT ', pronunciation: ' 지피티 ' })).toEqual({ + ok: true, + draft: { word: 'GPT', pronunciation: '지피티' } + }) + expect(validateDictionaryDraft({ word: 'GPT', pronunciation: ' ' })).toEqual({ + ok: true, + draft: { word: 'GPT', pronunciation: null } + }) + }) + + it('빈 단어를 거부한다', () => { + expect(validateDictionaryDraft({ word: ' ' })).toEqual({ ok: false, reason: 'empty-word' }) + }) + + it('정확히 120자 단어·200자 발음은 받아들인다 (앞뒤 공백 제외)', () => { + const result = validateDictionaryDraft({ + word: ` ${'가'.repeat(120)} `, + pronunciation: ` ${'a'.repeat(200)} ` + }) + expect(result.ok).toBe(true) + }) + + it('121자 단어를 too-long 으로 거부한다', () => { + expect(validateDictionaryDraft({ word: 'a'.repeat(121) })).toEqual({ + ok: false, + reason: 'too-long', + field: 'word', + max: 120 + }) + }) + + it('201자 발음을 too-long 으로 거부한다', () => { + expect(validateDictionaryDraft({ word: 'ok', pronunciation: 'b'.repeat(201) })).toEqual({ + ok: false, + reason: 'too-long', + field: 'pronunciation', + max: 200 + }) + }) + + it('코드 포인트로 센다 — 서로게이트 쌍 이모지 120개는 허용', () => { + const emoji = '\u{1F600}'.repeat(120) // UTF-16 길이 240 + expect(validateDictionaryDraft({ word: emoji }).ok).toBe(true) + expect(validateDictionaryDraft({ word: `${emoji}\u{1F600}` }).ok).toBe(false) + }) +}) + +describe('buildDictionaryUpdatePatch 길이 제한', () => { + it('121자 단어로 바꾸는 편집을 거부한다', () => { + expect(buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(121) })).toEqual({ + ok: false, + reason: 'too-long', + field: 'word', + max: 120 + }) + }) + + it('201자 발음으로 바꾸는 편집을 거부한다', () => { + expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: 'p'.repeat(201) })).toEqual({ + ok: false, + reason: 'too-long', + field: 'pronunciation', + max: 200 + }) + }) + + it('제한 이내 편집과 발음 지우기는 그대로 통과한다', () => { + expect( + buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) }) + ).toEqual({ ok: true, patch: { word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) } }) + expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: null })).toEqual({ + ok: true, + patch: { pronunciation: null } + }) + }) +}) + +describe('dictionaryWriteProblemError', () => { + it('too-long 은 field·max 를 details 로 담은 DictionaryImportInvalidFormat 이다', () => { + const err = dictionaryWriteProblemError({ reason: 'too-long', field: 'pronunciation', max: 200 }) + expect(err).toBeInstanceOf(D3ROError) + expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) + expect(err.details).toEqual({ reason: 'too-long', field: 'pronunciation', max: 200 }) + }) + + it('empty-word 는 기존 메시지를 유지한다', () => { + const err = dictionaryWriteProblemError({ reason: 'empty-word' }) + expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) + expect(err.message).toBe('Dictionary word is empty') + expect(err.details).toEqual({ reason: 'empty-word', field: 'word' }) + }) +}) diff --git a/packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts b/packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts new file mode 100644 index 0000000..848a82a --- /dev/null +++ b/packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts @@ -0,0 +1,100 @@ +// 제안 정책 회귀 (r3-1): +// - 케어렛을 모르는 필드에서 문서 중간을 고쳤으면 제안을 닫는다 (caret-unknown) +// - 단어를 끝낸 뒤(뒤 공백) 로컬 기억이 단어 조각을 붙이지 않는다 +// - 학습 제외 앱은 출처와 무관하게 학습하지 않는다 +import { describe, expect, it } from 'vitest' +import { + decideSuggestion, + isEditAtTextEnd, + shouldLearnFromApp, + shouldPersistSuggestionPrefix, + type SuggestionPolicyInput +} from '../src/input-intelligence' +import { buildLocalSuggestionCandidateEntries, joinSuggestion, normalizeSessionPrefix } from '../src/suggestion-text' + +function policy(overrides: Partial = {}): SuggestionPolicyInput { + return { + enabled: true, + modelAvailable: true, + overlayVisible: false, + composing: false, + hasSelection: false, + isPassword: false, + isEditable: true, + appName: 'notepad.exe', + excludedApps: [], + editedSinceFocus: true, + typedRecently: true, + prefix: '오늘 회의에서 논의한 내용을 정리해서', + idleMs: 2000, + triggerDelayMs: 600, + minPrefixChars: 8, + sinceLastRequestMs: 60_000, + minIntervalMs: 5000, + requestsThisMinute: 0, + maxRequestsPerMinute: 12, + requestsToday: 0, + dailyBudget: 500, + ...overrides + } +} + +describe('케어렛 신뢰도', () => { + it('문서 끝에서 쓰거나 지운 편집만 끝 편집으로 본다', () => { + expect(isEditAtTextEnd('abc', 'abcd')).toBe(true) + expect(isEditAtTextEnd('abc', 'ab')).toBe(true) + expect(isEditAtTextEnd('abc', 'abc')).toBe(true) + expect(isEditAtTextEnd('첫 문장. 둘째 문장.', '첫 문장이. 둘째 문장.')).toBe(false) + expect(isEditAtTextEnd('aXb', 'ab')).toBe(false) + }) + + it('caretReliable=false 면 요청하지 않고 떠 있는 세션도 닫는다', () => { + expect(decideSuggestion(policy({ caretReliable: false }))).toEqual({ action: 'clear', reason: 'caret-unknown' }) + expect(decideSuggestion(policy({ caretReliable: false, overlayVisible: true }))).toEqual({ + action: 'clear', + reason: 'caret-unknown' + }) + }) + + it('생략하거나 true 면 기존처럼 요청한다', () => { + expect(decideSuggestion(policy()).action).toBe('request') + expect(decideSuggestion(policy({ caretReliable: true })).action).toBe('request') + }) +}) + +describe('단어를 끝낸 뒤 로컬 기억 이음새', () => { + const hints = (phrase: string) => ({ continuationHints: [], relatedHints: [], phraseHints: [phrase] }) + + it("'내일 회의 ' 뒤에 '록 공유드립니다' 조각을 만들지 않는다", () => { + const raw = '내일 회의 ' + const entries = buildLocalSuggestionCandidateEntries(normalizeSessionPrefix(raw), hints('회의록 공유드립니다'), 3, 160, { + wordFinished: true + }) + expect(entries.map((entry) => entry.text)).not.toContain('록 공유드립니다') + }) + + it("'check the ' 뒤에 'n we can ship' 조각을 만들지 않는다 (원문을 넘기면 자동 판단)", () => { + const entries = buildLocalSuggestionCandidateEntries('check the ', hints('then we can ship'), 3, 160) + expect(entries.map((entry) => entry.text)).not.toContain('n we can ship') + }) + + it('단어 중간에서 멈췄으면 기존처럼 붙인다', () => { + const entries = buildLocalSuggestionCandidateEntries('내일 회의', hints('회의록 공유드립니다'), 3, 160) + expect(entries[0]).toEqual({ text: '록 공유드립니다', attachToPrefix: true }) + expect('내일 회의' + joinSuggestion('내일 회의', entries[0].text, 'attach')).toBe('내일 회의록 공유드립니다') + }) +}) + +describe('학습 제외 앱 정본', () => { + it('터미널·에디터는 출처와 무관하게 학습하지 않는다', () => { + expect(shouldLearnFromApp('WindowsTerminal.exe')).toBe(false) + expect(shouldLearnFromApp('Code.exe')).toBe(false) + expect(shouldLearnFromApp('KakaoTalk.exe')).toBe(true) + expect(shouldLearnFromApp(null)).toBe(true) + }) + + it('제안 접두 저장도 같은 규칙을 쓴다', () => { + expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'WindowsTerminal.exe' })).toBe(false) + expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'KakaoTalk.exe' })).toBe(true) + }) +}) diff --git a/packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts b/packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts new file mode 100644 index 0000000..c96acdf --- /dev/null +++ b/packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts @@ -0,0 +1,286 @@ +// packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts +// 레드팀 r3-8 회귀 테스트 — AltGr 배열(독일어·프랑스어·폴란드어·북유럽). +// +// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 타임스탬프)으로 보내고, libuiohook 은 +// 가짜 LCtrl 로 Ctrl 수정자를 켠다. 그래서 +// 1) '@'(AltGr+Q) 를 칠 때마다 Ctrl+RightAlt('command') 가 발동해 음성 세션이 열렸고, +// 2) 기본 dictation(RightAlt 단독) · hands-free(더블탭) 는 영영 발동하지 않았다. +// 데스크톱 어댑터와 같은 순서(AltGrPressDetector → noteKeyDown → keyDown)로 흘려 검증한다. + +import { describe, it, expect, beforeEach } from 'vitest' +import { + ALTGR_CHORD_GRACE_MS, + AltGrPressDetector, + ChordStateMachine +} from '../src/keybinding-runtime' +import type { ChordBindingEntry, ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime' +import { KEYBINDING_ACTIONS, VK, bindingKey, createDefaultBindingMap } from '../src/keybinding' + +const VK_Q = 0x51 + +// ============================================================ +// 가짜 시계 +// ============================================================ + +class FakeClock implements ChordClock { + private _now = 1_000 + private _timers: { at: number; callback: () => void; active: boolean }[] = [] + + now(): number { + return this._now + } + + schedule(callback: () => void, delayMs: number): () => void { + const timer = { at: this._now + delayMs, callback, active: true } + this._timers.push(timer) + return () => { + timer.active = false + } + } + + advance(ms: number): void { + const target = this._now + ms + for (;;) { + const due = this._timers + .filter((t) => t.active && t.at <= target) + .sort((a, b) => a.at - b.at)[0] + if (due === undefined) break + this._now = due.at + due.active = false + due.callback() + } + this._timers = this._timers.filter((t) => t.active) + this._now = target + } +} + +// ============================================================ +// 어댑터 흉내 (KeyBindingService._handleKeyDown / _handleKeyUp 와 같은 순서) +// ============================================================ + +interface Mods { + ctrl?: boolean + alt?: boolean +} + +function defaultEntries(): ChordBindingEntry[] { + const map = createDefaultBindingMap() + return KEYBINDING_ACTIONS.flatMap((spec) => + map[spec.id].map((binding) => ({ + actionId: spec.id, + holdMode: spec.holdMode, + doublePress: spec.doublePress, + binding + })) + ) +} + +class Harness { + readonly clock = new FakeClock() + readonly events: string[] = [] + readonly detector = new AltGrPressDetector() + readonly machine = new ChordStateMachine({ + clock: this.clock, + onTrigger: (e: ChordTriggerEvent) => this.events.push(`${e.actionId}:${e.type}`) + }) + + constructor() { + this.machine.setBindings(defaultEntries()) + } + + private _key(vk: number, mods: Mods, isAltGr: boolean): string { + return bindingKey({ + device: 'keyboard', + code: vk, + ctrl: (mods.ctrl ?? false) && !isAltGr, + alt: mods.alt ?? false, + shift: false, + meta: false + }) + } + + down(vk: number, mods: Mods = {}): void { + const isAltGr = this.detector.keyDown(vk, this.clock.now()) + this.machine.noteKeyDown(vk) + this.machine.keyDown(this._key(vk, mods, isAltGr), { altGr: isAltGr }) + } + + up(vk: number, mods: Mods = {}): void { + this.detector.keyUp(vk) + this.machine.keyUp(this._key(vk, mods, false), vk) + } + + /** AltGr 누름: 가짜 LCtrl → RAlt (같은 타임스탬프, RAlt 에는 Ctrl+Alt 가 실린다) */ + altGrDown(): void { + this.down(VK.CtrlLeft, { ctrl: true }) + this.down(VK.AltRight, { ctrl: true, alt: true }) + } + + /** AltGr 놓음: 가짜 LCtrl ↑ → RAlt ↑ */ + altGrUp(): void { + this.up(VK.CtrlLeft, { alt: true }) + this.up(VK.AltRight) + } + + wait(ms: number): void { + this.clock.advance(ms) + } +} + +describe('AltGr 배열 — 문자 입력은 음성 트리거를 발동하지 않는다', () => { + let h: Harness + + beforeEach(() => { + h = new Harness() + }) + + it("AltGr+Q('@') 를 치면 어떤 트리거도 나가지 않는다", () => { + h.altGrDown() + h.wait(40) + h.down(VK_Q, { ctrl: true, alt: true }) + h.up(VK_Q, { ctrl: true, alt: true }) + h.wait(20) + h.altGrUp() + h.wait(1_000) + expect(h.events).toEqual([]) + }) + + it("AltGr 를 700ms 넘게 누른 채 '{' '}' 를 쳐도 트리거가 나가지 않는다", () => { + h.altGrDown() + h.wait(100) + h.down(0x37, { ctrl: true, alt: true }) // 7 → '{' + h.up(0x37, { ctrl: true, alt: true }) + h.wait(300) + h.down(0x30, { ctrl: true, alt: true }) // 0 → '}' + h.up(0x30, { ctrl: true, alt: true }) + h.wait(400) + h.altGrUp() + expect(h.events).toEqual([]) + }) + + it('좌표계로 옮기지 못한 문자 키(vk=null, 예: VK_OEM_102 "<")도 보류를 취소한다', () => { + h.altGrDown() + h.wait(30) + h.detector.keyDown(null, h.clock.now()) + h.machine.noteKeyDown(null) + h.wait(30) + h.altGrUp() + h.wait(1_000) + expect(h.events).toEqual([]) + }) + + it('판별기가 AltGr 를 놓쳐도(타임스탬프 불일치) 문자 키가 Ctrl+RightAlt 보류를 취소한다', () => { + h.down(VK.CtrlLeft, { ctrl: true }) + h.wait(5) + h.down(VK.AltRight, { ctrl: true, alt: true }) // 'command' 로 보임 → AltGr 형태 보류 + h.down(VK_Q, { ctrl: true, alt: true }) + h.up(VK_Q, { ctrl: true, alt: true }) + h.up(VK.CtrlLeft, { alt: true }) + h.up(VK.AltRight) + h.wait(1_000) + expect(h.events).toEqual([]) + }) +}) + +describe('AltGr 배열 — 기본 dictation · hands-free 가 발동한다', () => { + let h: Harness + + beforeEach(() => { + h = new Harness() + }) + + it('AltGr 를 누르고 있으면 유예 뒤 dictation 이 pressed, 놓으면 released', () => { + h.altGrDown() + expect(h.events).toEqual([]) + h.wait(ALTGR_CHORD_GRACE_MS) + expect(h.events).toEqual(['dictation:pressed']) + h.wait(1_500) + h.altGrUp() + expect(h.events).toEqual(['dictation:pressed', 'dictation:released']) + }) + + it('AltGr auto-repeat(가짜 LCtrl + RAlt 반복)는 누름을 다시 트리거하지 않는다', () => { + h.altGrDown() + h.wait(ALTGR_CHORD_GRACE_MS) + h.wait(500) + h.altGrDown() + h.wait(33) + h.altGrDown() + h.altGrUp() + expect(h.events).toEqual(['dictation:pressed', 'dictation:released']) + }) + + it('AltGr 더블탭은 hands-free 를 켠다', () => { + h.altGrDown() + h.wait(60) + h.altGrUp() + h.wait(120) + h.altGrDown() + h.wait(60) + h.altGrUp() + expect(h.events).toEqual([ + 'dictation:pressed', + 'dictation:released', + 'hands-free:pressed', + 'hands-free:released' + ]) + }) + + it("'@' 직후 AltGr 를 탭해도 hands-free 로 세지 않는다 (취소된 누름은 첫 탭이 아니다)", () => { + h.altGrDown() + h.down(VK_Q, { ctrl: true, alt: true }) + h.up(VK_Q, { ctrl: true, alt: true }) + h.altGrUp() + h.wait(100) + h.altGrDown() + h.wait(60) + h.altGrUp() + expect(h.events).toEqual(['dictation:pressed', 'dictation:released']) + }) + + it('진짜 LCtrl 을 먼저 누르고 AltGr 를 누르면 command(Ctrl+RightAlt) 다', () => { + h.down(VK.CtrlLeft, { ctrl: true }) + h.wait(80) + h.altGrDown() // Windows 는 LCtrl 이 눌려 있어도 가짜 LCtrl 을 보낸다 + h.wait(ALTGR_CHORD_GRACE_MS) + expect(h.events).toEqual(['command:pressed']) + h.up(VK.AltRight, { ctrl: true }) + h.up(VK.CtrlLeft) + expect(h.events).toEqual(['command:pressed', 'command:released']) + }) +}) + +describe('AltGrPressDetector', () => { + it('같은 타임스탬프의 LCtrl 직후 RAlt 만 AltGr 다', () => { + const d = new AltGrPressDetector() + expect(d.keyDown(VK.CtrlLeft, 10)).toBe(false) + expect(d.keyDown(VK.AltRight, 10)).toBe(true) + }) + + it('타임스탬프가 다르거나 사이에 다른 키가 끼면 AltGr 가 아니다', () => { + const a = new AltGrPressDetector() + a.keyDown(VK.CtrlLeft, 10) + expect(a.keyDown(VK.AltRight, 26)).toBe(false) + + const b = new AltGrPressDetector() + b.keyDown(VK.CtrlLeft, 10) + b.keyDown(VK_Q, 10) + expect(b.keyDown(VK.AltRight, 10)).toBe(false) + }) + + it('RAlt 를 놓으면 다음 누름은 새로 판정한다', () => { + const d = new AltGrPressDetector() + d.keyDown(VK.CtrlLeft, 10) + expect(d.keyDown(VK.AltRight, 10)).toBe(true) + d.keyUp(VK.CtrlLeft) + d.keyUp(VK.AltRight) + expect(d.keyDown(VK.AltRight, 500)).toBe(false) + }) + + it('reset 은 상태를 비운다', () => { + const d = new AltGrPressDetector() + d.keyDown(VK.CtrlLeft, 10) + d.reset() + expect(d.keyDown(VK.AltRight, 10)).toBe(false) + }) +}) diff --git a/packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts b/packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts new file mode 100644 index 0000000..d3ee58d --- /dev/null +++ b/packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts @@ -0,0 +1,95 @@ +// packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts +// 회귀: DOCX 내보내기가 인라인 강조(**x**)를 별표째 찍어 PDF 와 결과가 달랐다. +// 이제 두 경로가 parseInlineRuns 하나로 굵기를 해석해야 한다. + +import { describe, expect, it } from 'vitest' +import JSZip from 'jszip' +import { markdownToDocx } from '../src/utils/markdown-to-docx' +import { markdownToSimpleHtml, parseInlineRuns } from '../src/utils/meeting-markdown' + +interface RunSpan { + text: string + bold: boolean +} + +/** document.xml 에서 본문 run(텍스트·굵기)만 뽑는다. */ +async function docxRuns(markdown: string): Promise { + const buffer = await markdownToDocx(markdown, 'T') + const zip = await JSZip.loadAsync(buffer) + const file = zip.file('word/document.xml') + if (!file) throw new Error('document.xml missing') + const xml = await file.async('string') + const runs: RunSpan[] = [] + for (const match of xml.matchAll(/([\s\S]*?)<\/w:r>/g)) { + const body = match[1] + const text = [...body.matchAll(/]*>([\s\S]*?)<\/w:t>/g)].map((m) => m[1]).join('') + const bold = /|/.test(body) + runs.push({ text, bold }) + } + // 첫 run 은 문서 제목('T') + return runs.slice(1) +} + +function boldTexts(runs: readonly RunSpan[]): string[] { + return runs.filter((r) => r.bold).map((r) => r.text) +} + +function htmlBoldTexts(html: string): string[] { + return [...html.matchAll(/([\s\S]*?)<\/strong>/g)].map((m) => m[1]) +} + +describe('parseInlineRuns', () => { + it('강조 표기를 벗기고 굵은 조각으로 나눈다', () => { + expect(parseInlineRuns('**결정**: 3월 출시')).toEqual([ + { text: '결정', bold: true }, + { text: ': 3월 출시', bold: false }, + ]) + expect(parseInlineRuns('a **b** c **d**')).toEqual([ + { text: 'a ', bold: false }, + { text: 'b', bold: true }, + { text: ' c ', bold: false }, + { text: 'd', bold: true }, + ]) + }) + + it('짝이 없는 별표·빈 문자열은 그대로 둔다', () => { + expect(parseInlineRuns('2 ** 3')).toEqual([{ text: '2 ** 3', bold: false }]) + expect(parseInlineRuns('****')).toEqual([{ text: '****', bold: false }]) + expect(parseInlineRuns('')).toEqual([]) + }) + + it('HTML 렌더링은 기존 출력과 같다 (이스케이프 후 )', () => { + expect(markdownToSimpleHtml('- **A** & "c"')).toBe( + '
    \n
  • A <b> & "c"
  • \n
', + ) + }) +}) + +describe('DOCX 인라인 강조 (PDF 와 동일)', () => { + it("'- **A** b' 는 DOCX 와 HTML 에서 같은 굵은 조각을 만든다", async () => { + const md = '- **A** b' + const runs = await docxRuns(md) + expect(runs.map((r) => r.text).join('')).toBe('A b') + expect(boldTexts(runs)).toEqual(htmlBoldTexts(markdownToSimpleHtml(md))) + expect(boldTexts(runs)).toEqual(['A']) + }) + + it('제목·체크박스·문단·표 셀에 별표가 남지 않는다', async () => { + const md = [ + '## **결정** 사항', + '- [x] **담당**: 윤', + '**요약** 줄', + '', + '| **담당** | 기한 |', + '|---|---|', + '| **윤** | 3월 |', + ].join('\n') + const runs = await docxRuns(md) + const allText = runs.map((r) => r.text).join('') + expect(allText).not.toContain('**') + expect(allText).toContain('☑ 담당: 윤') + // 표 머리글은 전체가 굵다 — 기한도 굵음 + expect(boldTexts(runs)).toEqual(['결정', '담당', '요약', '담당', '기한', '윤']) + expect(htmlBoldTexts(markdownToSimpleHtml(md))).toEqual(['결정', '담당', '요약', '담당', '윤']) + }) +}) diff --git a/packages/core/__tests__/meeting-transcript-segments.test.ts b/packages/core/__tests__/meeting-transcript-segments.test.ts index efe57e8..58cef96 100644 --- a/packages/core/__tests__/meeting-transcript-segments.test.ts +++ b/packages/core/__tests__/meeting-transcript-segments.test.ts @@ -83,9 +83,13 @@ describe('chunkTranscriptByLines / looksTruncatedRewrite', () => { expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc']) }) - it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => { + // redteam r3-3: 줄바꿈 없는 긴 전사도 한도 이하 조각으로 나눈다(이전: 긴 한 줄을 통째로 한 조각 — 한도를 넘겼다). + it('한도보다 긴 한 줄도 한도 이하 조각으로 나뉘고, 내용은 빠지지 않는다', () => { const long = `[00:01] ${'x'.repeat(50)}` - expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y']) + const chunks = chunkTranscriptByLines(`${long}\n[00:02] y`, 20) + expect(chunks.every((c) => c.length <= 20)).toBe(true) + expect(chunks.join('').split('x').length - 1).toBe(50) + expect(chunks[chunks.length - 1]).toBe('[00:02] y') }) it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => { diff --git a/packages/core/__tests__/template-field-policy.test.ts b/packages/core/__tests__/template-field-policy.test.ts new file mode 100644 index 0000000..fa60801 --- /dev/null +++ b/packages/core/__tests__/template-field-policy.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + findTemplateFieldIdProblem, + nextTemplateFieldId, + nextTemplateFieldNumber, + templateFieldIdProblems, +} from '../src/template-field-policy' + +const ids = (...values: string[]) => values.map((id) => ({ id })) + +describe('templateFieldIdProblems', () => { + it('유효한 id는 문제가 없다', () => { + expect(templateFieldIdProblems(ids('a', 'b', 'c'))).toEqual([null, null, null]) + expect(findTemplateFieldIdProblem(ids('a', 'b'))).toBeNull() + }) + + it('빈 id와 공백뿐인 id는 empty', () => { + expect(templateFieldIdProblems(ids('a', '', ' '))).toEqual([null, 'empty', 'empty']) + }) + + it('겹치는 id는 모든 행이 duplicate', () => { + expect(templateFieldIdProblems(ids('field3', 'x', 'field3'))).toEqual(['duplicate', null, 'duplicate']) + }) + + it('id는 정확히 비교한다(자리표시자 키가 정확 일치이므로)', () => { + expect(templateFieldIdProblems(ids('a', ' a', 'A'))).toEqual([null, null, null]) + }) + + it('findTemplateFieldIdProblem은 첫 문제의 위치와 id를 돌려준다', () => { + expect(findTemplateFieldIdProblem(ids('a', 'b', 'b'))).toEqual({ index: 1, id: 'b', problem: 'duplicate' }) + expect(findTemplateFieldIdProblem(ids('a', ''))).toEqual({ index: 1, id: '', problem: 'empty' }) + }) +}) + +describe('nextTemplateFieldId', () => { + it('빈 목록이면 field1', () => { + expect(nextTemplateFieldId([])).toBe('field1') + }) + + it('중간 필드를 지운 뒤에도 기존 id를 다시 만들지 않는다 (회귀: 개수 기반 번호)', () => { + // [field1, field2, field3]에서 field1을 지움 → 개수+1 = field3 (중복) + expect(nextTemplateFieldId(ids('field2', 'field3'))).toBe('field4') + }) + + it('사용자가 이름을 바꾼 필드도 번호 계산에 넣는다', () => { + expect(nextTemplateFieldNumber(ids('recipient', 'field7', 'body'))).toBe(8) + expect(nextTemplateFieldId(ids('recipient', 'body'))).toBe('field1') + }) + + it('추가·삭제를 섞어 반복해도 id가 겹치지 않는다', () => { + const fields = ids('field1') + for (let i = 0; i < 6; i += 1) { + fields.push({ id: nextTemplateFieldId(fields) }) + fields.push({ id: nextTemplateFieldId(fields) }) + fields.splice(i % fields.length, 1) + expect(findTemplateFieldIdProblem(fields)).toBeNull() + } + }) +}) diff --git a/packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts b/packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts new file mode 100644 index 0000000..ae9338a --- /dev/null +++ b/packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts @@ -0,0 +1,107 @@ +// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의 raw_transcript)도 한도 이하 조각으로 나뉘어야 한다. +import { describe, expect, it } from 'vitest' +import { splitTextToFit } from '../src/text-chunking' +import { chunkTranscriptByLines, looksTruncatedRewrite } from '../src/meeting-transcript' +import { + MEETING_MAP_CHUNK_CHARS, + planTranscriptCondense, + selectTranscriptExcerpts, +} from '../src/meeting-llm-input' + +/** 자막 세션 fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 (약 20,000자) */ +function singleLineTranscript(targetChars: number): string { + const sentences: string[] = [] + let length = 0 + for (let i = 0; length < targetChars; i++) { + const sentence = + i === 437 ? '출시 담당자는 김민수로 결정했습니다.' : `오늘 회의의 ${i}번째 안건에 대해 이야기를 나눴습니다.` + sentences.push(sentence) + length += sentence.length + 1 + } + return sentences.join(' ') +} + +describe('splitTextToFit', () => { + it('들어가면 그대로 둔다', () => { + expect(splitTextToFit(' 짧은 문장. ', 100)).toEqual(['짧은 문장.']) + expect(splitTextToFit(' ', 100)).toEqual([]) + }) + + it('문장 경계를 먼저 쓴다', () => { + expect(splitTextToFit('첫 문장입니다. 둘째 문장입니다. 셋째', 20)).toEqual(['첫 문장입니다. 둘째 문장입니다.', '셋째']) + }) + + it('소수점은 문장 경계가 아니다', () => { + const pieces = splitTextToFit('매출은 3.5 억 원 증가 예상입니다', 12) + expect(pieces.every((p) => p.length <= 12)).toBe(true) + expect(pieces.join(' ')).toBe('매출은 3.5 억 원 증가 예상입니다') + expect(pieces.some((p) => p.endsWith('3.'))).toBe(false) + }) + + it('문장 경계가 너무 앞에 있으면 공백에서 자른다', () => { + expect(splitTextToFit('네. 그러면 다음 주 월요일까지 초안을 보내겠습니다', 20)).toEqual([ + '네. 그러면 다음 주 월요일까지', + '초안을 보내겠습니다', + ]) + }) + + it('전각 문장 부호는 뒤에 공백이 없어도 경계다', () => { + expect(splitTextToFit('今日は晴れです。明日は雨です。', 9)).toEqual(['今日は晴れです。', '明日は雨です。']) + }) + + it('경계가 없으면 글자 수로 자르고 서로게이트 쌍은 가르지 않는다', () => { + expect(splitTextToFit('x'.repeat(25), 10)).toEqual(['x'.repeat(10), 'x'.repeat(10), 'x'.repeat(5)]) + const emoji = `${'a'.repeat(4)}😀${'b'.repeat(4)}` + const pieces = splitTextToFit(emoji, 5) + expect(pieces.join('')).toBe(emoji) + expect(pieces.every((p) => p.length <= 5)).toBe(true) + }) +}) + +describe('chunkTranscriptByLines — 줄바꿈 없는 긴 줄', () => { + it('단일 줄 20,000자 전사도 한도 이하 조각 여러 개로 나뉜다', () => { + const transcript = singleLineTranscript(20_000) + expect(transcript.includes('\n')).toBe(false) + const chunks = chunkTranscriptByLines(transcript, 1_500) + expect(chunks.length).toBeGreaterThanOrEqual(14) + expect(chunks.every((c) => c.length <= 1_500)).toBe(true) + // 내용은 잃지 않는다(조각 경계의 공백만 줄바꿈으로 바뀐다) + expect(chunks.join(' ').replace(/\s+/g, ' ')).toBe(transcript) + // 문장 경계에서 끊었다 + expect(chunks.every((c) => c.endsWith('.'))).toBe(true) + }) + + it('긴 시각 줄을 자르면 조각마다 [MM:SS] [화자] 머리를 붙인다', () => { + const line = `[12:34] [화자 1] ${'가나다라 마바사. '.repeat(40).trim()}` + const chunks = chunkTranscriptByLines(`[00:01] 짧은 줄\n${line}`, 120) + expect(chunks.every((c) => c.length <= 120)).toBe(true) + const pieces = chunks.flatMap((c) => c.split('\n')).slice(1) + expect(pieces.length).toBeGreaterThan(1) + expect(pieces.every((p) => p.startsWith('[12:34] [화자 1] '))).toBe(true) + }) + + it('다시 쓰기 조각 하나를 그대로 돌려받으면 잘린 것으로 보지 않는다', () => { + const chunks = chunkTranscriptByLines(singleLineTranscript(20_000), 1_500) + for (const chunk of chunks) expect(looksTruncatedRewrite(chunk, chunk)).toBe(false) + }) +}) + +describe('meeting-llm-input — 단일 줄 전사', () => { + it('planTranscriptCondense 는 프록시 한도 안쪽 조각 여러 개를 낸다', () => { + const transcript = singleLineTranscript(20_000) + const chunks = planTranscriptCondense(transcript, 8_000) + expect(chunks.length).toBeGreaterThanOrEqual(4) + expect(chunks.every((c) => c.length <= MEETING_MAP_CHUNK_CHARS)).toBe(true) + }) + + it('selectTranscriptExcerpts 는 질문과 관련된 구간을 실제로 넣는다(빈 발췌 금지)', () => { + const transcript = singleLineTranscript(20_000) + const excerpt = selectTranscriptExcerpts(transcript, '출시 담당자는 누구로 결정했나요?', 7_000) + expect(excerpt.complete).toBe(false) + expect(excerpt.totalChunks).toBeGreaterThan(1) + expect(excerpt.includedChunks).toBeGreaterThan(0) + expect(excerpt.text.length).toBeGreaterThan(0) + expect(excerpt.text.length).toBeLessThanOrEqual(7_000) + expect(excerpt.text).toContain('출시 담당자는 김민수로 결정했습니다.') + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index b157e04..3d45abb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,10 @@ "types": "./src/errors.ts", "default": "./src/errors.ts" }, + "./voice-error-message": { + "types": "./src/voice-error-message.ts", + "default": "./src/voice-error-message.ts" + }, "./ipc-channels": { "types": "./src/ipc-channels.ts", "default": "./src/ipc-channels.ts" diff --git a/packages/core/src/caption-streaming.ts b/packages/core/src/caption-streaming.ts index 3a6dcef..517edc1 100644 --- a/packages/core/src/caption-streaming.ts +++ b/packages/core/src/caption-streaming.ts @@ -25,10 +25,19 @@ export const CAPTION_STREAMING_DEFAULTS = { /** 다음 인식에 넘기는 앞 문맥 길이 (자) */ contextChars: 200, /** 문맥 다듬기 결과가 원문에서 이 비율 이상 바뀌면 버린다 */ - refineMaxChangeRatio: 0.35 + refineMaxChangeRatio: 0.35, + /** 인식이 연속 실패하면 첫 재시도까지 기다리는 시간 (실패마다 두 배) */ + failureBackoffBaseMs: 500, + /** 연속 실패 재시도 간격 상한 */ + failureBackoffMaxMs: 8000, + /** 인식이 계속 실패하는 동안 들고 있을 오디오 상한 — 넘는 앞부분은 버린다 */ + failureRetainMs: 24000 } as const -export type CaptionTickAction = 'finalize' | 'force-commit' | 'partial' | 'trim-idle' | 'wait' +/** + * - drop-overflow: 인식이 계속 실패해 버퍼가 상한을 넘었다 — 앞부분을 버린다(무한 증가·전체 재전송 방지). + */ +export type CaptionTickAction = 'finalize' | 'force-commit' | 'partial' | 'trim-idle' | 'drop-overflow' | 'wait' export interface CaptionTickInput { /** 확정되지 않은 오디오 길이 */ @@ -41,6 +50,20 @@ export interface CaptionTickInput { sincePartialMs: number /** 인식이 진행 중인가 (한 트랙에 한 건만) */ busy: boolean + /** 연속 인식 실패 수 (성공하면 0). 생략하면 0 */ + consecutiveFailures?: number + /** 마지막 실패 이후 지난 시간 */ + sinceFailureMs?: number +} + +/** 연속 실패 n 번 뒤 다음 인식까지 기다릴 시간 (지수 백오프). 실패가 없으면 0. */ +export function captionFailureBackoffMs( + consecutiveFailures: number, + opts: Partial = {} +): number { + if (consecutiveFailures <= 0) return 0 + const o = { ...CAPTION_STREAMING_DEFAULTS, ...opts } + return Math.min(o.failureBackoffMaxMs, o.failureBackoffBaseMs * 2 ** Math.min(consecutiveFailures - 1, 16)) } /** 매 틱마다 무엇을 할지 정한다. */ @@ -51,6 +74,13 @@ export function decideCaptionTick( const o = { ...CAPTION_STREAMING_DEFAULTS, ...opts } if (input.busy) return 'wait' if (!input.hasVoice) return input.bufferMs > o.idleKeepMs ? 'trim-idle' : 'wait' + // 인식이 계속 실패하면(사이드카 다운·모델 로드 실패) 버퍼를 자르지 못한다 — 매 틱 전체 버퍼로 + // 재시도하지 않고 물러서며, 상한을 넘은 앞부분은 버린다. + const failures = input.consecutiveFailures ?? 0 + if (failures > 0) { + if (input.bufferMs > o.failureRetainMs) return 'drop-overflow' + if ((input.sinceFailureMs ?? Number.MAX_SAFE_INTEGER) < captionFailureBackoffMs(failures, o)) return 'wait' + } if (input.bufferMs < o.minAudioMs) return 'wait' if (input.sinceVoiceMs >= o.silenceCommitMs) return 'finalize' if (input.bufferMs >= o.maxBufferMs) return 'force-commit' diff --git a/packages/core/src/dictionary-policy.ts b/packages/core/src/dictionary-policy.ts index 1f921a8..5e88633 100644 --- a/packages/core/src/dictionary-policy.ts +++ b/packages/core/src/dictionary-policy.ts @@ -2,12 +2,38 @@ // 사용자 사전 편집 정책 — 순수 함수만 둔다 (DB/IPC/UI 의존 없음). // 데스크톱 DictionaryService(쓰기 규칙)와 DictionaryPage(폼 → 요청 변환)가 같은 규칙을 공유한다. -import { ErrorCode } from './errors' +import { D3ROError, ErrorCode } from './errors' +import { charLength } from './instruction-policy' import type { DictionaryAddParams, DictionaryEntry, DictionaryUpdateParams } from './types' export type DictionaryCategory = DictionaryEntry['category'] -/** 단어를 정규화한다. 공백뿐이면 null (= 유효하지 않음). */ +/** + * 서버 쓰기 제약 — 마이그레이션 20260821000005 의 normalize_dictionary_word 트리거와 같다: + * char_length(btrim(word)) ≤ 120, char_length(btrim(pronunciation)) ≤ 200 (코드 포인트 기준). + * 로컬이 이보다 긴 값을 받아들이면 서버가 22023 으로 거부하고, 아웃박스가 재시도 끝에 항목을 영구 보류해 + * 다른 기기로 동기화되지 않는다. 모바일 사전 서비스·서버 포터빌리티 가져오기도 같은 값을 쓴다. + */ +export const DICTIONARY_LIMITS = { word: 120, pronunciation: 200 } as const + +export type DictionaryLimitField = keyof typeof DICTIONARY_LIMITS + +/** 사전 항목을 저장할 수 없는 이유 */ +export type DictionaryWriteProblem = + | { reason: 'empty-word' } + | { reason: 'too-long'; field: DictionaryLimitField; max: number } + +/** 정규화한 값이 서버 길이 제한을 넘으면 그 문제, 아니면 null. */ +export function dictionaryLengthProblem( + field: DictionaryLimitField, + normalized: string | null | undefined +): DictionaryWriteProblem | null { + if (normalized === null || normalized === undefined) return null + const max = DICTIONARY_LIMITS[field] + return charLength(normalized) > max ? { reason: 'too-long', field, max } : null +} + +/** 단어를 정규화한다. 공백뿐이면 null (= 유효하지 않음). 길이 제한은 validateDictionaryDraft 가 본다. */ export function normalizeDictionaryWord(raw: string): string | null { const word = raw.trim() return word.length > 0 ? word : null @@ -57,9 +83,9 @@ export interface DictionaryUpdatePatch { export type DictionaryUpdatePatchResult = | { ok: true; patch: DictionaryUpdatePatch } - | { ok: false; reason: 'empty-word' } + | ({ ok: false } & DictionaryWriteProblem) -/** 갱신 요청을 검증·정규화해 쓰기 패치로 바꾼다. */ +/** 갱신 요청을 검증·정규화해 쓰기 패치로 바꾼다. 준 필드만 검사한다(빈 단어·서버 길이 초과 거부). */ export function buildDictionaryUpdatePatch( params: DictionaryUpdateParams ): DictionaryUpdatePatchResult { @@ -67,14 +93,66 @@ export function buildDictionaryUpdatePatch( if (params.word !== undefined) { const word = normalizeDictionaryWord(params.word) if (word === null) return { ok: false, reason: 'empty-word' } + const tooLong = dictionaryLengthProblem('word', word) + if (tooLong) return { ok: false, ...tooLong } patch.word = word } const pronunciation = normalizeDictionaryPronunciation(params.pronunciation) - if (pronunciation !== undefined) patch.pronunciation = pronunciation + if (pronunciation !== undefined) { + const tooLong = dictionaryLengthProblem('pronunciation', pronunciation) + if (tooLong) return { ok: false, ...tooLong } + patch.pronunciation = pronunciation + } if (params.category !== undefined) patch.category = params.category return { ok: true, patch } } +/** 새 항목의 정규화된 쓰기 값 */ +export interface DictionaryDraft { + word: string + pronunciation: string | null +} + +export type DictionaryDraftResult = + | { ok: true; draft: DictionaryDraft } + | ({ ok: false } & DictionaryWriteProblem) + +/** + * 새 항목(추가·가져오기)을 검증·정규화한다 (순수 함수). + * 빈 단어와 서버 길이 제한을 넘는 단어·발음을 거부한다 — 서버 트리거와 같은 기준. + */ +export function validateDictionaryDraft(input: { + word: string + pronunciation?: string | null +}): DictionaryDraftResult { + const word = normalizeDictionaryWord(input.word) + if (word === null) return { ok: false, reason: 'empty-word' } + const wordProblem = dictionaryLengthProblem('word', word) + if (wordProblem) return { ok: false, ...wordProblem } + const pronunciation = normalizeDictionaryPronunciation(input.pronunciation) ?? null + const pronunciationProblem = dictionaryLengthProblem('pronunciation', pronunciation) + if (pronunciationProblem) return { ok: false, ...pronunciationProblem } + return { ok: true, draft: { word, pronunciation } } +} + +/** + * 쓰기 문제를 IPC 로 넘길 D3ROError 로 바꾼다. + * details 의 field·max 로 UI 가 어떤 필드가 몇 자를 넘었는지 보여줄 수 있다. + */ +export function dictionaryWriteProblemError(problem: DictionaryWriteProblem): D3ROError { + if (problem.reason === 'empty-word') { + return new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty', { + reason: problem.reason, + field: 'word' + }) + } + return new D3ROError( + ErrorCode.DictionaryImportInvalidFormat, + `Dictionary ${problem.field} exceeds ${problem.max} characters`, + { reason: problem.reason, field: problem.field, max: problem.max } + ) +} + /** SQLite UNIQUE 제약 위반 여부 (better-sqlite3 오류 메시지 기준). */ export function isUniqueConstraintViolation(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/core/src/input-intelligence.ts b/packages/core/src/input-intelligence.ts index b42ad63..6bf95ac 100644 --- a/packages/core/src/input-intelligence.ts +++ b/packages/core/src/input-intelligence.ts @@ -187,6 +187,23 @@ export const PASTE_INSERTION_THRESHOLD_CHARS = 200 * 텍스트가 되므로 키코드 재구성은 원리적으로 불가능하다. 커밋된 텍스트를 읽어 * diff 하는 방식만이 CJK 를 포함해 정확하다. */ +/** + * 두 텍스트 사이의 편집이 문서 끝에서 일어났는가 (케어렛을 모를 때 "커서 = 문서 끝" 가정의 근거). + * + * computeTypedDelta 와 같은 공통 접두/접미 분해를 쓴다. 바뀐 구간 뒤에 남은 공통 접미가 없으면 + * 끝에서 쓰거나 지운 것이다. 같으면(편집 없음) true. + */ +export function isEditAtTextEnd(prevText: string, nextText: string): boolean { + if (prevText === nextText) return true + let prefix = 0 + const maxPrefix = Math.min(prevText.length, nextText.length) + while (prefix < maxPrefix && prevText[prefix] === nextText[prefix]) prefix += 1 + const maxSuffix = Math.min(prevText.length - prefix, nextText.length - prefix) + // 공통 접미가 한 글자라도 남으면 편집 지점 뒤에 기존 텍스트가 있다 — 문서 중간 편집이다. + if (maxSuffix <= 0) return true + return prevText[prevText.length - 1] !== nextText[nextText.length - 1] +} + export function computeTypedDelta( prevText: string, nextText: string, @@ -399,6 +416,11 @@ export type SuggestionSkipReason = | 'cooldown' /** 포커스만 옮겨 왔을 뿐 이 필드에서 실제로 타이핑하지 않았다 (마우스 클릭 등) */ | 'not-typing' + /** + * 케어렛 위치를 알 수 없고(예: Notepad — ValuePattern 만 제공) 마지막 편집도 문서 끝이 아니었다. + * 접두가 "문서 끝" 이라 실제 커서 위치와 무관하다 — 수락하면 문서 중간에 엉뚱한 문장이 붙는다. + */ + | 'caret-unknown' export type SuggestionDecision = | { action: 'request'; prefix: string } @@ -423,6 +445,11 @@ export interface SuggestionPolicyInput { typedRecently: boolean /** 어렛 앞 스트 */ prefix: string + /** + * prefix 가 실제 커서 앞 텍스트라고 믿을 수 있는가. 케어렛 오프셋을 알면 true, 모르면 + * 마지막 편집이 문서 끝에서 일어났을 때만 true. 생략하면 true(호환). + */ + caretReliable?: boolean idleMs: number triggerDelayMs: number minPrefixChars: number @@ -448,6 +475,9 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi if (input.appName && isAppExcluded(input.appName, input.excludedApps)) { return { action: 'clear', reason: 'excluded-app' } } + // 케어렛을 모르는 필드에서 문서 중간을 고쳤다 — 접두(문서 끝)가 커서 위치와 무관하다. + // 떠 있는 세션도 닫는다: 수락하면 실제 커서(문서 중간)에 문서 끝의 이어 쓰기가 붙는다. + if (input.caretReliable === false) return { action: 'clear', reason: 'caret-unknown' } // 포커스만 옮겨 왔을 뿐(마우스 클릭 등) 이 필드에서 아무것도 치지 않았으면 제안하지 않는다. // // 유휴 판정이 키보드 기준이라, 필드에 이미 차 있던 텍스트로 클릭만 해도 (마지막 @@ -761,7 +791,17 @@ export function shouldPersistSuggestionPrefix(input: { appName: string | null }): boolean { if (!input.learnTypedText) return false - return !(input.appName && isAppExcluded(input.appName, LEARNING_EXCLUDED_APPS)) + return shouldLearnFromApp(input.appName) +} + +/** + * 이 앱에서 나온 텍스트를 개인 코퍼스(문구·그래프·표본)에 넣어도 되는가 — 학습 제외 앱의 정본. + * + * 출처(타이핑·받아쓰기·수락한 제안)와 무관하다: 터미널·에디터·에이전트 허브에 보낸 개발 지시는 + * 어떤 경로로 들어왔든 KakaoTalk 제안으로 새면 안 된다. 앱을 모르면(null) 학습한다. + */ +export function shouldLearnFromApp(appName: string | null | undefined): boolean { + return !(appName && isAppExcluded(appName, LEARNING_EXCLUDED_APPS)) } /** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */ diff --git a/packages/core/src/keybinding-runtime.ts b/packages/core/src/keybinding-runtime.ts index a613f35..1921f4d 100644 --- a/packages/core/src/keybinding-runtime.ts +++ b/packages/core/src/keybinding-runtime.ts @@ -16,7 +16,7 @@ // 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다. import { TIMING } from './constants' -import { bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding' +import { VK, bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding' import type { BindingDevice, KeyBinding, KeyBindingActionId } from './keybinding' // ============================================================ @@ -55,6 +55,18 @@ export interface ChordReleasedComponent { code: number } +/** keyDown 의 부가 정보 (어댑터가 관측한 눌림의 성격). */ +export interface ChordKeyDownOptions { + /** + * 이 눌림이 AltGr(가짜 LCtrl + RightAlt)이다 — AltGrPressDetector 판정. + * + * AltGr 배열(독일어·프랑스어·폴란드어·북유럽 등)에서 오른쪽 Alt 는 문자 입력(@, €, {, \)의 + * 시작이다. 바인딩 모양과 무관하게 이 누름으로 발동하는 트리거를 보류해, 이어서 문자 키가 + * 눌리면(noteKeyDown) 취소한다. + */ + altGr?: boolean +} + /** * 시간 포트. 실제 구현은 Date.now / setTimeout, 테스트는 가짜 타이머를 넣는다. * schedule 은 취소 함수를 돌려준다 — 타이머 핸들 타입을 core 가 알 필요가 없다. @@ -266,8 +278,23 @@ export class ChordStateMachine { // ── 입력 ─────────────────────────────────────────────── + /** + * 바인딩 여부와 무관하게 모든 키보드 눌림을 알린다 (keyDown 보다 먼저 호출). + * + * 비수정자 키가 눌렸다 = 보류 중인 AltGr 형태 트리거는 조합이나 문자 입력(AltGr+Q='@')의 + * 일부였다 → 취소한다. 바인딩된 키만 보면 바인딩되지 않은 문자 키가 보류를 끝내지 못해, + * 문자 하나를 칠 때마다 보류 트리거가 발동한다. + * + * vk 가 null 이면 어댑터가 정본 좌표계로 옮기지 못한 키다. 수정자 키는 모두 옮길 수 + * 있으므로 비수정자로 본다. 수정자 키(가짜 LCtrl · Shift 추가 등)는 보류를 건드리지 않는다. + */ + noteKeyDown(vk: number | null): void { + if (vk !== null && isModifierKeyCode(vk)) return + this._cancelPendingExcept(null) + } + /** 키/버튼 눌림. eventKey 는 이벤트의 bindingKey. */ - keyDown(eventKey: string): void { + keyDown(eventKey: string, options: ChordKeyDownOptions = {}): void { const entries = this._byKey.get(eventKey) if (entries === undefined || entries.length === 0) return @@ -312,7 +339,7 @@ export class ChordStateMachine { for (const trigger of triggers) { this._debug(`Key binding pressed: "${trigger.entry.actionId}" (double=${isDoublePress})`) - if (trigger.entry.altGrShaped) { + if (trigger.entry.altGrShaped || options.altGr === true) { this._deferPressed(trigger) } else { this._emitPressed(trigger) @@ -393,13 +420,19 @@ export class ChordStateMachine { }, this._altGrGraceMs) } - private _cancelPendingExcept(eventKey: string): void { + /** 보류 중인 트리거를 취소한다. eventKey 가 주어지면 그 바인딩의 보류는 남긴다. */ + private _cancelPendingExcept(eventKey: string | null): void { for (const trigger of this._pending) { if (trigger.key === eventKey) continue trigger.cancelTimer?.() trigger.cancelTimer = null trigger.status = 'cancelled' this._pending.delete(trigger) + // 취소된 누름은 더블프레스의 첫 탭이 아니다 — '@' 를 치고 곧바로 오른쪽 Alt 를 + // 탭하면 hands-free 가 켜지는 일을 막는다. + if (this._lastPress.get(trigger.key) === trigger.pressedAt) { + this._lastPress.delete(trigger.key) + } this._debug(`AltGr 형태 보류 트리거 취소 (다른 키가 이어짐): "${trigger.entry.actionId}"`) } } @@ -434,3 +467,66 @@ export class ChordStateMachine { } } } + +// ============================================================ +// AltGr 눌림 판별 (Windows) +// ============================================================ + +/** + * Windows AltGr 판별기 (순수 정책). + * + * AltGr 배열에서 오른쪽 Alt 를 누르면 Windows 는 가짜 LCtrl 눌림을 먼저 보내고 곧바로 + * RAlt 눌림을 보낸다. 두 이벤트의 타임스탬프는 같다. 후킹 라이브러리(libuiohook)는 이 가짜 + * LCtrl 을 걸러내지 않고 Ctrl 수정자를 켜므로, RAlt 눌림이 `Ctrl+RightAlt` 로 보인다 — + * 기본 'command' 바인딩과 겹치고 기본 'dictation'(RightAlt 단독)과는 절대 맞지 않는다. + * + * 판정: RAlt 눌림 직전 이벤트가 같은 타임스탬프의 LCtrl 눌림이고, 그 LCtrl 이 그 전까지 + * 놓여 있었다면 AltGr 다. 사용자가 LCtrl 을 먼저 누르고 있었다면(Ctrl+AltGr) 진짜 Ctrl 이다. + * AltGr 를 누른 채 반복되는 RAlt 눌림(auto-repeat)도 같은 AltGr 로 본다. + * + * 후킹 이벤트에 스캔 코드(0x21D)·주입 플래그가 없어 타임스탬프로만 판별한다. 그래서 진짜 + * LCtrl 과 RAlt 를 같은 시계 틱 안에 함께 누르면 AltGr 로 오인할 수 있다 — 결과는 보류가 + * 붙은 RightAlt 단독 눌림이므로 안전한 쪽으로 틀린다. + */ +export class AltGrPressDetector { + private _ctrlLeftDown = false + private _altGrHeld = false + /** 직전 키보드 눌림 (가짜 LCtrl 짝 판정용) */ + private _last: { vk: number | null; time: number; ctrlLeftWasDown: boolean } | null = null + + /** + * 키보드 눌림을 관측한다. 모든 눌림을 넣어야 한다(좌표계로 옮기지 못한 키는 vk=null). + * 반환값: 이 눌림이 AltGr(가짜 LCtrl + RAlt)인가. + */ + keyDown(vk: number | null, time: number): boolean { + const previous = this._last + this._last = { vk, time, ctrlLeftWasDown: this._ctrlLeftDown } + + if (vk === VK.CtrlLeft) { + this._ctrlLeftDown = true + return false + } + if (vk !== VK.AltRight) return false + + if (this._altGrHeld) return true + this._altGrHeld = + previous !== null && + previous.vk === VK.CtrlLeft && + previous.time === time && + !previous.ctrlLeftWasDown + return this._altGrHeld + } + + /** 키보드 놓임을 관측한다. */ + keyUp(vk: number | null): void { + if (vk === VK.CtrlLeft) this._ctrlLeftDown = false + if (vk === VK.AltRight) this._altGrHeld = false + } + + /** 모든 상태를 비운다 (후킹 중지 등). */ + reset(): void { + this._ctrlLeftDown = false + this._altGrHeld = false + this._last = null + } +} diff --git a/packages/core/src/meeting-llm-input.ts b/packages/core/src/meeting-llm-input.ts index 40d4df5..c0cb3d1 100644 --- a/packages/core/src/meeting-llm-input.ts +++ b/packages/core/src/meeting-llm-input.ts @@ -19,7 +19,7 @@ const EXCERPT_SEPARATOR = '\n…\n' /** * 전사가 budgetChars 를 넘으면 구간 요약용 조각으로 나눈다. 넘지 않으면 빈 배열(나눌 필요 없음). - * 한 줄이 조각 크기보다 길면 그 줄 하나가 한 조각이 된다(줄 중간은 자르지 않는다). + * 조각은 모두 조각 크기 이하다 — 줄바꿈 없는 긴 줄은 문장 경계 → 공백 → 글자 수 순으로 자른다. */ export function planTranscriptCondense( transcript: string, diff --git a/packages/core/src/meeting-transcript.ts b/packages/core/src/meeting-transcript.ts index a52eba4..405f560 100644 --- a/packages/core/src/meeting-transcript.ts +++ b/packages/core/src/meeting-transcript.ts @@ -3,6 +3,8 @@ // 데스크톱은 전사를 `[MM:SS] [화자] 내용` 줄로 저장하고, Supabase `transcripts` 는 구간 행으로 저장한다 // (모바일은 구간이 있으면 구간을 우선 표시한다). 두 표현을 이 한 곳에서만 바꾼다. +import { splitTextToFit } from './text-chunking' + export interface TranscriptLine { /** 회의 시작 기준 ms. 시각 표기가 없는 줄은 앞 줄의 시각을 이어받는다(처음이면 0). */ timestampMs: number @@ -113,20 +115,40 @@ export function applySpeakerLabels( }) } +/** 줄 앞의 `[MM:SS] [화자] ` 머리 — 긴 줄을 자를 때 이어지는 조각에도 붙여 시각·화자를 잃지 않는다 */ +const LINE_PREFIX = /^\[\d{1,3}:\d{2}\]\s*(?:\[[^\]]+\]\s*)?/ + /** - * 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기의 조각들로 나눈다. - * 한 줄이 maxChars 보다 길면 그 줄 하나로 한 조각을 만든다(줄 중간은 자르지 않는다). + * maxChars 보다 긴 한 줄을 maxChars 이하 줄들로 자른다(문장 경계 → 공백 → 글자 수). + * `[MM:SS] [화자]` 머리가 있으면 조각마다 붙인다 — 단, 머리가 한도의 절반을 넘으면 붙이지 않는다. + */ +function splitLongLine(line: string, maxChars: number): string[] { + if (line.length <= maxChars) return [line] + const prefix = LINE_PREFIX.exec(line)?.[0] ?? '' + if (!prefix || prefix.length * 2 > maxChars) return splitTextToFit(line, maxChars) + const body = line.slice(prefix.length) + return splitTextToFit(body, maxChars - prefix.length).map((piece) => `${prefix}${piece}`) +} + +/** + * 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기(maxChars 이하)의 조각들로 나눈다. + * 한 줄이 maxChars 보다 길면(줄바꿈 없는 자막·파일·폰 전사) 그 줄을 문장 경계 → 공백 → 글자 수 + * 순으로 잘라 여러 줄로 만든다 — 예전엔 줄 하나가 통째로 한 조각이 되어 LLM 입력·출력 한도를 넘었다. * 빈 줄은 버린다. */ export function chunkTranscriptByLines(text: string | null | undefined, maxChars: number): string[] { if (!text) return [] + const limit = Math.max(1, Math.floor(maxChars)) const chunks: string[] = [] let current: string[] = [] let size = 0 - for (const raw of text.split('\n')) { - const line = raw.trimEnd() - if (!line.trim()) continue - if (current.length > 0 && size + line.length + 1 > maxChars) { + const lines = text + .split('\n') + .map((raw) => raw.trimEnd()) + .filter((line) => line.trim().length > 0) + .flatMap((line) => splitLongLine(line, limit)) + for (const line of lines) { + if (current.length > 0 && size + line.length + 1 > limit) { chunks.push(current.join('\n')) current = [] size = 0 diff --git a/packages/core/src/suggestion-text.ts b/packages/core/src/suggestion-text.ts index 379ebc4..d400f1e 100644 --- a/packages/core/src/suggestion-text.ts +++ b/packages/core/src/suggestion-text.ts @@ -188,6 +188,14 @@ export function buildLocalSuggestionCandidates( return buildLocalSuggestionCandidateEntries(prefix, hints, limit, maxChars).map((entry) => entry.text) } +export interface LocalSuggestionBuildOptions { + /** + * 사용자가 친 원문이 공백으로 끝났는가(단어를 끝냈는가). 생략하면 prefix 인자의 뒤 공백으로 판단한다. + * true 면 접두 꼬리와 문구 머리가 단어 중간에서 겹치는 후보를 버린다. + */ + wordFinished?: boolean +} + /** * buildLocalSuggestionCandidates 와 같은 후보를, 접두와의 이음새 정보와 함께 돌려준다. * @@ -198,8 +206,12 @@ export function buildLocalSuggestionCandidateEntries( prefix: string, hints: LocalSuggestionHints, limit = 3, - maxChars = SUGGESTION_MAX_OUTPUT_CHARS + maxChars = SUGGESTION_MAX_OUTPUT_CHARS, + options: LocalSuggestionBuildOptions = {} ): LocalSuggestionCandidate[] { + // 커서 앞 원문이 공백으로 끝나면 단어가 끝났다 — 단어 중간 이어 쓰기(attach)는 조각이 된다 + // ("내일 회의 " + "록 공유드립니다"). 호출자가 뒤 공백을 걷어낸 접두를 넘기면 명시적으로 알린다. + const wordFinished = options.wordFinished ?? /\s$/u.test(prefix) const safeLimit = Math.max(0, Math.floor(limit)) const safeMaxChars = Math.max(0, Math.floor(maxChars)) if (safeLimit === 0 || safeMaxChars < 2) return [] @@ -232,7 +244,9 @@ export function buildLocalSuggestionCandidateEntries( const overlap = suffixPrefixOverlap(normalizedPrefix, candidate) if (overlap >= 2) { const remainder = candidate.slice(overlap) - append(normalizeSuggestionText(remainder, safeMaxChars), !/^\s/u.test(remainder)) + const midWord = !/^\s/u.test(remainder) + if (midWord && wordFinished) continue + append(normalizeSuggestionText(remainder, safeMaxChars), midWord) } else if (acceptsWholePhrase) { append(candidate) } @@ -282,7 +296,10 @@ function firstCodePoint(text: string): string { * - 이음새 양쪽 어느 쪽도 띄어 쓰지 않는 문자 체계(중·일·태 등, CJK 구두점)가 아니다. */ export function joinSuggestion(rawPrefix: string, candidate: string, joint: SuggestionJoint = 'separate'): string { - if (!candidate || joint === 'attach') return candidate + if (!candidate) return candidate + // 단어 중간 이어 쓰기는 공백 없이 붙인다. 원문이 이미 공백으로 끝났으면 이어 붙일 단어가 없다 + // — separate 규칙으로 간다(공백 뒤라 결과는 후보 그대로). + if (joint === 'attach' && !/\s$/u.test(rawPrefix)) return candidate const before = lastCodePoint(rawPrefix) const after = firstCodePoint(candidate) if (!before || !after) return candidate diff --git a/packages/core/src/template-field-policy.ts b/packages/core/src/template-field-policy.ts new file mode 100644 index 0000000..5e8652a --- /dev/null +++ b/packages/core/src/template-field-policy.ts @@ -0,0 +1,60 @@ +// packages/core/src/template-field-policy.ts +// 받아쓰기 템플릿 필드 id 규칙 — 편집 UI(TemplateSection)와 도메인 서비스(DictationTemplateService)가 함께 쓰는 정본. +// +// 필드 id는 세션에서 받아쓴 값을 담는 키(fieldValues[id])이자 출력 포맷의 {{id}} 자리표시자 키다. +// 그래서 한 템플릿 안에서 비어 있지 않고 서로 달라야 한다. 겹치면 나중 필드 값이 앞 필드 값을 덮어써 +// 출력에서 앞 값이 조용히 사라진다. + +import type { TemplateField } from './types' + +/** 필드 하나의 id 문제. 공백뿐인 id는 비어 있는 것으로 본다. */ +export type TemplateFieldIdProblem = 'empty' | 'duplicate' + +/** 새 필드 id 접두사 — `field1`, `field2`, ... */ +export const TEMPLATE_FIELD_ID_PREFIX = 'field' + +const GENERATED_ID = new RegExp(`^${TEMPLATE_FIELD_ID_PREFIX}(\\d+)$`) + +/** + * 필드마다 id 문제를 돌려준다(입력과 같은 길이, 문제 없으면 null). + * 같은 id를 가진 필드는 모두 'duplicate'로 표시한다 — 편집 UI가 겹치는 행을 전부 강조할 수 있게. + */ +export function templateFieldIdProblems( + fields: ReadonlyArray>, +): Array { + const counts = new Map() + for (const field of fields) counts.set(field.id, (counts.get(field.id) ?? 0) + 1) + return fields.map((field) => { + if (field.id.trim().length === 0) return 'empty' + return (counts.get(field.id) ?? 0) > 1 ? 'duplicate' : null + }) +} + +/** 첫 번째 id 문제와 그 위치. 모든 id가 유효하면 null. */ +export function findTemplateFieldIdProblem( + fields: ReadonlyArray>, +): { index: number; id: string; problem: TemplateFieldIdProblem } | null { + const problems = templateFieldIdProblems(fields) + const index = problems.findIndex((problem) => problem !== null) + if (index === -1) return null + return { index, id: fields[index].id, problem: problems[index] as TemplateFieldIdProblem } +} + +/** + * 기존 필드와 겹치지 않는 새 필드 번호 — `fieldN` 형식 id 중 가장 큰 N + 1. + * (사용자가 이름을 `field7`로 바꾼 필드도 세므로 `field7`은 다시 나오지 않는다.) + * 필드 개수에서 번호를 만들면 중간 필드를 지운 뒤 이미 있는 id가 다시 나온다. + */ +export function nextTemplateFieldNumber(fields: ReadonlyArray>): number { + let max = 0 + for (const field of fields) { + const match = GENERATED_ID.exec(field.id) + if (match) max = Math.max(max, Number(match[1])) + } + return max + 1 +} + +/** 기존 필드와 겹치지 않는 새 필드 id. */ +export function nextTemplateFieldId(fields: ReadonlyArray>): string { + return `${TEMPLATE_FIELD_ID_PREFIX}${nextTemplateFieldNumber(fields)}` +} diff --git a/packages/core/src/text-chunking.ts b/packages/core/src/text-chunking.ts new file mode 100644 index 0000000..3309b37 --- /dev/null +++ b/packages/core/src/text-chunking.ts @@ -0,0 +1,80 @@ +// packages/core/src/text-chunking.ts +// 줄바꿈이 없는 긴 텍스트를 한도 안의 조각으로 자르는 순수 정책(정본). +// +// 자막·파일 전사 히스토리(`segments.join(' ')`)와 폰 회의 전사(STT 결과 한 문단)는 줄바꿈이 없다. +// 줄 경계로만 나누면 전체가 조각 하나가 되어 LLM 입력 한도·출력 토큰 한도를 넘는다. 그래서 +// 문장 경계 → 공백 → 글자 수 순으로 내려가며 자른다. +// +// 순수 함수만 둔다 — Electron/Node 의존 금지. 다른 core 모듈을 import 하지 않는다(순환 방지). + +/** 문장을 끝내는 부호 (라틴·CJK·말줄임) */ +const SENTENCE_END = new Set(['.', '?', '!', '…', '。', '?', '!']) +/** 뒤에 공백이 없어도 문장 경계로 보는 전각 부호 (중국어·일본어는 띄어 쓰지 않는다) */ +const FULL_WIDTH_SENTENCE_END = new Set(['。', '?', '!']) +/** 문장 끝 부호 뒤에 붙을 수 있는 닫는 따옴표·괄호 */ +const CLOSING_MARKS = new Set(['"', "'", '”', '’', ')', ']', '」', '』', ')']) +const WHITESPACE = /\s/u + +/** 문장 경계를 이 비율보다 앞에서만 찾으면 너무 짧은 조각이 되므로 공백 경계를 대신 쓴다 */ +const MIN_SENTENCE_FILL_RATIO = 0.5 + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} + +/** + * window(= text 앞 maxChars 자) 안에서 마지막 문장 경계의 끝 위치를 찾는다(없으면 0). + * 경계: 문장 끝 부호(+닫는 부호) 뒤가 공백이거나 텍스트 끝, 또는 전각 문장 부호. + * "3.5" 처럼 부호 뒤에 바로 글자가 오면 경계가 아니다. + */ +function lastSentenceBoundary(text: string, limit: number): number { + let best = 0 + for (let i = 0; i < limit; i++) { + const ch = text[i] + if (!SENTENCE_END.has(ch)) continue + let end = i + 1 + while (end < limit && CLOSING_MARKS.has(text[end])) end++ + const next = text[end] + if (FULL_WIDTH_SENTENCE_END.has(ch) || next === undefined || WHITESPACE.test(next)) { + best = end + } + } + return best +} + +function lastWhitespace(text: string, limit: number): number { + for (let i = limit; i > 0; i--) { + if (WHITESPACE.test(text[i] ?? '')) return i + } + return 0 +} + +/** text(길이 > maxChars)의 첫 조각을 끊을 위치 — 문장 경계 → 공백 → 글자 수 순. */ +function findCut(text: string, maxChars: number): number { + const sentence = lastSentenceBoundary(text, maxChars) + if (sentence >= Math.ceil(maxChars * MIN_SENTENCE_FILL_RATIO)) return sentence + const space = lastWhitespace(text, maxChars) + if (space > 0) return space + if (sentence > 0) return sentence + // 글자 수로 자른다 — 서로게이트 쌍(이모지 등) 가운데는 피한다 + const hard = isHighSurrogate(text.charCodeAt(maxChars - 1)) ? maxChars - 1 : maxChars + return Math.max(1, hard) +} + +/** + * 텍스트를 maxChars 이하 조각들로 자른다. 들어가면 [text] 그대로(앞뒤 공백만 정리). + * 자른 조각의 앞뒤 공백은 버리고, 빈 조각은 내지 않는다. + */ +export function splitTextToFit(text: string, maxChars: number): string[] { + const limit = Math.max(1, Math.floor(maxChars)) + const pieces: string[] = [] + let rest = text.trim() + while (rest.length > limit) { + const cut = findCut(rest, limit) + const piece = rest.slice(0, cut).trim() + if (piece) pieces.push(piece) + rest = rest.slice(cut).trim() + } + if (rest) pieces.push(rest) + return pieces +} diff --git a/packages/core/src/utils/markdown-to-docx.ts b/packages/core/src/utils/markdown-to-docx.ts index 4c027ee..d8728a8 100644 --- a/packages/core/src/utils/markdown-to-docx.ts +++ b/packages/core/src/utils/markdown-to-docx.ts @@ -17,6 +17,7 @@ import { import { CHECKBOX_CHECKED, CHECKBOX_UNCHECKED, + parseInlineRuns, parseMarkdownBlocks, type MarkdownBlock, } from './meeting-markdown' @@ -27,9 +28,20 @@ const HEADING_LEVELS = { 3: HeadingLevel.HEADING_3, } as const +/** + * 블록 텍스트를 TextRun 목록으로 바꾼다. 인라인 강조 해석은 HTML 경로와 같은 + * parseInlineRuns 를 쓰므로 PDF/DOCX 굵기가 일치한다. forceBold 는 표 머리글처럼 + * 블록 전체가 굵어야 할 때 쓴다. 빈 텍스트도 문단 구조가 유지되게 빈 run 하나를 둔다. + */ +function inlineTextRuns(text: string, forceBold = false): TextRun[] { + const runs = parseInlineRuns(text) + if (runs.length === 0) return [new TextRun({ text: '', bold: forceBold })] + return runs.map((run) => new TextRun({ text: run.text, bold: forceBold || run.bold })) +} + function tableCell(text: string, bold: boolean): TableCell { return new TableCell({ - children: [new Paragraph({ children: [new TextRun({ text, bold })] })], + children: [new Paragraph({ children: inlineTextRuns(text, bold) })], }) } @@ -39,15 +51,18 @@ export function blocksToDocxElements(blocks: readonly MarkdownBlock[]): Array cursor) runs.push({ text: text.slice(cursor, start), bold: false }) + runs.push({ text: match[1], bold: true }) + cursor = start + match[0].length + } + if (cursor < text.length) runs.push({ text: text.slice(cursor), bold: false }) + return runs +} + // ============================================================ // HTML 렌더링 (PDF 생성용) // ============================================================ @@ -174,9 +204,11 @@ export function escapeHtml(text: string): string { return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch) } -/** 이스케이프 후 인라인 강조(**굵게**)만 태그로 바꾼다. */ +/** 인라인 조각을 이스케이프하고 굵은 조각만 으로 감싼다. */ function renderInline(text: string): string { - return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '$1') + return parseInlineRuns(text) + .map((run) => (run.bold ? `${escapeHtml(run.text)}` : escapeHtml(run.text))) + .join('') } export const CHECKBOX_UNCHECKED = '☐' diff --git a/packages/core/src/voice-error-message.ts b/packages/core/src/voice-error-message.ts new file mode 100644 index 0000000..a92d0e3 --- /dev/null +++ b/packages/core/src/voice-error-message.ts @@ -0,0 +1,67 @@ +// packages/core/src/voice-error-message.ts +// 받아쓰기 오류 코드 → 사용자 문구 종류의 단일 분류(순수 함수, i18n 의존 없음). +// +// VoiceModeService 의 D3ROError.message 는 로그용 영문("Audio capture failed: …")이다. 이 분류가 없으면 +// 녹음 팁·메인 창 알림이 각자 코드를 해석하거나 영문 원문을 그대로 보인다(2026-09-28 감사: 팁 분기가 +// 오류 코드를 받지 못해 한 번도 타지 않았다). 문구 종류(VoiceErrorMessageId)를 여기서 한 번 정하고, +// 표면(main 팁 / 렌더러 알림)은 종류 → 번역 키를 Record 로 매핑한다 — 종류가 늘면 매핑 누락이 타입 오류가 된다. + +import { ErrorCode } from './errors' + +export type VoiceErrorMessageId = + | 'modelMissing' + | 'engine' + | 'micOpen' + | 'micLost' + | 'noSpeech' + | 'tooShort' + | 'transcription' + | 'captionActive' + | 'quota' + | 'llmFailed' + +export function classifyVoiceError(code: number | undefined | null): VoiceErrorMessageId | null { + switch (code) { + case ErrorCode.STTModelNotFound: + return 'modelMissing' + case ErrorCode.STTEngineNotInstalled: + case ErrorCode.STTModelLoadFailed: + case ErrorCode.STTModelNotLoaded: + case ErrorCode.STTSidecarSpawnFailed: + case ErrorCode.STTSidecarCrashed: + case ErrorCode.STTSidecarCommunicationFailed: + return 'engine' + case ErrorCode.AudioDeviceNotFound: + case ErrorCode.AudioDeviceAccessDenied: + case ErrorCode.AudioDeviceBusy: + case ErrorCode.AudioCaptureStartFailed: + case ErrorCode.AudioNoPermission: + return 'micOpen' + case ErrorCode.AudioCaptureFailed: + case ErrorCode.AudioStreamError: + return 'micLost' + case ErrorCode.STTNoAudioData: + return 'noSpeech' + case ErrorCode.STTAudioTooShort: + return 'tooShort' + case ErrorCode.STTTranscriptionFailed: + case ErrorCode.STTTranscriptionTimeout: + return 'transcription' + case ErrorCode.CaptionAlreadyActive: + return 'captionActive' + case ErrorCode.QuotaExceeded: + return 'quota' + case ErrorCode.LLMProcessingFailed: + case ErrorCode.ChainExecutionFailed: + return 'llmFailed' + default: + return null + } +} + +/** 이 종류의 오류를 사용자가 설정에서 고칠 수 있으면 그 탭 */ +export function voiceErrorSettingsTab(id: VoiceErrorMessageId | null): 'stt' | 'audio' | null { + if (id === 'modelMissing') return 'stt' + if (id === 'micOpen') return 'audio' + return null +} diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs index 1bcac8d..89047e7 100644 --- a/scripts/ci/build-portable.mjs +++ b/scripts/ci/build-portable.mjs @@ -38,6 +38,7 @@ import { createReadStream, createWriteStream } from 'node:fs' import * as tar from 'tar' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { buildRuntimeIndex } from './lib/runtime-index-builder.mjs' const require = createRequire(import.meta.url) const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') @@ -424,23 +425,16 @@ if (ffmpegSource && existsSync(ffmpegSource)) { console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다') } -const runtimeIndex = { - schemaVersion: 1, +// 사이드카(PyInstaller)와 @ffmpeg-installer 바이너리는 이 호스트의 네이티브 실행 파일이다. +// platform/arch 를 인덱스에 밝혀야 다른 플랫폼 앱이 부품(~160MB)을 받기 전에 거부할 수 있다. +const runtimeIndex = buildRuntimeIndex({ version, generatedAt: new Date().toISOString(), - components: Object.fromEntries( - Object.entries(runtimeComponents).map(([name, entry]) => [ - name, - { - ...entry, - parts: entry.parts.map((part) => ({ - ...part, - url: `${FEED}/runtime-${version}/${part.name}`, - })), - }, - ]), - ), -} + platform: process.platform, + arch: process.arch, + partBaseUrl: `${FEED}/runtime-${version}`, + components: runtimeComponents, +}) writeFileSync(join(RUNTIME_DIR, 'runtime.json'), `${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8') console.log( [ @@ -452,7 +446,7 @@ console.log( ` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`, ` 인덱스 : ${join(releaseDir, 'portable.json')}`, ` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`, - ` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'} → ${join(RUNTIME_DIR, 'runtime.json')}`, + ` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'} (${runtimeIndex.platform}-${runtimeIndex.arch}) → ${join(RUNTIME_DIR, 'runtime.json')}`, ` 게시 : node scripts/ci/publish-portable-release.mjs`, ].join('\n'), ) \ No newline at end of file diff --git a/scripts/ci/lib/forgejo-generic-file-delete.mjs b/scripts/ci/lib/forgejo-generic-file-delete.mjs new file mode 100644 index 0000000..32b65d6 --- /dev/null +++ b/scripts/ci/lib/forgejo-generic-file-delete.mjs @@ -0,0 +1,27 @@ +// scripts/ci/lib/forgejo-generic-file-delete.mjs +// Forgejo generic registry 어댑터에 파일 단위 삭제(PackageRegistry.deleteFile)를 더한다. +// +// 별칭(*-latest)을 패키지 버전째 지우면 인덱스가 수백 MB 업로드 내내 404가 된다. +// Forgejo generic registry는 파일 하나만 지우는 엔드포인트 +// DELETE /api/packages/{owner}/generic/{name}/{version}/{file} +// 를 제공하므로, 바뀐 파일만 DELETE→PUT 해 404 구간을 파일 하나 교체 시간으로 줄인다. +// 404는 이미 없는 것이므로 성공으로 본다. 그 밖의 오류는 예외(fail-closed). + +/** + * @template {{ fileUrl: (versionPath: string, name: string) => string }} R + * @param {R} registry + * @param {{ fetchImpl: (url: string, init?: RequestInit) => Promise }} deps + * @returns {R & { deleteFile: (versionPath: string, name: string) => Promise }} + */ +export function withFileDeletion(registry, { fetchImpl }) { + return { + ...registry, + async deleteFile(versionPath, name) { + const url = registry.fileUrl(versionPath, name); + const response = await fetchImpl(url, { method: "DELETE" }); + if (!response.ok && response.status !== 404) { + throw new Error(`파일 삭제 실패 (HTTP ${response.status}): ${url}`); + } + }, + }; +} diff --git a/scripts/ci/lib/portable-alias-plan.mjs b/scripts/ci/lib/portable-alias-plan.mjs new file mode 100644 index 0000000..bb03f67 --- /dev/null +++ b/scripts/ci/lib/portable-alias-plan.mjs @@ -0,0 +1,110 @@ +// scripts/ci/lib/portable-alias-plan.mjs +// *-latest 별칭 게시의 순수 정책 (IO 없음). 유스케이스는 ./portable-publish-policy.mjs. +// +// 별칭 교체 원칙 +// - 패키지 버전 전체를 지우지 않는다. 바뀐 파일만 파일 단위로 DELETE→PUT 한다 +// (Forgejo는 같은 이름 덮어쓰기를 409로 거부한다). 404 구간은 파일 하나를 바꾸는 수 초뿐이다. +// - 인덱스(runtime.json / portable.json)는 항상 마지막에 바꾼다 — 인덱스 교체가 커밋 지점이다. +// 새 부품을 올리는 동안에는 옛 인덱스와 옛 부품이 그대로 살아 있다. +// - 새 인덱스가 올라간 뒤에만 별칭 집합에 없는 파일(이전 버전 부품, 예전에 별칭에 올리던 볼륨)을 정리한다. +// - 별칭에는 클라이언트가 별칭 경로로 직접 받는 파일만 둔다. 버전 경로 URL로 참조되는 부품은 올리지 않는다. + +/** + * @typedef {import("./portable-publish-policy.mjs").HashedPayload} HashedPayload + * @typedef {{ item: HashedPayload, replace: boolean }} AliasWrite + * @typedef {{ action: "skip" | "update", writes: AliasWrite[], prunes: string[] }} AliasPlan + */ + +export class AliasSelectionError extends Error { + /** @param {string} message */ + constructor(message) { + super(message); + this.name = "AliasSelectionError"; + } +} + +/** + * 별칭에 올릴 항목을 고른다. aliasNames가 없으면 모든 payload(하위 호환). + * payload 순서를 유지하되 인덱스는 맨 뒤로 보낸다(커밋 지점). + * + * @param {{ items: readonly HashedPayload[], indexName: string, aliasNames?: readonly string[] }} input + * @returns {HashedPayload[]} + */ +export function selectAliasItems({ items, indexName, aliasNames }) { + let selected = [...items]; + if (aliasNames !== undefined) { + const wanted = new Set(aliasNames); + const known = new Set(items.map((item) => item.name)); + const unknown = [...wanted].filter((name) => !known.has(name)); + if (unknown.length > 0) { + throw new AliasSelectionError(`별칭 파일이 게시 payload에 없습니다: ${unknown.join(", ")}`); + } + if (!wanted.has(indexName)) { + throw new AliasSelectionError(`별칭에는 인덱스(${indexName})가 있어야 합니다.`); + } + selected = items.filter((item) => wanted.has(item.name)); + } + return [ + ...selected.filter((item) => item.name !== indexName), + ...selected.filter((item) => item.name === indexName), + ]; +} + +/** + * 별칭 파일 단위 교체 계획. + * - writes: 원격과 바이트가 다른 항목(항목 순서 유지). replace=true면 같은 이름이 원격에 있어 먼저 지워야 한다. + * - prunes: 원격 별칭에만 있는 파일. 모든 writes(인덱스 포함)가 끝난 뒤에 지운다. + * + * @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap }} input + * @returns {AliasPlan} + */ +export function planAliasFiles({ items, remoteHashes }) { + const writes = items + .filter((item) => remoteHashes.get(item.name) !== item.sha256) + .map((item) => ({ item, replace: remoteHashes.has(item.name) })); + const keep = new Set(items.map((item) => item.name)); + const prunes = [...remoteHashes.keys()].filter((name) => !keep.has(name)); + const action = writes.length > 0 || prunes.length > 0 ? "update" : "skip"; + return { action, writes, prunes }; +} + +/** + * 버전 경로가 이미 완성돼 있는데(재빌드 바이트가 달라 버전 단계가 abort) 별칭을 그 게시본으로 + * 복구할 수 있는지 판단한다. 원격 버전 경로의 바이트가 정본이다. + * - 별칭 항목이 버전 경로에 하나라도 없으면 복구 불가(버전 경로 미완성). + * - 별칭에 이미 같은 sha256으로 있는 항목은 그대로 둔다(retain). + * - 다른 항목은 텍스트(인덱스·설치 스크립트)일 때만 버전 경로에서 읽어 옮길 수 있다(reads). + * 이진 부품이 어긋나 있으면 복구 불가. + * + * @param {{ + * aliasItems: readonly HashedPayload[], + * versionedHashes: ReadonlyMap, + * aliasHashes: ReadonlyMap, + * }} input + * @returns {{ restorable: false, reason: string } | + * { restorable: true, reads: Array<{ name: string, contentType: string, sha256: string }>, prunes: string[] }} + */ +export function planAliasRestore({ aliasItems, versionedHashes, aliasHashes }) { + const missing = aliasItems.filter((item) => !versionedHashes.has(item.name)); + if (missing.length > 0) { + return { restorable: false, reason: `버전 경로에 없음: ${missing.map((item) => item.name).join(", ")}` }; + } + /** @type {Array<{ name: string, contentType: string, sha256: string }>} */ + const reads = []; + for (const item of aliasItems) { + const sha256 = /** @type {string} */ (versionedHashes.get(item.name)); + if (aliasHashes.get(item.name) === sha256) continue; + if (!isTextContentType(item.contentType)) { + return { restorable: false, reason: `이진 파일은 게시본에서 옮길 수 없음: ${item.name}` }; + } + reads.push({ name: item.name, contentType: item.contentType, sha256 }); + } + const keep = new Set(aliasItems.map((item) => item.name)); + const prunes = [...aliasHashes.keys()].filter((name) => !keep.has(name)); + return { restorable: true, reads, prunes }; +} + +/** @param {string} contentType */ +export function isTextContentType(contentType) { + return contentType === "application/json" || contentType.startsWith("text/"); +} diff --git a/scripts/ci/lib/portable-publish-alias.test.mjs b/scripts/ci/lib/portable-publish-alias.test.mjs new file mode 100644 index 0000000..cab6fd4 --- /dev/null +++ b/scripts/ci/lib/portable-publish-alias.test.mjs @@ -0,0 +1,423 @@ +// node --test scripts/ci/lib/portable-publish-alias.test.mjs +// *-latest 별칭 교체 회귀: 패키지째 지우고 부품을 다시 올리는 동안 인덱스가 404가 되던 문제, +// 중간 실패 후 CI 재실행(재빌드 바이트 → 버전 경로 abort)으로 별칭이 복구되지 않던 문제. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { withFileDeletion } from "./forgejo-generic-file-delete.mjs"; +import { + hashPayloads, + planAliasFiles, + planAliasRestore, + PortablePublishError, + publishPortablePackages, + selectAliasItems, +} from "./portable-publish-policy.mjs"; + +const sha = (text) => createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex"); +const payload = (name, text, contentType = "application/octet-stream") => ({ + name, + bytes: Buffer.from(text, "utf8"), + contentType, +}); +const runtimeJson = (version, generatedAt = "2026-09-20T00:00:00.000Z") => + JSON.stringify({ schemaVersion: 1, version, generatedAt }); +const portableJson = (version) => JSON.stringify({ version }); +const runtimeIndex = (version, generatedAt) => + payload("runtime.json", runtimeJson(version, generatedAt), "application/json"); +const portableIndex = (version) => payload("portable.json", portableJson(version), "application/json"); +const installer = (text = "irm") => payload("install-d3ro-voice.ps1", text, "text/plain"); + +/** + * Forgejo 동작을 흉내 내는 메모리 registry: 같은 이름 PUT은 409, 파일/버전 단위 삭제 지원. + * 모든 쓰기 직후 watch 경로의 존재 여부를 timeline 에 기록한다. + */ +function forgejoLikeRegistry(initial = {}, { watch = [], failUpload } = {}) { + const packages = new Map( + Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]), + ); + const calls = []; + const timeline = []; + const snapshot = (op) => + timeline.push({ + op, + present: Object.fromEntries( + watch.map((key) => { + const [path, name] = key.split("/"); + return [key, packages.get(path)?.has(name) ?? false]; + }), + ), + }); + return { + packages, + calls, + timeline, + async listFileHashes(versionPath) { + calls.push(["list", versionPath]); + const files = packages.get(versionPath); + return new Map([...(files ?? new Map())].map(([name, text]) => [name, sha(text)])); + }, + async deleteVersion(versionPath) { + calls.push(["deleteVersion", versionPath]); + packages.delete(versionPath); + snapshot(`deleteVersion ${versionPath}`); + }, + async deleteFile(versionPath, name) { + calls.push(["deleteFile", versionPath, name]); + const files = packages.get(versionPath); + files?.delete(name); + if (files && files.size === 0) packages.delete(versionPath); + snapshot(`deleteFile ${versionPath}/${name}`); + }, + async uploadFile(versionPath, file) { + calls.push(["upload", versionPath, file.name]); + if (failUpload?.(versionPath, file.name)) throw new Error(`HTTP 524 ${versionPath}/${file.name}`); + const files = packages.get(versionPath) ?? new Map(); + if (files.has(file.name)) throw new Error(`409 conflict ${versionPath}/${file.name}`); + files.set(file.name, Buffer.from(file.bytes).toString("utf8")); + packages.set(versionPath, files); + snapshot(`upload ${versionPath}/${file.name}`); + }, + async readTextFile(versionPath, name) { + calls.push(["read", versionPath, name]); + return packages.get(versionPath)?.get(name); + }, + }; +} + +const RUNTIME_PARTS = (tag) => [ + payload("d3ro-runtime-sidecar.tar.gz.001", `sidecar-1-${tag}`), + payload("d3ro-runtime-sidecar.tar.gz.002", `sidecar-2-${tag}`), + payload("d3ro-runtime-ffmpeg.tar.gz.001", `ffmpeg-${tag}`), +]; + +const publishRuntime = (registry, version, runtimePayloads) => + publishPortablePackages({ + version, + registry, + log: () => {}, + packages: [ + { kind: "runtime", indexName: "runtime.json", payloads: runtimePayloads, aliasNames: ["runtime.json"] }, + ], + }); + +// ── 순수 정책 ───────────────────────────────────────────────────────────── + +test("selectAliasItems keeps only alias files and moves the index to the end", () => { + const items = hashPayloads([portableIndex("1.9.1"), payload("a.zip.001", "z"), payload("a.7z.001", "v"), installer()]); + const selected = selectAliasItems({ + items, + indexName: "portable.json", + aliasNames: ["portable.json", "a.zip.001", "install-d3ro-voice.ps1"], + }); + assert.deepEqual( + selected.map((item) => item.name), + ["a.zip.001", "install-d3ro-voice.ps1", "portable.json"], + ); + assert.throws( + () => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["a.zip.001"] }), + /portable\.json/, + ); + assert.throws( + () => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["portable.json", "nope"] }), + /nope/, + ); +}); + +test("planAliasFiles swaps only changed files and prunes stale ones", () => { + const items = hashPayloads([payload("new.zip.001", "n"), installer("same"), portableIndex("1.9.1")]); + const plan = planAliasFiles({ + items, + remoteHashes: new Map([ + ["old.zip.001", sha("o")], + ["install-d3ro-voice.ps1", sha("same")], + ["portable.json", sha(portableJson("1.9.0"))], + ]), + }); + assert.equal(plan.action, "update"); + assert.deepEqual( + plan.writes.map(({ item, replace }) => [item.name, replace]), + [["new.zip.001", false], ["portable.json", true]], + ); + assert.deepEqual(plan.prunes, ["old.zip.001"]); + assert.equal(planAliasFiles({ items, remoteHashes: new Map(items.map((i) => [i.name, i.sha256])) }).action, "skip"); +}); + +test("planAliasRestore only restores from a complete versioned package and never copies binaries", () => { + const aliasItems = hashPayloads([payload("a.zip.001", "rebuilt-zip"), runtimeIndex("1.9.1", "B")]); + const versionedHashes = new Map([ + ["a.zip.001", sha("zip")], + ["runtime.json", sha(runtimeJson("1.9.1", "A"))], + ]); + // 별칭에 같은 zip이 이미 있으면 인덱스만 옮기면 된다. + const ok = planAliasRestore({ + aliasItems, + versionedHashes, + aliasHashes: new Map([["a.zip.001", sha("zip")], ["stale", sha("s")]]), + }); + assert.equal(ok.restorable, true); + assert.deepEqual(ok.reads.map((read) => [read.name, read.sha256]), [["runtime.json", sha(runtimeJson("1.9.1", "A"))]]); + assert.deepEqual(ok.prunes, ["stale"]); + // 이진 부품이 어긋나 있으면 복구하지 않는다. + assert.equal(planAliasRestore({ aliasItems, versionedHashes, aliasHashes: new Map() }).restorable, false); + // 버전 경로 미완성(인덱스 없음)이면 복구하지 않는다. + assert.equal( + planAliasRestore({ + aliasItems, + versionedHashes: new Map([["a.zip.001", sha("zip")]]), + aliasHashes: new Map([["a.zip.001", sha("zip")]]), + }).restorable, + false, + ); +}); + +// ── 유스케이스: 404 구간 ────────────────────────────────────────────────── + +test("a tag bump never deletes runtime-latest nor re-uploads parts; runtime.json is missing only for its own swap", async () => { + const registry = forgejoLikeRegistry( + { + "runtime-latest": { + "d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part", + "runtime.json": runtimeJson("1.9.0"), + }, + }, + { watch: ["runtime-latest/runtime.json"] }, + ); + const result = await publishRuntime(registry, "1.9.1", [...RUNTIME_PARTS("191"), runtimeIndex("1.9.1", "2026-09-28T00:00:00.000Z")]); + + assert.deepEqual(result, { versioned: { runtime: "uploaded" }, aliases: { runtime: "replaced" } }); + assert.equal(registry.calls.some(([op]) => op === "deleteVersion"), false); + assert.equal( + registry.calls.some(([op, path, name]) => op === "upload" && path === "runtime-latest" && name !== "runtime.json"), + false, + "parts must not be re-uploaded to the alias — runtime.json points at runtime-", + ); + const gap = registry.timeline.filter((entry) => !entry.present["runtime-latest/runtime.json"]); + assert.deepEqual( + gap.map((entry) => entry.op), + ["deleteFile runtime-latest/runtime.json"], + "the only write while the index is missing is the index swap itself", + ); + assert.deepEqual([...registry.packages.get("runtime-latest").keys()], ["runtime.json"]); + assert.equal( + registry.packages.get("runtime-latest").get("runtime.json"), + runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z"), + ); +}); + +test("portable-latest keeps the old index and installer live while new zip parts upload, prunes after the swap", async () => { + const registry = forgejoLikeRegistry( + { + "portable-latest": { + "D3RO-Voice-1.9.0-x64-portable.7z.001": "legacy-volume", + "D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190", + "install-d3ro-voice.ps1": "irm-old", + "portable.json": portableJson("1.9.0"), + }, + }, + { watch: ["portable-latest/portable.json", "portable-latest/install-d3ro-voice.ps1"] }, + ); + await publishPortablePackages({ + version: "1.9.1", + registry, + log: () => {}, + packages: [ + { + kind: "portable", + indexName: "portable.json", + payloads: [ + payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "vol191"), + payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"), + installer("irm-new"), + portableIndex("1.9.1"), + ], + aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"], + }, + ], + }); + + const aliasOps = registry.timeline.map((entry) => entry.op).filter((op) => op.includes("portable-latest")); + assert.deepEqual(aliasOps, [ + "upload portable-latest/D3RO-Voice-1.9.1-x64-portable.zip.001", + "deleteFile portable-latest/install-d3ro-voice.ps1", + "upload portable-latest/install-d3ro-voice.ps1", + "deleteFile portable-latest/portable.json", + "upload portable-latest/portable.json", + "deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.7z.001", + "deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.zip.001", + ]); + const aliasFiles = Object.fromEntries(registry.packages.get("portable-latest")); + assert.deepEqual(aliasFiles, { + "D3RO-Voice-1.9.1-x64-portable.zip.001": "zip191", + "install-d3ro-voice.ps1": "irm-new", + "portable.json": portableJson("1.9.1"), + }); +}); + +test("an upload failure mid-alias leaves the previous index serving", async () => { + const registry = forgejoLikeRegistry( + { + "portable-latest": { + "D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190", + "install-d3ro-voice.ps1": "irm", + "portable.json": portableJson("1.9.0"), + }, + }, + { failUpload: (path, name) => path === "portable-latest" && name.endsWith(".zip.001") }, + ); + await assert.rejects( + publishPortablePackages({ + version: "1.9.1", + registry, + log: () => {}, + packages: [ + { + kind: "portable", + indexName: "portable.json", + payloads: [payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"), installer("irm"), portableIndex("1.9.1")], + aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"], + }, + ], + }), + /HTTP 524/, + ); + assert.equal(registry.packages.get("portable-latest").get("portable.json"), portableJson("1.9.0")); + assert.equal(registry.packages.get("portable-latest").get("D3RO-Voice-1.9.0-x64-portable.zip.001"), "zip190"); +}); + +// ── 유스케이스: 재실행 복구 ──────────────────────────────────────────────── + +test("a CI re-run with rebuilt bytes restores an alias left without its index, then still fails closed", async () => { + const publishedIndex = runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z"); + const registry = forgejoLikeRegistry({ + "runtime-1.9.1": { + "d3ro-runtime-sidecar.tar.gz.001": "sidecar-original", + "runtime.json": publishedIndex, + }, + // 이전 실행이 runtime.json DELETE 뒤 PUT에서 끊겼다. + "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part" }, + }); + await assert.rejects( + publishRuntime(registry, "1.9.1", [ + payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"), + runtimeIndex("1.9.1", "2026-09-28T01:00:00.000Z"), + ]), + (error) => + error instanceof PortablePublishError && + /runtime-1\.9\.1/.test(error.message) && + /runtime-latest/.test(error.message), + ); + assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), publishedIndex); + assert.equal(registry.packages.get("runtime-latest").has("d3ro-runtime-sidecar.tar.gz.001"), false); + assert.equal( + registry.calls.some(([op, path]) => op !== "list" && op !== "read" && path === "runtime-1.9.1"), + false, + "the immutable versioned package is never written", + ); + assert.equal(registry.packages.get("runtime-1.9.1").get("d3ro-runtime-sidecar.tar.gz.001"), "sidecar-original"); +}); + +test("re-run recovery never rolls back a newer alias nor restores from an incomplete versioned package", async () => { + const newer = runtimeJson("1.9.2"); + const rolledForward = forgejoLikeRegistry({ + "runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p", "runtime.json": runtimeJson("1.9.1", "A") }, + "runtime-latest": { "runtime.json": newer }, + }); + await assert.rejects( + publishRuntime(rolledForward, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]), + PortablePublishError, + ); + assert.equal(rolledForward.packages.get("runtime-latest").get("runtime.json"), newer); + assert.equal(rolledForward.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false); + + const incomplete = forgejoLikeRegistry({ + "runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p" }, + "runtime-latest": { "runtime.json": runtimeJson("1.9.0") }, + }); + await assert.rejects( + publishRuntime(incomplete, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]), + PortablePublishError, + ); + assert.equal(incomplete.packages.get("runtime-latest").get("runtime.json"), runtimeJson("1.9.0")); + assert.equal(incomplete.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false); +}); + +test("a conflict in one package blocks every versioned upload (no partial new version)", async () => { + const registry = forgejoLikeRegistry({ + "portable-1.9.1": { "D3RO-Voice-1.9.1-x64-portable.7z.001": "scoop-pinned" }, + }); + await assert.rejects( + publishPortablePackages({ + version: "1.9.1", + registry, + log: () => {}, + packages: [ + { kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] }, + { kind: "portable", indexName: "portable.json", payloads: [payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "rebuilt"), portableIndex("1.9.1")] }, + ], + }), + /portable-1\.9\.1/, + ); + assert.equal(registry.calls.some(([op]) => op === "upload"), false); +}); + +test("an alias set without the index is rejected before any IO", async () => { + const registry = forgejoLikeRegistry(); + await assert.rejects( + publishPortablePackages({ + version: "1.9.1", + registry, + log: () => {}, + packages: [{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["p"] }], + }), + (error) => error instanceof PortablePublishError && /runtime\.json/.test(error.message), + ); + assert.equal(registry.calls.length, 0); +}); + +test("dry run lists only alias files for the alias path", async () => { + const lines = []; + await publishPortablePackages({ + version: "1.9.1", + registry: forgejoLikeRegistry(), + dryRun: true, + log: (line) => lines.push(line), + packages: [ + { kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] }, + ], + }); + assert.deepEqual(lines.map((line) => line.split(" ")[2]), ["runtime-1.9.1/p", "runtime-1.9.1/runtime.json", "runtime-latest/runtime.json"]); +}); + +// ── IO 어댑터 / 조립 ────────────────────────────────────────────────────── + +test("withFileDeletion deletes a single file and fails closed on non-404 errors", async () => { + const seen = []; + const statuses = { "a.json": 204, "gone.json": 404, "boom.json": 500 }; + const fetchImpl = async (url, init = {}) => { + seen.push(`${init.method} ${url}`); + return new Response(null, { status: statuses[url.split("/").pop()] }); + }; + const base = { fileUrl: (path, name) => `https://feed.test/${path}/${name}`, marker: 1 }; + const registry = withFileDeletion(base, { fetchImpl }); + assert.equal(registry.marker, 1); + await registry.deleteFile("runtime-latest", "a.json"); + await registry.deleteFile("runtime-latest", "gone.json"); + await assert.rejects(registry.deleteFile("runtime-latest", "boom.json"), /HTTP 500/); + assert.deepEqual(seen, [ + "DELETE https://feed.test/runtime-latest/a.json", + "DELETE https://feed.test/runtime-latest/gone.json", + "DELETE https://feed.test/runtime-latest/boom.json", + ]); +}); + +test("the portable publisher swaps alias files individually and keeps parts off runtime-latest", () => { + const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8"); + assert.match(source, /withFileDeletion\(/); + assert.match(source, /aliasNames:\s*\[\s*'runtime\.json'\s*\]/); + assert.match(source, /aliasNames:\s*portableAliasNames/); + // 인덱스가 버전 경로에서도 마지막(설치 스크립트 뒤)이어야 재실행 복구가 완성 여부를 판단할 수 있다. + assert.ok(source.indexOf("name: 'install-d3ro-voice.ps1'") < source.indexOf("name: 'portable.json'")); + assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/); +}); diff --git a/scripts/ci/lib/portable-publish-policy.mjs b/scripts/ci/lib/portable-publish-policy.mjs index 090db34..4122470 100644 --- a/scripts/ci/lib/portable-publish-policy.mjs +++ b/scripts/ci/lib/portable-publish-policy.mjs @@ -7,29 +7,45 @@ // 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다. // 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만 // 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed). -// - 별칭 경로 -latest : 모든 태그가 공유. 버전 경로가 완성된 뒤에만, 그리고 +// - 별칭 경로 -latest : 모든 태그가 공유. 버전 경로가 모두 완성된 뒤에만, 그리고 // 이미 게시된 인덱스(runtime.json / portable.json)의 버전보다 오래된 버전이 아닐 때만 // 교체한다. 게시된 버전을 읽을 수 없으면 건드리지 않는다(fail-closed). +// 교체는 패키지 삭제가 아니라 바뀐 파일만 파일 단위 DELETE→PUT, 인덱스가 마지막이다 +// (./portable-alias-plan.mjs). 별칭에는 spec.aliasNames 파일만 둔다. +// - 재실행 복구: 버전 경로가 이미 완성돼 있고 재빌드 바이트만 달라 abort 되는 경우(빌드가 +// 재현 불가 — generatedAt 등), 중간에 끊긴 별칭을 원격 버전 경로의 인덱스로 복구한 뒤 중단한다. // // 구조 -// 1) 순수 정책: planVersionedPackage / planAliasPackage / parsePublishedIndexVersion / -// decideAliasUpdate +// 1) 순수 정책: planVersionedPackage / parsePublishedIndexVersion / decideAliasUpdate +// + ./portable-alias-plan.mjs (selectAliasItems / planAliasFiles / planAliasRestore) // 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다. -// IO 어댑터는 ./forgejo-generic-registry.mjs 에 있다. +// IO 어댑터는 ./forgejo-generic-registry.mjs (+ ./forgejo-generic-file-delete.mjs) 에 있다. import { createHash } from "node:crypto"; import { decideLatestFeedUpdate, parseSemver } from "./latest-feed-guard.mjs"; +import { + AliasSelectionError, + planAliasFiles, + planAliasRestore, + selectAliasItems, +} from "./portable-alias-plan.mjs"; + +export { planAliasFiles, planAliasRestore, selectAliasItems } from "./portable-alias-plan.mjs"; /** * @typedef {{ name: string, bytes: Uint8Array, contentType: string }} Payload * @typedef {Payload & { sha256: string }} HashedPayload * @typedef {{ * listFileHashes: (versionPath: string) => Promise>, - * deleteVersion: (versionPath: string) => Promise, * uploadFile: (versionPath: string, file: HashedPayload) => Promise, * readTextFile: (versionPath: string, name: string) => Promise, + * deleteFile?: (versionPath: string, name: string) => Promise, + * deleteVersion?: (versionPath: string) => Promise, * }} PackageRegistry - * @typedef {{ kind: string, indexName: string, payloads: readonly Payload[] }} PackageSpec + * deleteFile 이 있으면 별칭을 파일 단위로 교체한다(권장). 없으면 deleteVersion 으로 + * 별칭 전체를 지우고 다시 올리는 예전 방식으로 동작한다(인덱스 404 구간이 길다). + * @typedef {{ kind: string, indexName: string, payloads: readonly Payload[], aliasNames?: readonly string[] }} PackageSpec + * aliasNames: *-latest 별칭에 둘 파일(인덱스 포함). 생략하면 모든 payload. */ export class PortablePublishError extends Error { @@ -47,10 +63,15 @@ export class PortablePublishError extends Error { export function hashPayloads(payloads) { return payloads.map((payload) => ({ ...payload, - sha256: createHash("sha256").update(payload.bytes).digest("hex"), + sha256: sha256Hex(payload.bytes), })); } +/** @param {Uint8Array} bytes */ +function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + /** * 불변 버전 경로 게시 계획. * - 같은 이름에 다른 sha256이 원격에 있음 → abort (절대 삭제/교체하지 않는다) @@ -71,15 +92,16 @@ export function planVersionedPackage({ items, remoteHashes }) { } /** - * 별칭 경로 게시 계획. Forgejo는 파일 단위 덮어쓰기를 거부(409)하므로, 다른 파일이 하나라도 - * 있으면 버전 전체를 지우고 모든 파일을 다시 올린다(낡은 바이트와 새 바이트가 섞이지 않게). + * 별칭 교체 여부 요약(skip/replace). 유스케이스는 planAliasFiles 의 파일 단위 계획을 쓴다. + * deleteFirst 는 원격 별칭에 파일이 있어 교체 시 삭제가 필요하다는 뜻이다. * + * @deprecated planAliasFiles 를 쓴다. 기존 호출부 호환용 요약이다. * @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap }} input * @returns {{ action: "skip" | "replace", deleteFirst: boolean }} */ export function planAliasPackage({ items, remoteHashes }) { - const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256); - if (differing.length === 0) return { action: "skip", deleteFirst: false }; + const { writes } = planAliasFiles({ items, remoteHashes }); + if (writes.length === 0) return { action: "skip", deleteFirst: false }; return { action: "replace", deleteFirst: remoteHashes.size > 0 }; } @@ -128,6 +150,150 @@ async function readAliasVersion(registry, aliasPath, indexName) { return parsePublishedIndexVersion(text); } +/** + * @typedef {HashedPayload[]} AliasItems + * @typedef {{ + * spec: PackageSpec & { items: HashedPayload[] }, + * versionPath: string, + * aliasPath: string, + * aliasItems: AliasItems, + * remoteHashes: Map, + * plan: ReturnType, + * }} PlannedPackage + */ + +/** + * 롤백 방지 판정. 교체해도 되면 true, 더 새로운 버전이 있으면 false, 읽을 수 없으면 예외. + * @param {PackageRegistry} registry + * @param {PlannedPackage} pkg + * @param {string} version + * @param {(message: string) => void} log + * @param {Record} aliases + */ +async function aliasMayAdvance(registry, pkg, version, log, aliases) { + const { aliasPath, spec } = pkg; + const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName); + const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion }); + if (decision.abort) { + throw new PortablePublishError( + `${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` + + "버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).", + ); + } + if (!decision.update) { + log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`); + aliases[spec.kind] = decision.reason; + } + return decision.update; +} + +/** + * 별칭 계획을 실행한다. deleteFile 포트가 있으면 파일 단위 교체(인덱스가 마지막, 정리는 그 뒤), + * 없으면 예전 방식(별칭 전체 삭제 후 전부 업로드)으로 대체한다. + * + * @param {PackageRegistry} registry + * @param {string} aliasPath + * @param {AliasItems} aliasItems + * @param {{ writes: Array<{ item: HashedPayload, replace: boolean }>, prunes: readonly string[] }} plan + */ +async function applyAliasPlan(registry, aliasPath, aliasItems, plan) { + if (typeof registry.deleteFile === "function") { + for (const { item, replace } of plan.writes) { + if (replace) await registry.deleteFile(aliasPath, item.name); + await registry.uploadFile(aliasPath, item); + } + for (const name of plan.prunes) await registry.deleteFile(aliasPath, name); + return; + } + const needsDelete = plan.prunes.length > 0 || plan.writes.some((write) => write.replace); + if (!needsDelete) { + for (const { item } of plan.writes) await registry.uploadFile(aliasPath, item); + return; + } + if (typeof registry.deleteVersion !== "function") { + throw new PortablePublishError(`${aliasPath} 를 교체할 삭제 수단(deleteFile/deleteVersion)이 registry에 없습니다.`); + } + await registry.deleteVersion(aliasPath); + for (const item of aliasItems) await registry.uploadFile(aliasPath, item); +} + +/** + * 로컬 빌드로 별칭을 갱신한다(버전 경로가 완성된 뒤에만 호출). + * @param {PackageRegistry} registry + * @param {PlannedPackage} pkg + * @returns {Promise<"unchanged" | "replaced">} + */ +async function publishAlias(registry, pkg) { + const plan = planAliasFiles({ + items: pkg.aliasItems, + remoteHashes: await registry.listFileHashes(pkg.aliasPath), + }); + if (plan.action === "skip") return "unchanged"; + await applyAliasPlan(registry, pkg.aliasPath, pkg.aliasItems, plan); + return "replaced"; +} + +/** + * 버전 경로가 이미 완성돼 있는데 로컬 재빌드가 달라 게시를 중단하는 경우, 별칭을 원격 버전 경로의 + * 게시본(정본)으로 맞춘다 — 이전 실행이 별칭 교체 도중 끊겼다면 CI 재실행이 이를 복구한다. + * 파일 단위 삭제 포트가 없거나 복구할 수 없으면 아무것도 쓰지 않는다. + * + * @param {PackageRegistry} registry + * @param {PlannedPackage} pkg + * @returns {Promise<"restored" | "unchanged" | "unrestorable">} + */ +async function restoreAliasFromPublished(registry, pkg) { + if (typeof registry.deleteFile !== "function") return "unrestorable"; + const aliasHashes = await registry.listFileHashes(pkg.aliasPath); + const restore = planAliasRestore({ + aliasItems: pkg.aliasItems, + versionedHashes: pkg.remoteHashes, + aliasHashes, + }); + if (!restore.restorable) return "unrestorable"; + if (restore.reads.length === 0 && restore.prunes.length === 0) return "unchanged"; + + /** @type {Array<{ item: HashedPayload, replace: boolean }>} */ + const writes = []; + for (const read of restore.reads) { + const text = await registry.readTextFile(pkg.versionPath, read.name); + if (text === undefined) return "unrestorable"; + const bytes = Buffer.from(text, "utf8"); + // 원격 바이트와 정확히 같은지 확인한다(인코딩 손실이 있으면 옮기지 않는다). + if (sha256Hex(bytes) !== read.sha256) return "unrestorable"; + writes.push({ + item: { name: read.name, bytes, contentType: read.contentType, sha256: read.sha256 }, + replace: aliasHashes.has(read.name), + }); + } + await applyAliasPlan(registry, pkg.aliasPath, [], { writes, prunes: restore.prunes }); + return "restored"; +} + +/** + * @param {PlannedPackage[]} aborted + * @param {Record} aliases + */ +function conflictError(aborted, aliases) { + const lines = aborted.map( + (pkg) => + `${pkg.versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` + + pkg.plan.conflicts.map((item) => item.name).join(", "), + ); + const restored = Object.entries(aliases) + .filter(([, status]) => status === "restored") + .map(([kind]) => `${kind}-latest`); + return new PortablePublishError( + `${lines.join("\n")}\n` + + " 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" + + (restored.length > 0 + ? ` (중단된 별칭은 게시된 버전 경로의 인덱스로 복구했습니다: ${restored.join(", ")})\n` + : "") + + " (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " + + "패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)", + ); +} + /** * 버전 경로를 모두 완성한 뒤 별칭을 갱신한다. * @@ -154,7 +320,22 @@ export async function publishPortablePackages({ } const active = packages .filter((spec) => spec.payloads.length > 0) - .map((spec) => ({ ...spec, items: hashPayloads(spec.payloads) })); + .map((spec) => { + const items = hashPayloads(spec.payloads); + let aliasItems; + try { + aliasItems = selectAliasItems({ items, indexName: spec.indexName, aliasNames: spec.aliasNames }); + } catch (error) { + if (error instanceof AliasSelectionError) throw new PortablePublishError(`${spec.kind}: ${error.message}`); + throw error; + } + return { + spec: { ...spec, items }, + versionPath: `${spec.kind}-${version}`, + aliasPath: `${spec.kind}-latest`, + aliasItems, + }; + }); /** @type {Record} */ const versioned = {}; @@ -162,69 +343,57 @@ export async function publishPortablePackages({ const aliases = {}; if (dryRun) { - for (const spec of active) { - for (const path of [`${spec.kind}-${version}`, `${spec.kind}-latest`]) { - for (const item of spec.items) { - log(`(check) PUT ${describeUrl(path, item.name)} (${item.bytes.length} bytes)`); - } + for (const pkg of active) { + for (const item of pkg.spec.items) { + log(`(check) PUT ${describeUrl(pkg.versionPath, item.name)} (${item.bytes.length} bytes)`); + } + for (const item of pkg.aliasItems) { + log(`(check) PUT ${describeUrl(pkg.aliasPath, item.name)} (${item.bytes.length} bytes)`); } } return { versioned, aliases }; } - // 1) 불변 버전 경로 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다. - for (const spec of active) { - const versionPath = `${spec.kind}-${version}`; - const plan = planVersionedPackage({ - items: spec.items, - remoteHashes: await registry.listFileHashes(versionPath), + // 1) 불변 버전 경로 계획 — 어느 하나라도 충돌하면 어떤 버전 경로에도 쓰지 않는다. + /** @type {PlannedPackage[]} */ + const planned = []; + for (const pkg of active) { + const remoteHashes = await registry.listFileHashes(pkg.versionPath); + planned.push({ + ...pkg, + remoteHashes, + plan: planVersionedPackage({ items: pkg.spec.items, remoteHashes }), }); - if (plan.action === "abort") { - throw new PortablePublishError( - `${versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` + - `${plan.conflicts.map((item) => item.name).join(", ")}\n` + - " 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" + - " (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " + - "패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)", - ); - } - if (plan.action === "skip") { - log(`변경 없음(건너뜀): ${versionPath}`); - versioned[spec.kind] = "unchanged"; - continue; - } - for (const item of plan.uploads) await registry.uploadFile(versionPath, item); - versioned[spec.kind] = plan.uploads.length === spec.items.length ? "uploaded" : "resumed"; } - // 2) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다. - for (const spec of active) { - const aliasPath = `${spec.kind}-latest`; - const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName); - const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion }); - if (decision.abort) { - throw new PortablePublishError( - `${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` + - "버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).", - ); + const aborted = planned.filter((pkg) => pkg.plan.action === "abort"); + if (aborted.length > 0) { + // 재실행 복구: 이미 완성된 버전 경로(게시본)로 끊긴 별칭을 맞춘 뒤 중단한다. + for (const pkg of aborted) { + if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue; + aliases[pkg.spec.kind] = await restoreAliasFromPublished(registry, pkg); } - if (!decision.update) { - log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`); - aliases[spec.kind] = decision.reason; + throw conflictError(aborted, aliases); + } + + // 2) 버전 경로 업로드 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다. + for (const pkg of planned) { + if (pkg.plan.action === "skip") { + log(`변경 없음(건너뜀): ${pkg.versionPath}`); + versioned[pkg.spec.kind] = "unchanged"; continue; } - const plan = planAliasPackage({ - items: spec.items, - remoteHashes: await registry.listFileHashes(aliasPath), - }); - if (plan.action === "skip") { - log(`변경 없음(건너뜀): ${aliasPath}`); - aliases[spec.kind] = "unchanged"; - continue; - } - if (plan.deleteFirst) await registry.deleteVersion(aliasPath); - for (const item of spec.items) await registry.uploadFile(aliasPath, item); - aliases[spec.kind] = "replaced"; + for (const item of pkg.plan.uploads) await registry.uploadFile(pkg.versionPath, item); + versioned[pkg.spec.kind] = + pkg.plan.uploads.length === pkg.spec.items.length ? "uploaded" : "resumed"; + } + + // 3) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다. 파일 단위, 인덱스가 마지막. + for (const pkg of planned) { + if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue; + const status = await publishAlias(registry, pkg); + if (status === "unchanged") log(`변경 없음(건너뜀): ${pkg.aliasPath}`); + aliases[pkg.spec.kind] = status; } return { versioned, aliases }; diff --git a/scripts/ci/lib/runtime-feed-gate.mjs b/scripts/ci/lib/runtime-feed-gate.mjs new file mode 100644 index 0000000..bd238b0 --- /dev/null +++ b/scripts/ci/lib/runtime-feed-gate.mjs @@ -0,0 +1,121 @@ +// scripts/ci/lib/runtime-feed-gate.mjs +// 앱 업데이트(latest.yml)를 게시하기 전에 runtime-latest 가 이 빌드와 맞는지 확인하는 게이트. +// +// 왜: 앱은 사이드카 엔진을 설치본에 넣지 않고 runtime-latest 에서 받는다. 앱이 +// RUNTIME_MIN_VERSION 을 올렸는데 runtime-latest 가 아직 예전 버전이면, 업데이트한 +// 사용자는 설치된 엔진도 "낡았다" 고 거부하고 새 엔진도 받지 못해 로컬 STT 가 멈춘다. +// 태그 파이프라인(release.yml)과 런타임 파이프라인(portable.yml)은 서로 기다리지 않고, +// 로컬 게시(release:updater)도 "런타임 먼저" 가 문서 절차일 뿐이었다. 그래서 두 +// publisher 가 latest.yml 을 올리기 전에 이 게이트를 fail-closed 로 돌린다. +// +// 구성: +// 1) parseRuntimeMinVersions — 앱 소스(runtime-index.ts)의 RUNTIME_MIN_VERSION 을 읽는다 (정본은 앱) +// 2) evaluateRuntimeFeed — 순수 판정 (문제 목록) +// 3) assertRuntimeFeedCompatible — fetch 를 주입받는 IO 어댑터 + +import { compareSemver, parseSemver } from "./latest-feed-guard.mjs"; + +/** 앱 쪽 최소 버전 정본 (저장소 루트 기준) */ +export const RUNTIME_MIN_VERSION_SOURCE = "apps/desktop/src/main/services/runtime/runtime-index.ts"; + +/** 자동 업데이트 feed(latest.yml)가 배포하는 데스크톱 빌드의 대상 — electron-builder --win --x64 */ +export const WINDOWS_X64_TARGET = Object.freeze({ platform: "win32", arch: "x64" }); + +/** + * runtime-index.ts 소스에서 RUNTIME_MIN_VERSION 객체를 읽는다. + * 형식이 바뀌어 읽을 수 없으면 조용히 넘어가지 않고 예외를 던진다. + * @param {string} source + * @returns {Record} + */ +export function parseRuntimeMinVersions(source) { + const block = /export const RUNTIME_MIN_VERSION\b[^=]*=\s*\{([\s\S]*?)\}/.exec(String(source ?? "")); + if (!block) throw new Error("RUNTIME_MIN_VERSION not found in runtime-index.ts"); + /** @type {Record} */ + const result = {}; + for (const match of block[1].matchAll(/^\s*([A-Za-z][\w-]*)\s*:\s*(null|'([^']*)'|"([^"]*)")\s*,?\s*$/gm)) { + const value = match[2] === "null" ? null : (match[3] ?? match[4]); + if (value !== null && !parseSemver(value)) { + throw new Error(`RUNTIME_MIN_VERSION.${match[1]} is not semver: ${value}`); + } + result[match[1]] = value; + } + if (Object.keys(result).length === 0) throw new Error("RUNTIME_MIN_VERSION has no components"); + return result; +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * runtime.json 이 이 빌드의 요구를 만족하는지 판정한다. 비어 있으면 통과. + * - version 은 semver 이고, 최소 버전이 있는 모든 구성 요소의 최소 버전 이상이어야 한다. + * - 앱이 받는 모든 구성 요소(minVersions 의 키)가 components 에 있어야 한다. + * - platform/arch 를 밝혀야 하고, 대상과 같아야 한다 (다른 플랫폼 엔진을 받는 루프 방지). + * @param {{ index: unknown, minVersions: Record, target: { platform: string, arch: string } }} input + * @returns {string[]} + */ +export function evaluateRuntimeFeed({ index, minVersions, target }) { + if (!isRecord(index)) return ["runtime.json is not an object"]; + const problems = []; + const version = typeof index.version === "string" ? index.version : null; + if (!version || !parseSemver(version)) problems.push(`runtime.json version is not semver: ${String(index.version)}`); + + if (typeof index.platform !== "string" || typeof index.arch !== "string") { + problems.push("runtime.json does not declare platform/arch — republish the runtime with the current build-portable.mjs"); + } else if (index.platform !== target.platform || index.arch !== target.arch) { + problems.push( + `runtime.json targets ${index.platform}-${index.arch}, but this app build is ${target.platform}-${target.arch}`, + ); + } + + const components = isRecord(index.components) ? index.components : {}; + for (const [component, minimum] of Object.entries(minVersions)) { + if (!isRecord(components[component])) { + problems.push(`runtime.json has no ${component} component`); + continue; + } + if (minimum !== null && version && parseSemver(version) && compareSemver(version, minimum) < 0) { + problems.push( + `runtime.json version ${version} is below this build's RUNTIME_MIN_VERSION.${component} ${minimum}`, + ); + } + } + return problems; +} + +/** + * 게시된 runtime.json 을 읽어 판정하고, 문제가 있으면 예외를 던진다 (fail-closed). + * 읽을 수 없어도(404·네트워크·JSON 오류) 통과시키지 않는다. + * @param {{ + * url: string, + * fetchImpl: (url: string, init?: RequestInit) => Promise, + * minVersions: Record, + * target?: { platform: string, arch: string }, + * }} input + * @returns {Promise<{ version: string }>} + */ +export async function assertRuntimeFeedCompatible({ url, fetchImpl, minVersions, target = WINDOWS_X64_TARGET }) { + let index; + try { + const response = await fetchImpl(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + index = await response.json(); + } catch (error) { + throw new Error( + `Runtime feed gate: cannot read ${url} (${error instanceof Error ? error.message : String(error)}). ` + + "Publish the runtime first (npm run release:portable) — refusing to publish latest.yml.", + ); + } + const problems = evaluateRuntimeFeed({ index, minVersions, target }); + if (problems.length > 0) { + throw new Error( + [ + `Runtime feed gate: ${url} cannot serve this app build — refusing to publish latest.yml.`, + ...problems.map((problem) => ` - ${problem}`), + " Publish the runtime first (npm run release:portable / portable.yml), then rerun this publisher.", + ].join("\n"), + ); + } + return { version: /** @type {{ version: string }} */ (index).version }; +} diff --git a/scripts/ci/lib/runtime-feed-gate.test.mjs b/scripts/ci/lib/runtime-feed-gate.test.mjs new file mode 100644 index 0000000..50bb2f3 --- /dev/null +++ b/scripts/ci/lib/runtime-feed-gate.test.mjs @@ -0,0 +1,129 @@ +// node --test scripts/ci/lib/runtime-feed-gate.test.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { fileURLToPath, URL } from "node:url"; +import { + RUNTIME_MIN_VERSION_SOURCE, + WINDOWS_X64_TARGET, + assertRuntimeFeedCompatible, + evaluateRuntimeFeed, + parseRuntimeMinVersions, +} from "./runtime-feed-gate.mjs"; +import { buildRuntimeIndex } from "./runtime-index-builder.mjs"; + +const SHA = "a".repeat(64); +const MIN = { sidecar: "1.7.0", ffmpeg: null }; + +function index({ version = "1.9.0", platform = "win32", arch = "x64", components = ["sidecar", "ffmpeg"] } = {}) { + return buildRuntimeIndex({ + version, + generatedAt: "2026-09-28T00:00:00.000Z", + platform, + arch, + partBaseUrl: `https://feed.test/runtime-${version}`, + components: Object.fromEntries( + components.map((name) => [ + name, + { + archive: `d3ro-runtime-${name}.tar.gz`, + sha256: SHA, + totalSize: 10, + parts: [{ name: `d3ro-runtime-${name}.tar.gz.001`, size: 10, sha256: SHA }], + }, + ]), + ), + }); +} + +function jsonFetch(body, status = 200) { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + return new Response(JSON.stringify(body), { status }); + }; + return { fetchImpl, calls }; +} + +test("parseRuntimeMinVersions reads the app's real RUNTIME_MIN_VERSION (drift guard)", () => { + const source = readFileSync(fileURLToPath(new URL(`../../../${RUNTIME_MIN_VERSION_SOURCE}`, import.meta.url)), "utf8"); + const parsed = parseRuntimeMinVersions(source); + assert.deepEqual(Object.keys(parsed).sort(), ["ffmpeg", "sidecar"]); + assert.match(parsed.sidecar, /^\d+\.\d+\.\d+$/); + assert.equal(parsed.ffmpeg, null); +}); + +test("parseRuntimeMinVersions fails loudly when the constant cannot be read", () => { + assert.throws(() => parseRuntimeMinVersions("export const OTHER = {}"), /RUNTIME_MIN_VERSION not found/); + assert.throws( + () => parseRuntimeMinVersions("export const RUNTIME_MIN_VERSION = {\n sidecar: '1.7',\n}"), + /not semver/, + ); +}); + +test("a runtime feed that satisfies the minimum and target passes", () => { + assert.deepEqual(evaluateRuntimeFeed({ index: index(), minVersions: MIN, target: WINDOWS_X64_TARGET }), []); +}); + +test("runtime-latest older than this build's minimum blocks latest.yml (regression)", () => { + const problems = evaluateRuntimeFeed({ + index: index({ version: "1.6.0" }), + minVersions: MIN, + target: WINDOWS_X64_TARGET, + }); + assert.equal(problems.length, 1); + assert.match(problems[0], /1\.6\.0 is below .*sidecar 1\.7\.0/); +}); + +test("a legacy index without platform/arch or for another platform is rejected", () => { + const legacy = index(); + delete legacy.platform; + delete legacy.arch; + assert.match( + evaluateRuntimeFeed({ index: legacy, minVersions: MIN, target: WINDOWS_X64_TARGET }).join("\n"), + /does not declare platform\/arch/, + ); + assert.match( + evaluateRuntimeFeed({ index: index({ platform: "darwin", arch: "arm64" }), minVersions: MIN, target: WINDOWS_X64_TARGET }).join("\n"), + /targets darwin-arm64/, + ); +}); + +test("a missing component is rejected even without a minimum version", () => { + const problems = evaluateRuntimeFeed({ + index: index({ components: ["sidecar"] }), + minVersions: MIN, + target: WINDOWS_X64_TARGET, + }); + assert.deepEqual(problems, ["runtime.json has no ffmpeg component"]); +}); + +test("assertRuntimeFeedCompatible fails closed when the feed is unreadable", async () => { + const missing = jsonFetch({}, 404); + await assert.rejects( + assertRuntimeFeedCompatible({ url: "https://feed.test/runtime-latest/runtime.json", fetchImpl: missing.fetchImpl, minVersions: MIN }), + /cannot read .*HTTP 404/, + ); + const failing = async () => { + throw new Error("offline"); + }; + await assert.rejects( + assertRuntimeFeedCompatible({ url: "https://feed.test/x", fetchImpl: failing, minVersions: MIN }), + /cannot read .*offline/, + ); +}); + +test("assertRuntimeFeedCompatible returns the feed version when compatible and throws when stale", async () => { + const ok = jsonFetch(index({ version: "1.9.0" })); + assert.deepEqual( + await assertRuntimeFeedCompatible({ url: "https://feed.test/runtime.json", fetchImpl: ok.fetchImpl, minVersions: MIN }), + { version: "1.9.0" }, + ); + assert.deepEqual(ok.calls, ["https://feed.test/runtime.json"]); + + const stale = jsonFetch(index({ version: "1.6.0" })); + await assert.rejects( + assertRuntimeFeedCompatible({ url: "https://feed.test/runtime.json", fetchImpl: stale.fetchImpl, minVersions: MIN }), + /refusing to publish latest\.yml[\s\S]*1\.6\.0 is below/, + ); +}); diff --git a/scripts/ci/lib/runtime-index-builder.mjs b/scripts/ci/lib/runtime-index-builder.mjs new file mode 100644 index 0000000..c0f494d --- /dev/null +++ b/scripts/ci/lib/runtime-index-builder.mjs @@ -0,0 +1,52 @@ +// scripts/ci/lib/runtime-index-builder.mjs +// 로컬 AI 런타임 인덱스(runtime.json)를 만드는 순수 함수. +// +// 왜 분리하나: build-portable.mjs 가 인덱스를 즉석 객체로 만들면서 platform/arch 를 +// 빠뜨렸다. 사이드카(PyInstaller)와 ffmpeg 는 빌드 호스트의 네이티브 실행 파일인데, +// 인덱스가 그 사실을 밝히지 않아 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고 +// 검증에서 떨어진 뒤 매번 ~160MB 를 다시 받았다. 인덱스 모양은 여기 한 곳에서 정하고, +// 클라이언트(apps/desktop/src/main/services/runtime/runtime-index.ts)는 platform/arch 가 +// 다르면 부품을 받기 전에 거부한다. + +export const RUNTIME_INDEX_SCHEMA_VERSION = 1; + +/** + * @typedef {{ name: string, size: number, sha256: string }} RuntimePackedPart + * @typedef {{ archive: string, sha256: string, totalSize: number, parts: RuntimePackedPart[] }} RuntimePackedComponent + */ + +/** + * @param {{ + * version: string, + * generatedAt: string, + * platform: string, + * arch: string, + * partBaseUrl: string, + * components: Record, + * }} input + */ +export function buildRuntimeIndex({ version, generatedAt, platform, arch, partBaseUrl, components }) { + if (typeof platform !== "string" || platform.length === 0) { + throw new Error("runtime index requires a build platform (process.platform)"); + } + if (typeof arch !== "string" || arch.length === 0) { + throw new Error("runtime index requires a build arch (process.arch)"); + } + const base = String(partBaseUrl).replace(/\/+$/, ""); + return { + schemaVersion: RUNTIME_INDEX_SCHEMA_VERSION, + version, + platform, + arch, + generatedAt, + components: Object.fromEntries( + Object.entries(components).map(([name, entry]) => [ + name, + { + ...entry, + parts: entry.parts.map((part) => ({ ...part, url: `${base}/${part.name}` })), + }, + ]), + ), + }; +} diff --git a/scripts/ci/lib/runtime-index-builder.test.mjs b/scripts/ci/lib/runtime-index-builder.test.mjs new file mode 100644 index 0000000..a6295ed --- /dev/null +++ b/scripts/ci/lib/runtime-index-builder.test.mjs @@ -0,0 +1,48 @@ +// node --test scripts/ci/lib/runtime-index-builder.test.mjs +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { RUNTIME_INDEX_SCHEMA_VERSION, buildRuntimeIndex } from "./runtime-index-builder.mjs"; + +const SHA = "b".repeat(64); +const components = { + sidecar: { + component: "sidecar", + archive: "d3ro-runtime-sidecar.tar.gz", + sha256: SHA, + totalSize: 20, + parts: [ + { name: "d3ro-runtime-sidecar.tar.gz.001", size: 10, sha256: SHA }, + { name: "d3ro-runtime-sidecar.tar.gz.002", size: 10, sha256: SHA }, + ], + }, +}; + +test("runtime index declares the build platform and arch (regression: macOS got the Windows engine)", () => { + const index = buildRuntimeIndex({ + version: "1.9.0", + generatedAt: "2026-09-28T00:00:00.000Z", + platform: "win32", + arch: "x64", + partBaseUrl: "https://feed.test/runtime-1.9.0/", + components, + }); + assert.equal(index.schemaVersion, RUNTIME_INDEX_SCHEMA_VERSION); + assert.equal(index.platform, "win32"); + assert.equal(index.arch, "x64"); + assert.equal(index.version, "1.9.0"); + assert.deepEqual( + index.components.sidecar.parts.map((part) => part.url), + [ + "https://feed.test/runtime-1.9.0/d3ro-runtime-sidecar.tar.gz.001", + "https://feed.test/runtime-1.9.0/d3ro-runtime-sidecar.tar.gz.002", + ], + ); + // 입력은 바꾸지 않는다 + assert.equal("url" in components.sidecar.parts[0], false); +}); + +test("runtime index refuses to be built without a platform or arch", () => { + const base = { version: "1.9.0", generatedAt: "x", partBaseUrl: "https://feed.test", components }; + assert.throws(() => buildRuntimeIndex({ ...base, platform: "", arch: "x64" }), /platform/); + assert.throws(() => buildRuntimeIndex({ ...base, platform: "win32", arch: undefined }), /arch/); +}); diff --git a/scripts/ci/lib/update-policy-schema.mjs b/scripts/ci/lib/update-policy-schema.mjs new file mode 100644 index 0000000..96fb984 --- /dev/null +++ b/scripts/ci/lib/update-policy-schema.mjs @@ -0,0 +1,97 @@ +// scripts/ci/lib/update-policy-schema.mjs +// release/update-policy.json 의 엄격한 스키마 검증 (게시 직전 게이트). +// +// 왜: 클라이언트(apps/desktop/src/main/update-policy.ts)는 모르는 형식의 필드를 버린다. +// 예전에는 `"killSwitch": "true"`, `"stagingPercentage": "5"` 같은 오타가 가장 허용적인 +// 기본값(킬 스위치 꺼짐, 100% 배포)으로 조용히 바뀌었고, publisher 는 schemaVersion 만 +// 보거나(forgejo) 아무것도 보지 않고(updater) 그대로 올렸다. 게시 경로마다 같은 규칙으로 +// 막도록 규칙을 여기 한 곳에 둔다 (verify-release-metadata.mjs 의 CI 검사와 같은 규칙). + +const CHANNELS = ["latest", "beta", "alpha"]; +const STABLE_SEMVER = /^\d+\.\d+\.\d+$/; +const KNOWN_KEYS = new Set([ + "schemaVersion", + "defaultChannel", + "channels", + "minimumSupportedVersion", + "forceInstallBelow", + "fullInstallOnMajorChange", + "fullInstallVersionGap", + "stagingPercentage", + "killSwitch", +]); + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * 모든 필드가 있고 형식이 맞는지 본다. 모르는 필드도 오류로 본다(오타 방지). + * @param {unknown} doc + * @returns {string[]} 문제 목록 (비면 통과) + */ +export function validateUpdatePolicyDocument(doc) { + if (!isRecord(doc)) return ["update-policy.json must be a JSON object"]; + const errors = []; + const fail = (condition, message) => { + if (!condition) errors.push(message); + }; + + for (const key of Object.keys(doc)) fail(KNOWN_KEYS.has(key), `unknown field "${key}"`); + + fail(doc.schemaVersion === 1, "schemaVersion must be 1"); + fail(CHANNELS.includes(doc.defaultChannel), `defaultChannel must be one of ${CHANNELS.join("/")}`); + + if (!isRecord(doc.channels)) { + errors.push("channels must be an object"); + } else { + for (const key of Object.keys(doc.channels)) fail(CHANNELS.includes(key), `unknown channel "${key}"`); + for (const channel of CHANNELS) { + const entry = doc.channels[channel]; + fail( + isRecord(entry) && typeof entry.allowPrerelease === "boolean" && Object.keys(entry).length === 1, + `channels.${channel} must be { "allowPrerelease": boolean }`, + ); + } + } + + fail( + typeof doc.minimumSupportedVersion === "string" && STABLE_SEMVER.test(doc.minimumSupportedVersion), + "minimumSupportedVersion must be a stable semver string (x.y.z)", + ); + fail( + doc.forceInstallBelow === null || + (typeof doc.forceInstallBelow === "string" && STABLE_SEMVER.test(doc.forceInstallBelow)), + "forceInstallBelow must be null or a stable semver string (x.y.z)", + ); + fail(typeof doc.fullInstallOnMajorChange === "boolean", "fullInstallOnMajorChange must be a boolean"); + fail( + Number.isSafeInteger(doc.fullInstallVersionGap) && doc.fullInstallVersionGap >= 0, + "fullInstallVersionGap must be a non-negative integer", + ); + fail( + Number.isSafeInteger(doc.stagingPercentage) && doc.stagingPercentage >= 0 && doc.stagingPercentage <= 100, + "stagingPercentage must be an integer between 0 and 100", + ); + fail(typeof doc.killSwitch === "boolean", "killSwitch must be a boolean (true/false, not a string)"); + return errors; +} + +/** + * 원문 바이트/문자열을 파싱해 검증한다. 문제가 있으면 모두 모아 예외를 던진다. + * @param {string | Buffer} raw + * @param {string} [label] + */ +export function assertValidUpdatePolicy(raw, label = "update-policy.json") { + let doc; + try { + doc = JSON.parse(Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw)); + } catch (error) { + throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + const errors = validateUpdatePolicyDocument(doc); + if (errors.length > 0) { + throw new Error(`${label} is invalid — refusing to publish:\n${errors.map((error) => ` - ${error}`).join("\n")}`); + } + return doc; +} diff --git a/scripts/ci/lib/update-policy-schema.test.mjs b/scripts/ci/lib/update-policy-schema.test.mjs new file mode 100644 index 0000000..ea5ec56 --- /dev/null +++ b/scripts/ci/lib/update-policy-schema.test.mjs @@ -0,0 +1,55 @@ +// node --test scripts/ci/lib/update-policy-schema.test.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { fileURLToPath, URL } from "node:url"; +import { assertValidUpdatePolicy, validateUpdatePolicyDocument } from "./update-policy-schema.mjs"; + +const committedRaw = readFileSync(fileURLToPath(new URL("../../../release/update-policy.json", import.meta.url)), "utf8"); +const committed = JSON.parse(committedRaw); + +test("the committed release/update-policy.json passes the strict schema", () => { + assert.deepEqual(validateUpdatePolicyDocument(committed), []); + assert.deepEqual(assertValidUpdatePolicy(committedRaw), committed); +}); + +for (const [field, value, pattern] of [ + ["stagingPercentage", 5.5, /stagingPercentage/], + ["stagingPercentage", "5", /stagingPercentage/], + ["stagingPercentage", 101, /stagingPercentage/], + ["killSwitch", "true", /killSwitch/], + ["killSwitch", 1, /killSwitch/], + ["minimumSupportedVersion", "1.9", /minimumSupportedVersion/], + ["forceInstallBelow", "v1.0.0", /forceInstallBelow/], + ["fullInstallVersionGap", -1, /fullInstallVersionGap/], + ["fullInstallOnMajorChange", "yes", /fullInstallOnMajorChange/], + ["defaultChannel", "stable", /defaultChannel/], + ["schemaVersion", 2, /schemaVersion/], +]) { + test(`rejects ${field}=${JSON.stringify(value)} instead of publishing a silently permissive policy`, () => { + const errors = validateUpdatePolicyDocument({ ...committed, [field]: value }); + assert.equal(errors.length, 1, errors.join("\n")); + assert.match(errors[0], pattern); + assert.throws(() => assertValidUpdatePolicy(JSON.stringify({ ...committed, [field]: value })), /refusing to publish/); + }); +} + +test("rejects missing safety fields, unknown fields and malformed channels", () => { + const { killSwitch: _omit, ...withoutKillSwitch } = committed; + assert.match(validateUpdatePolicyDocument(withoutKillSwitch).join("\n"), /killSwitch/); + assert.match(validateUpdatePolicyDocument({ ...committed, killswitch: true }).join("\n"), /unknown field "killswitch"/); + assert.match( + validateUpdatePolicyDocument({ ...committed, channels: { ...committed.channels, beta: { allowPrerelease: "true" } } }).join("\n"), + /channels\.beta/, + ); + assert.match( + validateUpdatePolicyDocument({ ...committed, channels: { ...committed.channels, rogue: { allowPrerelease: true } } }).join("\n"), + /unknown channel "rogue"/, + ); +}); + +test("rejects non-object and non-JSON input", () => { + assert.deepEqual(validateUpdatePolicyDocument([]), ["update-policy.json must be a JSON object"]); + assert.throws(() => assertValidUpdatePolicy("{"), /not valid JSON/); + assert.throws(() => assertValidUpdatePolicy(Buffer.from("null")), /must be a JSON object/); +}); diff --git a/scripts/ci/publish-forgejo-release.mjs b/scripts/ci/publish-forgejo-release.mjs index 47e7add..c283af5 100644 --- a/scripts/ci/publish-forgejo-release.mjs +++ b/scripts/ci/publish-forgejo-release.mjs @@ -31,6 +31,13 @@ import { uploadImmutableAsset, } from "./lib/immutable-package-guard.mjs"; import { publishLatestAlias, readPublishedFeedVersion } from "./lib/latest-feed-guard.mjs"; +import { + RUNTIME_MIN_VERSION_SOURCE, + WINDOWS_X64_TARGET, + assertRuntimeFeedCompatible, + parseRuntimeMinVersions, +} from "./lib/runtime-feed-gate.mjs"; +import { assertValidUpdatePolicy } from "./lib/update-policy-schema.mjs"; const { forgejoAuthorization } = credentialHelpers; @@ -68,13 +75,8 @@ if (tag !== `v${productVersion.version}`) { } const policyPath = fileURLToPath(new URL("../../release/update-policy.json", import.meta.url)); -const policyRaw = readFileSync(policyPath); -try { - const policy = JSON.parse(policyRaw.toString("utf8")); - if (policy.schemaVersion !== 1) throw new Error("unsupported schemaVersion"); -} catch (error) { - throw new Error(`update-policy.json is invalid: ${error instanceof Error ? error.message : String(error)}`); -} +// schemaVersion 만 보면 "killSwitch": "true" 같은 오타가 클라이언트에서 가장 허용적인 값으로 바뀐다. +assertValidUpdatePolicy(readFileSync(policyPath), "release/update-policy.json"); const releaseDirectory = process.env.FORGEJO_RELEASE_DIR?.trim() ? process.env.FORGEJO_RELEASE_DIR.trim() @@ -109,6 +111,20 @@ if (dryRun) { process.exit(0); } +// 런타임 게이트 — 이 빌드의 RUNTIME_MIN_VERSION 을 runtime-latest 가 만족하지 못하면 어떤 파일도 +// 올리기 전에 멈춘다. release.yml 과 portable.yml 은 같은 태그에서 서로 기다리지 않고 돌기 때문에, +// 최소 버전을 올린 릴리스는 런타임 게시가 끝난 뒤 이 job 을 다시 실행해야 한다. +const runtimeFeedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/runtime-latest/runtime.json`; +const runtimeFeed = await assertRuntimeFeedCompatible({ + url: runtimeFeedUrl, + fetchImpl: forgejoFetch, + minVersions: parseRuntimeMinVersions( + readFileSync(fileURLToPath(new URL(`../../${RUNTIME_MIN_VERSION_SOURCE}`, import.meta.url)), "utf8"), + ), + target: WINDOWS_X64_TARGET, +}); +process.stdout.write(` runtime-latest ${runtimeFeed.version} satisfies this build's runtime minimum\n`); + // 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지). // 버전별 경로의 원격 sha256이 로컬과 같으면 재시도/재실행으로 보고 건너뛰고, // 다른 바이트(재빌드·재서명된 설치본 등)가 있으면 어떤 파일도 건드리기 전에 fail-closed로 중단한다. diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs index 6889de3..02db073 100644 --- a/scripts/ci/publish-portable-release.mjs +++ b/scripts/ci/publish-portable-release.mjs @@ -9,12 +9,16 @@ // .../generic/d3ro-voice/portable-/<륨>.7z.00N // .../generic/d3ro-voice/portable-/portable.json // .../generic/d3ro-voice/portable-/install-d3ro-voice.ps1 -// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias) +// .../generic/d3ro-voice/portable-latest/... (zip 부품 + install-d3ro-voice.ps1 + portable.json) // .../generic/d3ro-voice/runtime-/... (로컬 AI 런타임 부품 + runtime.json) -// .../generic/d3ro-voice/runtime-latest/... (동일 파일 alias) +// .../generic/d3ro-voice/runtime-latest/runtime.json (인덱스만 — 부품 URL은 runtime-) // // 버전 경로는 불변(다른 바이트면 중단, 부분 업로드는 이어 올림), *-latest 별칭은 버전 경로가 // 모두 완성된 뒤 더 오래된 버전으로 되돌리지 않을 때만 교체한다 — lib/portable-publish-policy.mjs. +// 별칭에는 클라이언트가 별칭 경로로 직접 받는 파일만 둔다(aliasNames). 7z 볼륨·런타임 부품은 +// 인덱스가 버전 경로 URL로 가리키므로 별칭에 올리지 않는다. 설치 스크립트는 zip 부품을 +// `$FeedBase/<부품>`(= portable-latest)에서 받으므로 zip 부품은 별칭에 둔다. +// 별칭 교체는 파일 단위 DELETE→PUT이고 인덱스가 마지막이다(패키지째 지우지 않는다). // // 사용: // node scripts/ci/build-portable.mjs @@ -26,6 +30,7 @@ import { existsSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { withFileDeletion } from './lib/forgejo-generic-file-delete.mjs' import { createForgejoGenericRegistry } from './lib/forgejo-generic-registry.mjs' import { publishPortablePackages } from './lib/portable-publish-policy.mjs' @@ -56,7 +61,10 @@ if (!existsSync(installerPath)) { } // 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다. +// 순서: 볼륨 → zip 부품 → 설치 스크립트 → portable.json(인덱스가 마지막 = 커밋 지점) const payloads = [] +/** portable-latest 별칭에 둘 파일 (설치 스크립트가 별칭 경로에서 직접 받는 것) */ +const portableAliasNames = [] for (const volume of index.volumes) { const path = join(releaseDir, volume.name) if (!existsSync(path)) { @@ -88,17 +96,19 @@ for (const part of index.zipParts ?? []) { process.exit(1) } payloads.push({ name: part.name, bytes: partBytes, contentType: 'application/octet-stream' }) + portableAliasNames.push(part.name) } -payloads.push({ - name: 'portable.json', - bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'), - contentType: 'application/json', -}) payloads.push({ name: 'install-d3ro-voice.ps1', bytes: await readFile(installerPath), contentType: 'text/plain', }) +payloads.push({ + name: 'portable.json', + bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'), + contentType: 'application/json', +}) +portableAliasNames.push('install-d3ro-voice.ps1', 'portable.json') // ── 로컬 AI 런타임 번들 게시 (설치본에는 없고, 앱이 처음 필요할 때 내려받는다) ── const runtimeDir = join(releaseDir, 'runtime') @@ -141,12 +151,16 @@ async function forgejoFetch(url, init = {}) { const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice' -const registry = createForgejoGenericRegistry({ - feedUrl: FEED, - packageApiUrl: PACKAGE_API, - fetchImpl: forgejoFetch, - onUploaded: (url) => console.log(`[portable] uploaded ${url}`), -}) +// 파일 단위 삭제를 더해 별칭을 패키지째 지우지 않고 바뀐 파일만 교체한다. +const registry = withFileDeletion( + createForgejoGenericRegistry({ + feedUrl: FEED, + packageApiUrl: PACKAGE_API, + fetchImpl: forgejoFetch, + onUploaded: (url) => console.log(`[portable] uploaded ${url}`), + }), + { fetchImpl: forgejoFetch }, +) // 정책(불변 버전 경로 · 별칭 롤백 방지)은 lib/portable-publish-policy.mjs 에 있다. // 버전 경로(runtime-, portable-)를 모두 완성한 뒤에만 *-latest 별칭을 갱신한다. @@ -154,8 +168,14 @@ try { await publishPortablePackages({ version, packages: [ - { kind: 'runtime', indexName: 'runtime.json', payloads: runtimePayloads }, - { kind: 'portable', indexName: 'portable.json', payloads }, + // 런타임 부품은 runtime.json이 runtime- URL로 가리킨다 → 별칭에는 인덱스만. + { + kind: 'runtime', + indexName: 'runtime.json', + payloads: runtimePayloads, + aliasNames: ['runtime.json'], + }, + { kind: 'portable', indexName: 'portable.json', payloads, aliasNames: portableAliasNames }, ], registry, dryRun: check, @@ -175,7 +195,7 @@ console.log( ` 인덱스 : ${FEED}/portable-latest/portable.json`, ` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`, runtimePayloads.length - ? ` 런타임 : ${FEED}/runtime-latest/runtime.json (${runtimePayloads.length}개 파일)` + ? ` 런타임 : ${FEED}/runtime-latest/runtime.json (runtime-${version} 에 ${runtimePayloads.length}개 파일)` : ' 런타임 : (없음)', '', ' 설치(Scoop, 권장):', diff --git a/scripts/ci/publish-updater-release.mjs b/scripts/ci/publish-updater-release.mjs index fe72a08..558eee4 100644 --- a/scripts/ci/publish-updater-release.mjs +++ b/scripts/ci/publish-updater-release.mjs @@ -21,6 +21,13 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { + RUNTIME_MIN_VERSION_SOURCE, + WINDOWS_X64_TARGET, + assertRuntimeFeedCompatible, + parseRuntimeMinVersions, +} from './lib/runtime-feed-gate.mjs' +import { assertValidUpdatePolicy } from './lib/update-policy-schema.mjs' const { forgejoAuthorization } = credentialHelpers @@ -133,6 +140,16 @@ const installerPath = join(releaseDir, installer) const blockmapPath = `${installerPath}.blockmap` const policyPath = join(root, 'release', 'update-policy.json') +// 원격 정책은 오타 하나로 킬 스위치·staged rollout 이 조용히 꺼질 수 있다 — 올리기 전에 엄격히 검증한다. +if (existsSync(policyPath)) { + try { + assertValidUpdatePolicy(readFileSync(policyPath), 'release/update-policy.json') + } catch (error) { + console.error(`[updater] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + } +} + const payloads = [ { name: installer, path: installerPath, type: 'application/octet-stream' }, { name: `${installer}.blockmap`, path: blockmapPath, type: 'application/octet-stream' }, @@ -175,6 +192,21 @@ if (!metadata.includes(`version: ${version}`)) { const targets = [`${FEED}/${version}`, `${FEED}/latest`] +// 앱이 요구하는 런타임(RUNTIME_MIN_VERSION)을 runtime-latest 가 아직 제공하지 못하면, 이 설치본으로 +// 업데이트한 사용자는 로컬 STT 엔진을 쓸 수 없다. latest.yml 을 올리기 전에 fail-closed 로 확인한다. +try { + const runtime = await assertRuntimeFeedCompatible({ + url: `${FEED}/runtime-latest/runtime.json`, + fetchImpl: (url, init) => fetch(url, init), + minVersions: parseRuntimeMinVersions(readFileSync(join(root, RUNTIME_MIN_VERSION_SOURCE), 'utf8')), + target: WINDOWS_X64_TARGET, + }) + console.log(`[updater] runtime-latest ${runtime.version} 이 이 빌드의 최소 런타임 요구를 만족합니다.`) +} catch (error) { + console.error(`[updater] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +} + if (check) { console.log('[updater] (check) 게시 예정:') for (const target of targets) { diff --git a/server/supabase/functions/generate-meeting-document/generation.ts b/server/supabase/functions/generate-meeting-document/generation.ts index 18eb3a0..61a8800 100644 --- a/server/supabase/functions/generate-meeting-document/generation.ts +++ b/server/supabase/functions/generate-meeting-document/generation.ts @@ -6,10 +6,15 @@ // adapter that wires real clients into these ports and turns the result into a // Response. // -// Quota: the claim RPC holds one unit of the user's allowance while the request -// is 'processing' (migration 20260928000037). Every path that leaves a claimed -// request unfinished must therefore call markFailed so the unit is released -// immediately instead of waiting for the lease to expire. +// Quota: the claim RPC reserves one unit of the user's LLM allowance through +// the shared reserve_llm_quota lease (migration 20260929020000), the same +// ledger llm-proxy uses, so a running document and a chat request can never +// both take the last unit. commit settles the lease; markFailed releases it. +// Every path that leaves a claimed request unfinished must therefore call +// markFailed so the unit is returned immediately instead of waiting for the +// 10 minute lease to expire. commit can still answer generation_quota_exceeded +// when the lease expired (or the claim predates the lease) and the allowance +// is spent by then; that maps to markFailed('quota_exceeded') + 429. import { buildMeetingDocumentSystemPrompt } from '../_shared/generative-ai-safety.ts' import { diff --git a/server/supabase/functions/payple-webhook/handler.test.ts b/server/supabase/functions/payple-webhook/handler.test.ts new file mode 100644 index 0000000..2102d70 --- /dev/null +++ b/server/supabase/functions/payple-webhook/handler.test.ts @@ -0,0 +1,118 @@ +// Handler-level regression tests with in-memory ports: no Supabase, no Payple. +import { + createPaypleWebhookHandler, + type IgnoredProviderEvent, + type PaypleWebhookPorts, + type ProviderApplyResult, +} from './index.ts' +import type { CorrelatedPayment, PaypleProviderEventArgs } from './webhook-policy.ts' +import { type PayplePaymentLookupResult, TIER_PRICE } from '../_shared/payple.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const OLD_ORDER = 'D3RO-20260801090000-user-oid1' +const CURRENT_ORDER = 'D3RO-20260901090000-user-oid2' +const PAYER = 'payer-billing-key' + +interface FakeState { + applied: Array + ignored: IgnoredProviderEvent[] +} + +function fakePorts( + state: FakeState, + lookups: Record>, +): PaypleWebhookPorts { + // Both orders were registered on the same billing key; oid2 (the renewal) + // funds the current period. + const operations: Record = { + [OLD_ORDER]: { + user_id: '00000000-0000-4000-8000-000000000001', + tier: 'pro', + operation_id: '00000000-0000-4000-8000-0000000000a1', + payer_id: PAYER, + current_order_id: CURRENT_ORDER, + }, + [CURRENT_ORDER]: { + user_id: '00000000-0000-4000-8000-000000000001', + tier: 'pro', + operation_id: '00000000-0000-4000-8000-0000000000a2', + payer_id: PAYER, + current_order_id: CURRENT_ORDER, + }, + } + return { + correlatePayment: (orderId) => Promise.resolve(operations[orderId] ?? null), + lookupPayment: ({ orderId, payType }) => Promise.resolve({ + PCD_PAY_RST: 'success', + PCD_PAY_CODE: 'PCHK0000', + PCD_PAY_MSG: 'ok', + PCD_PAY_OID: orderId, + PCD_PAY_TYPE: payType, + PCD_PAYER_ID: PAYER, + PCD_PAY_TOTAL: String(TIER_PRICE.pro), + PCD_PAY_TIME: '20260901090000', + ...lookups[orderId], + }), + applyProviderEvent: (args): Promise => { + state.applied.push(args) + return Promise.resolve({ applied: true, duplicate: false }) + }, + recordIgnoredEvent: (event) => { + state.ignored.push(event) + return Promise.resolve() + }, + now: () => new Date('2026-09-15T00:00:00.000Z'), + } +} + +function cancellationRequest(orderId: string): Request { + return new Request('http://localhost/payple-webhook', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + PCD_PAY_RST: 'success', + PCD_PAY_CODE: 'PAYC0000', + PCD_PAYCANCEL_FLAG: 'Y', + PCD_PAY_OID: orderId, + PCD_PAY_TYPE: 'card', + PCD_PAYER_ID: PAYER, + }), + }) +} + +Deno.test('refund of a past Payple order does not revoke the current paid subscription', async () => { + const state: FakeState = { applied: [], ignored: [] } + const handler = createPaypleWebhookHandler(() => fakePorts(state, { + [OLD_ORDER]: { PCD_PAY_STATE: '승인취소완료', PCD_PAY_TIME: '20260801090000' }, + })) + + const response = await handler(cancellationRequest(OLD_ORDER)) + const body = await response.json() as Record + + assert(response.status === 200, `acknowledged, got ${response.status}`) + assert(body.ignored === 'canceled_order_not_current', `ignored reason, got ${JSON.stringify(body)}`) + assert(state.applied.length === 0, 'no entitlement change is applied') + assert(state.ignored.length === 1, 'the ignored event is recorded') + assert(state.ignored[0].eventId === `cancel:${OLD_ORDER}:승인취소완료`, 'event id recorded') + assert(/^[0-9a-f]{64}$/.test(state.ignored[0].payloadDigest), 'digest recorded') +}) + +Deno.test('refund of the current Payple order still revokes entitlement', async () => { + const state: FakeState = { applied: [], ignored: [] } + const handler = createPaypleWebhookHandler(() => fakePorts(state, { + [CURRENT_ORDER]: { PCD_PAY_STATE: '승인취소완료' }, + })) + + const response = await handler(cancellationRequest(CURRENT_ORDER)) + + assert(response.status === 200, `acknowledged, got ${response.status}`) + assert(state.ignored.length === 0, 'nothing ignored') + assert(state.applied.length === 1, 'revocation applied') + const args = state.applied[0] + assert(args.p_entitled === false && args.p_tier === 'free', 'revokes entitlement') + assert(args.p_provider_order_id === CURRENT_ORDER, 'scoped to the current order') + assert(/^[0-9a-f]{64}$/.test(args.p_payload_digest), 'digest attached') +}) diff --git a/server/supabase/functions/payple-webhook/index.ts b/server/supabase/functions/payple-webhook/index.ts index 0640e36..12ada52 100644 --- a/server/supabase/functions/payple-webhook/index.ts +++ b/server/supabase/functions/payple-webhook/index.ts @@ -1,12 +1,9 @@ import { createServiceRoleClient } from '../_shared/quota.ts' import { - calcSubscriptionPeriod, getPaypleConfig, - parsePaypleTimestamp, paypleAuth, paypleLookupPayment, payplePaymentEventDigest, - payplePaymentEventId, PaypleConfigurationError, PaypleVerificationError, resolvePaypleOrderDate, @@ -14,36 +11,53 @@ import { TIER_PRICE, type PayplePaymentLookupResult, } from '../_shared/payple.ts' +import { + classifyPaypleWebhook, + type CorrelatedPayment, + decideWebhookTransition, + normalizeTier, + type PaypleProviderEventArgs, + type PaypleWebhookPayload, + stringValue, + validateReconciledPaypleEvent, +} from './webhook-policy.ts' -interface PaypleWebhookPayload { - PCD_PAY_RST?: unknown - PCD_PAY_CODE?: unknown - PCD_PAY_MSG?: unknown - PCD_PAY_TYPE?: unknown - PCD_PAY_OID?: unknown - PCD_PAY_TOTAL?: unknown - PCD_PAYER_ID?: unknown - PCD_PAYER_NO?: unknown - PCD_PAY_TIME?: unknown - PCD_PAY_WORK?: unknown - PCD_PAYCANCEL_FLAG?: unknown - PCD_PAY_CARDTRADENUM?: unknown -} +// Re-exported so existing importers of the handler module keep working; the +// rules themselves live in webhook-policy.ts. +export { classifyPaypleWebhook, validateReconciledPaypleEvent } -interface CorrelatedPayment { - user_id: string - tier: 'pro' | 'pro_plus' - operation_id: string | null - payer_id: string -} - -interface ProviderApplyResult { +export interface ProviderApplyResult { applied?: boolean duplicate?: boolean reason?: string } -type WebhookKind = 'payment' | 'cancellation' | 'billing_key_revoked' | 'unsupported' +export interface IgnoredProviderEvent { + userId: string + eventId: string + eventCreatedAt: string + eventType: string + payloadDigest: string + providerResourceId: string +} + +/** + * IO port of the webhook handler. The handler orchestrates; adapters talk to + * Supabase and Payple. Tests substitute an in-memory implementation. + */ +export interface PaypleWebhookPorts { + correlatePayment(orderId: string, payerId: string | null): Promise + lookupPayment(params: { + orderId: string + payType: 'card' | 'transfer' + payTime: string | null + }): Promise + applyProviderEvent( + args: PaypleProviderEventArgs & { p_payload_digest: string }, + ): Promise + recordIgnoredEvent(event: IgnoredProviderEvent): Promise + now(): Date +} function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { @@ -52,52 +66,23 @@ function jsonResponse(body: Record, status = 200): Response { }) } -function stringValue(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null -} +type ServiceClient = ReturnType -function normalizeTier(value: unknown): 'pro' | 'pro_plus' | null { - if (value === 'pro') return 'pro' - if (value === 'pro_plus' || value === 'team') return 'pro_plus' - return null -} - -export function classifyPaypleWebhook(payload: PaypleWebhookPayload): WebhookKind { - if (payload.PCD_PAY_WORK === 'PUSERDEL') return 'billing_key_revoked' - if ( - payload.PCD_PAYCANCEL_FLAG === 'Y' - || (typeof payload.PCD_PAY_CODE === 'string' && payload.PCD_PAY_CODE.startsWith('PAYC')) - ) { - return 'cancellation' - } - if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) return 'payment' - return 'unsupported' -} - -export function validateReconciledPaypleEvent( - payload: PaypleWebhookPayload, - lookup: PayplePaymentLookupResult, -): void { - const orderId = stringValue(payload.PCD_PAY_OID) - const payType = stringValue(payload.PCD_PAY_TYPE) - const payerId = stringValue(payload.PCD_PAYER_ID) - if (!orderId || lookup.PCD_PAY_OID !== orderId || lookup.PCD_PAY_RST !== 'success') { - throw new PaypleVerificationError('payple_webhook_order_mismatch') - } - if (payType && lookup.PCD_PAY_TYPE !== payType) { - throw new PaypleVerificationError('payple_webhook_type_mismatch') - } - if (payerId && lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== payerId) { - throw new PaypleVerificationError('payple_webhook_payer_mismatch') - } - const payloadTotal = stringValue(payload.PCD_PAY_TOTAL) - if (payloadTotal && lookup.PCD_PAY_TOTAL && Number(payloadTotal) !== Number(lookup.PCD_PAY_TOTAL)) { - throw new PaypleVerificationError('payple_webhook_amount_mismatch') - } +async function readCurrentPaypleOrder( + serviceClient: ServiceClient, + userId: string, +): Promise { + const { data, error } = await serviceClient + .from('subscriptions') + .select('payple_pay_oid') + .eq('user_id', userId) + .maybeSingle() + if (error) throw new Error('subscription_lookup_failed') + return stringValue(data?.payple_pay_oid) } async function correlatePayment( - serviceClient: ReturnType, + serviceClient: ServiceClient, orderId: string, payerId: string | null, ): Promise { @@ -111,11 +96,13 @@ async function correlatePayment( const operationTier = normalizeTier(operation?.requested_tier) const operationPayerId = stringValue(operation?.provider_resource_id) ?? payerId if (operation?.user_id && operationTier && operationPayerId) { + const userId = operation.user_id as string return { - user_id: operation.user_id as string, + user_id: userId, tier: operationTier, operation_id: operation.id as string, payer_id: operationPayerId, + current_order_id: await readCurrentPaypleOrder(serviceClient, userId), } } @@ -133,140 +120,160 @@ async function correlatePayment( tier: subscriptionTier, operation_id: null, payer_id: subscriptionPayerId, + // Matched through payple_pay_oid, so this order is the current one. + current_order_id: orderId, } } -export async function paypleWebhookHandler(req: Request): Promise { - if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) - if (!(req.headers.get('content-type') ?? '').toLowerCase().includes('application/json')) { - return jsonResponse({ error: 'unsupported_content_type' }, 415) - } - - const rawPayload = await req.text() - if (!rawPayload || rawPayload.length > 64 * 1024) { - return jsonResponse({ error: 'invalid_payload' }, 400) - } - - let payload: PaypleWebhookPayload - try { - payload = JSON.parse(rawPayload) as PaypleWebhookPayload - } catch { - return jsonResponse({ error: 'invalid_json' }, 400) - } - - const kind = classifyPaypleWebhook(payload) - if (kind === 'unsupported') return jsonResponse({ received: true, ignored: 'unsupported_event' }) - if (kind === 'billing_key_revoked') { - // Payple's documented PUSERDEL webhook has no signature and no transaction - // identifier that can be reconciled through PayChkAct. It is therefore not - // authorized to mutate entitlement; payple-manage applies the verified - // result of the server-originated PUSERDEL API call instead. - return jsonResponse({ received: true, ignored: 'non_authoritative_billing_key_event' }) - } - - const orderId = stringValue(payload.PCD_PAY_OID) - const payType = stringValue(payload.PCD_PAY_TYPE) - if ( - !orderId - || !/^[A-Za-z0-9._-]{8,64}$/.test(orderId) - || (payType !== 'card' && payType !== 'transfer') - ) { - return jsonResponse({ error: 'invalid_payload' }, 400) - } - +export function createSupabasePaypleWebhookPorts(): PaypleWebhookPorts { const serviceClient = createServiceRoleClient() - try { - // Reject unknown order IDs before consuming Payple's authenticated lookup - // rate limit. Every accepted order must have originated in our operation - // ledger or be the current order on an existing Payple subscription. - const correlated = await correlatePayment( - serviceClient, - orderId, - stringValue(payload.PCD_PAYER_ID), - ) - if (!correlated) return jsonResponse({ error: 'payment_not_registered' }, 422) - - const config = getPaypleConfig() - const payDate = resolvePaypleOrderDate(orderId, stringValue(payload.PCD_PAY_TIME) ?? undefined) - const auth = await paypleAuth(config, { payCheckFlag: true }) - const lookup = await paypleLookupPayment(config, auth, { orderId, payType, payDate }) - validateReconciledPaypleEvent(payload, lookup) - - if (lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== correlated.payer_id) { - return jsonResponse({ error: 'payment_owner_mismatch' }, 401) - } - - const expectedAmount = TIER_PRICE[correlated.tier] - const lookupAmount = Number(lookup.PCD_PAY_TOTAL) - if (kind === 'payment' && (!Number.isFinite(lookupAmount) || lookupAmount !== expectedAmount)) { - return jsonResponse({ error: 'payment_amount_mismatch' }, 422) - } - - const paymentTime = lookup.PCD_PAY_TIME - ? parsePaypleTimestamp(lookup.PCD_PAY_TIME) - : new Date() - const authoritativeCanceled = lookup.PCD_PAY_STATE === '승인취소완료' - || lookup.PCD_PAY_STATE === 'canceled' - if (kind === 'cancellation' && !authoritativeCanceled) { - return jsonResponse({ error: 'cancellation_not_confirmed' }, 409) - } - - const eventTime = kind === 'cancellation' ? new Date() : paymentTime - const eventId = kind === 'cancellation' - ? `cancel:${orderId}:${lookup.PCD_PAY_STATE ?? 'confirmed'}` - : payplePaymentEventId(orderId) - const payloadDigest = kind === 'cancellation' - ? await sha256Text(JSON.stringify({ payload, lookup })) - : await payplePaymentEventDigest({ - orderId, - payerId: correlated.payer_id, - payType: lookup.PCD_PAY_TYPE, - amount: lookupAmount, - }) - const { start, end } = calcSubscriptionPeriod(paymentTime) - const { data: applyData, error: applyError } = await serviceClient.rpc( - 'apply_payment_provider_event', - { - p_user_id: correlated.user_id, + return { + correlatePayment: (orderId, payerId) => correlatePayment(serviceClient, orderId, payerId), + async lookupPayment({ orderId, payType, payTime }) { + const config = getPaypleConfig() + const payDate = resolvePaypleOrderDate(orderId, payTime ?? undefined) + const auth = await paypleAuth(config, { payCheckFlag: true }) + return await paypleLookupPayment(config, auth, { orderId, payType, payDate }) + }, + async applyProviderEvent(args) { + const { data, error } = await serviceClient.rpc('apply_payment_provider_event', args) + if (error) throw new Error('payple_entitlement_apply_failed') + return data as ProviderApplyResult | null + }, + async recordIgnoredEvent(event) { + // Replay-protected audit record that neither touches entitlement nor + // advances the provider ordering cursor. + const { error } = await serviceClient.rpc('record_payment_provider_observation', { + p_user_id: event.userId, p_provider: 'payple', - p_event_id: eventId, - p_event_created_at: eventTime.toISOString(), - p_event_type: kind === 'cancellation' - ? 'webhook.payment_canceled' - : 'payment.completed', - p_payload_digest: payloadDigest, - p_provider_resource_id: correlated.payer_id, - p_tier: kind === 'cancellation' ? 'free' : correlated.tier, - p_status: kind === 'cancellation' ? 'canceled' : 'active', - p_entitled: kind !== 'cancellation', - p_current_period_start: kind === 'cancellation' ? null : start, - p_current_period_end: kind === 'cancellation' ? eventTime.toISOString() : end, - p_cancel_at: kind === 'cancellation' ? eventTime.toISOString() : null, - p_auto_renewing: kind !== 'cancellation', - p_provider_customer_id: correlated.payer_id, - p_provider_order_id: orderId, - p_store_product_id: null, - p_store_purchase_id: null, - p_operation_id: correlated.operation_id, - }, - ) - if (applyError) throw new Error('payple_entitlement_apply_failed') - const result = applyData as ProviderApplyResult | null - return jsonResponse({ - received: true, - applied: result?.applied ?? false, - duplicate: result?.duplicate ?? false, - reason: result?.reason, - }) - } catch (error) { - if (error instanceof PaypleConfigurationError) { - return jsonResponse({ error: error.code }, 503) - } - if (error instanceof PaypleVerificationError) { - return jsonResponse({ error: error.code }, 401) - } - return jsonResponse({ error: 'payple_webhook_processing_failed' }, 500) + p_event_id: event.eventId, + p_event_created_at: event.eventCreatedAt, + p_event_type: event.eventType, + p_payload_digest: event.payloadDigest, + p_provider_resource_id: event.providerResourceId, + }) + if (error) throw new Error('payple_event_record_failed') + }, + now: () => new Date(), } } +export function createPaypleWebhookHandler( + makePorts: () => PaypleWebhookPorts, +): (req: Request) => Promise { + return async (req) => { + if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) + if (!(req.headers.get('content-type') ?? '').toLowerCase().includes('application/json')) { + return jsonResponse({ error: 'unsupported_content_type' }, 415) + } + + const rawPayload = await req.text() + if (!rawPayload || rawPayload.length > 64 * 1024) { + return jsonResponse({ error: 'invalid_payload' }, 400) + } + + let payload: PaypleWebhookPayload + try { + payload = JSON.parse(rawPayload) as PaypleWebhookPayload + } catch { + return jsonResponse({ error: 'invalid_json' }, 400) + } + + const kind = classifyPaypleWebhook(payload) + if (kind === 'unsupported') return jsonResponse({ received: true, ignored: 'unsupported_event' }) + if (kind === 'billing_key_revoked') { + // Payple's documented PUSERDEL webhook has no signature and no transaction + // identifier that can be reconciled through PayChkAct. It is therefore not + // authorized to mutate entitlement; payple-manage applies the verified + // result of the server-originated PUSERDEL API call instead. + return jsonResponse({ received: true, ignored: 'non_authoritative_billing_key_event' }) + } + + const orderId = stringValue(payload.PCD_PAY_OID) + const payType = stringValue(payload.PCD_PAY_TYPE) + if ( + !orderId + || !/^[A-Za-z0-9._-]{8,64}$/.test(orderId) + || (payType !== 'card' && payType !== 'transfer') + ) { + return jsonResponse({ error: 'invalid_payload' }, 400) + } + + try { + const ports = makePorts() + // Reject unknown order IDs before consuming Payple's authenticated lookup + // rate limit. Every accepted order must have originated in our operation + // ledger or be the current order on an existing Payple subscription. + const correlated = await ports.correlatePayment(orderId, stringValue(payload.PCD_PAYER_ID)) + if (!correlated) return jsonResponse({ error: 'payment_not_registered' }, 422) + + const lookup = await ports.lookupPayment({ + orderId, + payType, + payTime: stringValue(payload.PCD_PAY_TIME), + }) + validateReconciledPaypleEvent(payload, lookup) + + const transition = decideWebhookTransition({ + kind, + orderId, + lookup, + correlated, + expectedAmount: TIER_PRICE[correlated.tier], + now: ports.now(), + }) + if (transition.kind === 'reject') { + return jsonResponse({ error: transition.error }, transition.status) + } + + if (transition.kind === 'ignore') { + await ports.recordIgnoredEvent({ + userId: correlated.user_id, + eventId: transition.eventId, + eventCreatedAt: transition.eventCreatedAt, + eventType: transition.eventType, + payloadDigest: await sha256Text(JSON.stringify({ payload, lookup })), + providerResourceId: transition.providerResourceId, + }) + return jsonResponse({ + received: true, + applied: false, + duplicate: false, + ignored: transition.reason, + reason: transition.reason, + }) + } + + const payloadDigest = kind === 'cancellation' + ? await sha256Text(JSON.stringify({ payload, lookup })) + : await payplePaymentEventDigest({ + orderId, + payerId: correlated.payer_id, + payType: lookup.PCD_PAY_TYPE, + amount: transition.amount, + }) + const result = await ports.applyProviderEvent({ + ...transition.args, + p_payload_digest: payloadDigest, + }) + return jsonResponse({ + received: true, + applied: result?.applied ?? false, + duplicate: result?.duplicate ?? false, + reason: result?.reason, + }) + } catch (error) { + if (error instanceof PaypleConfigurationError) { + return jsonResponse({ error: error.code }, 503) + } + if (error instanceof PaypleVerificationError) { + return jsonResponse({ error: error.code }, 401) + } + return jsonResponse({ error: 'payple_webhook_processing_failed' }, 500) + } + } +} + +export const paypleWebhookHandler = createPaypleWebhookHandler(createSupabasePaypleWebhookPorts) + if (import.meta.main) Deno.serve(paypleWebhookHandler) diff --git a/server/supabase/functions/payple-webhook/webhook-policy.test.ts b/server/supabase/functions/payple-webhook/webhook-policy.test.ts new file mode 100644 index 0000000..3548c0d --- /dev/null +++ b/server/supabase/functions/payple-webhook/webhook-policy.test.ts @@ -0,0 +1,160 @@ +import { + type CorrelatedPayment, + decideWebhookTransition, + type WebhookTransitionInput, +} from './webhook-policy.ts' +import type { PayplePaymentLookupResult } from '../_shared/payple.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +const OLD_ORDER = 'D3RO-20260801090000-user-oid1' +const CURRENT_ORDER = 'D3RO-20260901090000-user-oid2' +const NOW = new Date('2026-09-15T00:00:00.000Z') + +function correlated(overrides: Partial = {}): CorrelatedPayment { + return { + user_id: '00000000-0000-4000-8000-000000000001', + tier: 'pro', + operation_id: '00000000-0000-4000-8000-0000000000aa', + payer_id: 'payer-billing-key', + current_order_id: CURRENT_ORDER, + ...overrides, + } +} + +function lookup(orderId: string, overrides: Partial = {}): PayplePaymentLookupResult { + return { + PCD_PAY_RST: 'success', + PCD_PAY_CODE: 'PCHK0000', + PCD_PAY_MSG: 'ok', + PCD_PAY_OID: orderId, + PCD_PAY_TYPE: 'card', + PCD_PAYER_ID: 'payer-billing-key', + PCD_PAY_TOTAL: '9900', + PCD_PAY_TIME: '20260901090000', + ...overrides, + } +} + +function input(overrides: Partial): WebhookTransitionInput { + return { + kind: 'payment', + orderId: CURRENT_ORDER, + lookup: lookup(CURRENT_ORDER), + correlated: correlated(), + expectedAmount: 9900, + now: NOW, + ...overrides, + } +} + +Deno.test('payment of the current order grants the correlated tier for one period', () => { + const decision = decideWebhookTransition(input({})) + assert(decision.kind === 'apply', `expected apply, got ${decision.kind}`) + assertEquals(decision.amount, 9900, 'amount') + const args = decision.args + assertEquals(args.p_event_id, `payment:${CURRENT_ORDER}`, 'event id') + assertEquals(args.p_event_type, 'payment.completed', 'event type') + assertEquals(args.p_entitled, true, 'entitled') + assertEquals(args.p_tier, 'pro', 'tier') + assertEquals(args.p_status, 'active', 'status') + assertEquals(args.p_auto_renewing, true, 'auto renewing') + assertEquals(args.p_provider_order_id, CURRENT_ORDER, 'order id') + assertEquals(args.p_provider_resource_id, 'payer-billing-key', 'resource id') + // 20260901090000 KST = 2026-09-01T00:00:00Z + assertEquals(args.p_event_created_at, '2026-09-01T00:00:00.000Z', 'event time is payment time') + assertEquals(args.p_current_period_start, '2026-09-01T00:00:00.000Z', 'period start') + assertEquals(args.p_current_period_end, '2026-10-01T00:00:00.000Z', 'period end') + assertEquals(args.p_cancel_at, null, 'no cancel_at') +}) + +Deno.test('payment of an older registered order is still applied (ordering is the RPC cursor)', () => { + const decision = decideWebhookTransition(input({ + orderId: OLD_ORDER, + lookup: lookup(OLD_ORDER, { PCD_PAY_TIME: '20260801090000' }), + })) + assert(decision.kind === 'apply', 'payments are not order-scoped') +}) + +Deno.test('payment whose reconciled amount differs from the tier price is rejected', () => { + for (const total of ['29900', 'not-a-number', undefined]) { + const decision = decideWebhookTransition(input({ + lookup: lookup(CURRENT_ORDER, { PCD_PAY_TOTAL: total }), + })) + assertEquals(decision, { kind: 'reject', status: 422, error: 'payment_amount_mismatch' }, `total ${total}`) + } + const unpricedTier = decideWebhookTransition(input({ expectedAmount: undefined })) + assertEquals(unpricedTier.kind, 'reject', 'tier without a price cannot be paid') +}) + +Deno.test('lookup payer different from the correlated payer is rejected', () => { + const decision = decideWebhookTransition(input({ + lookup: lookup(CURRENT_ORDER, { PCD_PAYER_ID: 'payer-other' }), + })) + assertEquals(decision, { kind: 'reject', status: 401, error: 'payment_owner_mismatch' }, 'owner') +}) + +Deno.test('cancellation not confirmed by the Payple lookup is rejected', () => { + for (const state of [undefined, '승인완료']) { + const decision = decideWebhookTransition(input({ + kind: 'cancellation', + lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: state }), + })) + assertEquals(decision, { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' }, `state ${state}`) + } +}) + +Deno.test('confirmed cancellation of the current order revokes entitlement now', () => { + for (const state of ['승인취소완료', 'canceled']) { + const decision = decideWebhookTransition(input({ + kind: 'cancellation', + lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: state, PCD_PAY_TOTAL: '1' }), + })) + assert(decision.kind === 'apply', `expected apply for ${state}`) + const args = decision.args + assertEquals(args.p_event_id, `cancel:${CURRENT_ORDER}:${state}`, 'event id') + assertEquals(args.p_event_type, 'webhook.payment_canceled', 'event type') + assertEquals(args.p_entitled, false, 'not entitled') + assertEquals(args.p_tier, 'free', 'tier') + assertEquals(args.p_status, 'canceled', 'status') + assertEquals(args.p_auto_renewing, false, 'auto renewing') + assertEquals(args.p_event_created_at, NOW.toISOString(), 'event time is now') + assertEquals(args.p_current_period_start, null, 'period start untouched') + assertEquals(args.p_current_period_end, NOW.toISOString(), 'period ends now') + assertEquals(args.p_cancel_at, NOW.toISOString(), 'cancel_at now') + assertEquals(args.p_provider_order_id, CURRENT_ORDER, 'order id') + } +}) + +Deno.test('confirmed cancellation of an older order is ignored and keeps the paid period', () => { + const decision = decideWebhookTransition(input({ + kind: 'cancellation', + orderId: OLD_ORDER, + lookup: lookup(OLD_ORDER, { PCD_PAY_STATE: '승인취소완료', PCD_PAY_TIME: '20260801090000' }), + })) + assertEquals(decision, { + kind: 'ignore', + reason: 'canceled_order_not_current', + eventId: `cancel:${OLD_ORDER}:승인취소완료`, + eventCreatedAt: NOW.toISOString(), + eventType: 'webhook.payment_canceled', + providerResourceId: 'payer-billing-key', + }, 'old order refund must not revoke the current subscription') +}) + +Deno.test('confirmed cancellation while no current order is on record is ignored', () => { + const decision = decideWebhookTransition(input({ + kind: 'cancellation', + correlated: correlated({ current_order_id: null }), + lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: '승인취소완료' }), + })) + assertEquals(decision.kind, 'ignore', 'fail safe: unknown current order never revokes') +}) diff --git a/server/supabase/functions/payple-webhook/webhook-policy.ts b/server/supabase/functions/payple-webhook/webhook-policy.ts new file mode 100644 index 0000000..809f60d --- /dev/null +++ b/server/supabase/functions/payple-webhook/webhook-policy.ts @@ -0,0 +1,242 @@ +// Pure decision rules for the Payple webhook edge function. No IO lives here: +// index.ts owns the Supabase/Payple calls and asks these functions what a +// reconciled webhook means for the user's entitlement, so the rules (which +// order may revoke a subscription, which amount is acceptable, ...) are +// unit-testable without a network or a database. + +import { + calcSubscriptionPeriod, + parsePaypleTimestamp, + payplePaymentEventId, + PaypleVerificationError, + type PayplePaymentLookupResult, +} from '../_shared/payple.ts' + +export interface PaypleWebhookPayload { + PCD_PAY_RST?: unknown + PCD_PAY_CODE?: unknown + PCD_PAY_MSG?: unknown + PCD_PAY_TYPE?: unknown + PCD_PAY_OID?: unknown + PCD_PAY_TOTAL?: unknown + PCD_PAYER_ID?: unknown + PCD_PAYER_NO?: unknown + PCD_PAY_TIME?: unknown + PCD_PAY_WORK?: unknown + PCD_PAYCANCEL_FLAG?: unknown + PCD_PAY_CARDTRADENUM?: unknown +} + +export type WebhookKind = 'payment' | 'cancellation' | 'billing_key_revoked' | 'unsupported' + +/** The webhook kinds that are reconciled against Payple and may change entitlement. */ +export type ReconciledWebhookKind = 'payment' | 'cancellation' + +export type PaypleTier = 'pro' | 'pro_plus' + +export interface CorrelatedPayment { + user_id: string + tier: PaypleTier + operation_id: string | null + payer_id: string + /** + * subscriptions.payple_pay_oid at correlation time: the order that funds the + * current paid period. null when the user has no Payple order on record. + */ + current_order_id: string | null +} + +export function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function normalizeTier(value: unknown): PaypleTier | null { + if (value === 'pro') return 'pro' + if (value === 'pro_plus' || value === 'team') return 'pro_plus' + return null +} + +export function classifyPaypleWebhook(payload: PaypleWebhookPayload): WebhookKind { + if (payload.PCD_PAY_WORK === 'PUSERDEL') return 'billing_key_revoked' + if ( + payload.PCD_PAYCANCEL_FLAG === 'Y' + || (typeof payload.PCD_PAY_CODE === 'string' && payload.PCD_PAY_CODE.startsWith('PAYC')) + ) { + return 'cancellation' + } + if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) return 'payment' + return 'unsupported' +} + +export function validateReconciledPaypleEvent( + payload: PaypleWebhookPayload, + lookup: PayplePaymentLookupResult, +): void { + const orderId = stringValue(payload.PCD_PAY_OID) + const payType = stringValue(payload.PCD_PAY_TYPE) + const payerId = stringValue(payload.PCD_PAYER_ID) + if (!orderId || lookup.PCD_PAY_OID !== orderId || lookup.PCD_PAY_RST !== 'success') { + throw new PaypleVerificationError('payple_webhook_order_mismatch') + } + if (payType && lookup.PCD_PAY_TYPE !== payType) { + throw new PaypleVerificationError('payple_webhook_type_mismatch') + } + if (payerId && lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== payerId) { + throw new PaypleVerificationError('payple_webhook_payer_mismatch') + } + const payloadTotal = stringValue(payload.PCD_PAY_TOTAL) + if (payloadTotal && lookup.PCD_PAY_TOTAL && Number(payloadTotal) !== Number(lookup.PCD_PAY_TOTAL)) { + throw new PaypleVerificationError('payple_webhook_amount_mismatch') + } +} + +/** Payple's PayChkAct state is the only authority that an order was canceled. */ +export function isAuthoritativeCancellation(lookup: PayplePaymentLookupResult): boolean { + return lookup.PCD_PAY_STATE === '승인취소완료' || lookup.PCD_PAY_STATE === 'canceled' +} + +/** + * apply_payment_provider_event arguments, minus p_payload_digest (the digest + * is an async hash computed by the IO layer from the event it forwards). + */ +export type PaypleProviderEventArgs = { + p_user_id: string + p_provider: 'payple' + p_event_id: string + p_event_created_at: string + p_event_type: 'payment.completed' | 'webhook.payment_canceled' + p_provider_resource_id: string + p_tier: 'free' | PaypleTier + p_status: 'active' | 'canceled' + p_entitled: boolean + p_current_period_start: string | null + p_current_period_end: string + p_cancel_at: string | null + p_auto_renewing: boolean + p_provider_customer_id: string + p_provider_order_id: string + p_store_product_id: null + p_store_purchase_id: null + p_operation_id: string | null +} + +export interface WebhookTransitionInput { + kind: ReconciledWebhookKind + orderId: string + lookup: PayplePaymentLookupResult + correlated: CorrelatedPayment + /** Payple price of the correlated tier; undefined when the tier has no price. */ + expectedAmount: number | undefined + now: Date +} + +export type IgnoredWebhookReason = 'canceled_order_not_current' + +export type WebhookTransition = + | { kind: 'reject'; status: 401 | 409 | 422; error: string } + | { + kind: 'ignore' + reason: IgnoredWebhookReason + eventId: string + eventCreatedAt: string + eventType: 'webhook.payment_canceled' + providerResourceId: string + } + | { kind: 'apply'; amount: number; args: PaypleProviderEventArgs } + +function cancellationEventId(orderId: string, lookup: PayplePaymentLookupResult): string { + return `cancel:${orderId}:${lookup.PCD_PAY_STATE ?? 'confirmed'}` +} + +/** + * Decides what a reconciled Payple webhook does to the subscription. + * + * - The Payple lookup's payer must be the correlated payer (401). + * - A payment must match the correlated tier's price exactly (422). + * - A cancellation must be confirmed by Payple's own lookup state (409). + * - A confirmed cancellation revokes entitlement only when the canceled order + * is the one that funds the current period (subscriptions.payple_pay_oid). + * A refund of an older order (last month's checkout, a duplicate charge) — + * or of any order while no current order is on record — is ignored, because + * every order on the same billing key shares the payer id and would + * otherwise pass the database's resource-ownership check. + */ +export function decideWebhookTransition(input: WebhookTransitionInput): WebhookTransition { + const { kind, orderId, lookup, correlated, now } = input + if (lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== correlated.payer_id) { + return { kind: 'reject', status: 401, error: 'payment_owner_mismatch' } + } + + const amount = Number(lookup.PCD_PAY_TOTAL) + if (kind === 'payment' && (!Number.isFinite(amount) || amount !== input.expectedAmount)) { + return { kind: 'reject', status: 422, error: 'payment_amount_mismatch' } + } + + const paymentTime = lookup.PCD_PAY_TIME ? parsePaypleTimestamp(lookup.PCD_PAY_TIME) : now + if (kind === 'cancellation') { + if (!isAuthoritativeCancellation(lookup)) { + return { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' } + } + const eventId = cancellationEventId(orderId, lookup) + const eventCreatedAt = now.toISOString() + if (correlated.current_order_id !== orderId) { + return { + kind: 'ignore', + reason: 'canceled_order_not_current', + eventId, + eventCreatedAt, + eventType: 'webhook.payment_canceled', + providerResourceId: correlated.payer_id, + } + } + return { + kind: 'apply', + amount, + args: { + ...baseArgs(correlated, orderId), + p_event_id: eventId, + p_event_created_at: eventCreatedAt, + p_event_type: 'webhook.payment_canceled', + p_tier: 'free', + p_status: 'canceled', + p_entitled: false, + p_current_period_start: null, + p_current_period_end: eventCreatedAt, + p_cancel_at: eventCreatedAt, + p_auto_renewing: false, + }, + } + } + + const { start, end } = calcSubscriptionPeriod(paymentTime) + return { + kind: 'apply', + amount, + args: { + ...baseArgs(correlated, orderId), + p_event_id: payplePaymentEventId(orderId), + p_event_created_at: paymentTime.toISOString(), + p_event_type: 'payment.completed', + p_tier: correlated.tier, + p_status: 'active', + p_entitled: true, + p_current_period_start: start, + p_current_period_end: end, + p_cancel_at: null, + p_auto_renewing: true, + }, + } +} + +function baseArgs(correlated: CorrelatedPayment, orderId: string) { + return { + p_user_id: correlated.user_id, + p_provider: 'payple' as const, + p_provider_resource_id: correlated.payer_id, + p_provider_customer_id: correlated.payer_id, + p_provider_order_id: orderId, + p_store_product_id: null, + p_store_purchase_id: null, + p_operation_id: correlated.operation_id, + } +} diff --git a/server/supabase/migrations/20260929010000_team_rpc_null_role_guard.sql b/server/supabase/migrations/20260929010000_team_rpc_null_role_guard.sql new file mode 100644 index 0000000..2d8cdcc --- /dev/null +++ b/server/supabase/migrations/20260929010000_team_rpc_null_role_guard.sql @@ -0,0 +1,358 @@ +-- ============================================================================ +-- 20260929010000_team_rpc_null_role_guard.sql +-- +-- Team management RPCs must refuse callers that are not members of the team. +-- +-- Bug +-- create_team_invite (20260821000010), update_team_member_role, +-- remove_team_member and cancel_team_invite (20260821000008) read the caller +-- role with +-- SELECT role INTO caller_role FROM team_members +-- WHERE team_id = ... AND user_id = auth.uid(); +-- and then guarded with `caller_role <> 'owner'` / `caller_role NOT IN (...)`. +-- For a caller without a membership row caller_role is NULL, every guard +-- evaluates to NULL, and plpgsql skips an IF whose condition is NULL. The +-- functions are SECURITY DEFINER and granted to authenticated, so any +-- signed-in user who knew a team id could mint an admin invite for a second +-- account (and join as admin through accept_team_invite), change member +-- roles, remove members and cancel invites. +-- +-- Fix +-- Role policy lives in one place instead of being re-implemented as ad-hoc +-- comparisons in every RPC: +-- * team_role_at_least_v1(role, minimum) pure, NULL-safe hierarchy check +-- (owner > admin > member); a +-- missing role never qualifies. +-- * require_team_role_v1(team, minimum) resolves the caller's membership +-- and raises 42501 unless it meets +-- the minimum; returns the role. +-- The four RPCs are redefined on top of these helpers. Everything else in +-- their bodies (validation, locks, rate limit, idempotency, response shape, +-- error names) is unchanged, with two deliberate ordering changes: +-- * remove_team_member authorizes a caller removing someone else before it +-- looks the target up, so a non-member cannot probe who is in a team. +-- Leaving (removing yourself) keeps its existing behaviour. +-- * NULL inputs (email, role) are rejected by validation instead of +-- slipping through a NULL comparison. +-- +-- Both helpers are SECURITY INVOKER and not executable by client roles; the +-- SECURITY DEFINER RPCs call them as their owner. The function-acl allowlist +-- (tests/function-acl.integration.sql) is therefore unaffected. +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- 1. Role policy +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.team_role_at_least_v1( + team_role text, + minimum_role text +) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + -- Unknown or NULL values rank 0; a requirement that ranks 0 is never met. + SELECT required.rank > 0 AND held.rank >= required.rank + FROM ( + SELECT CASE team_role + WHEN 'owner' THEN 3 WHEN 'admin' THEN 2 WHEN 'member' THEN 1 ELSE 0 + END AS rank + ) AS held + CROSS JOIN ( + SELECT CASE minimum_role + WHEN 'owner' THEN 3 WHEN 'admin' THEN 2 WHEN 'member' THEN 1 ELSE 0 + END AS rank + ) AS required; +$$; + +CREATE OR REPLACE FUNCTION public.require_team_role_v1( + target_team_id uuid, + minimum_role text +) +RETURNS text +LANGUAGE plpgsql +STABLE +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + caller_role text; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + + SELECT member.role INTO caller_role + FROM public.team_members AS member + WHERE member.team_id = target_team_id AND member.user_id = current_user_id; + + IF NOT public.team_role_at_least_v1(caller_role, minimum_role) THEN + RAISE EXCEPTION '%', CASE minimum_role + WHEN 'owner' THEN 'team_owner_required' + WHEN 'admin' THEN 'team_admin_required' + ELSE 'team_member_required' + END + USING ERRCODE = '42501'; + END IF; + RETURN caller_role; +END; +$$; + +REVOKE ALL ON FUNCTION public.team_role_at_least_v1(text, text) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.require_team_role_v1(uuid, text) FROM PUBLIC, anon, authenticated; + +-- ---------------------------------------------------------------------------- +-- 2. create_team_invite (was 20260821000010) +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.create_team_invite( + target_team_id uuid, + invited_email text, + invited_role text DEFAULT 'member' +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + current_user_email text; + caller_team_role text; + normalized_email text := lower(btrim(invited_email)); + existing_invite public.team_invites; + created_invite public.team_invites; + invite_token text; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + IF target_team_id IS NULL + OR normalized_email IS NULL + OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$' + OR char_length(normalized_email) > 254 + OR invited_role IS NULL + OR invited_role NOT IN ('admin', 'member') THEN + RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023'; + END IF; + + SELECT lower(account.email) INTO current_user_email + FROM auth.users AS account + WHERE account.id = current_user_id; + IF current_user_email = normalized_email THEN + RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023'; + END IF; + caller_team_role := public.require_team_role_v1(target_team_id, 'admin'); + IF invited_role = 'admin' + AND NOT public.team_role_at_least_v1(caller_team_role, 'owner') THEN + RAISE EXCEPTION 'owner_required_for_admin_invite' USING ERRCODE = '42501'; + END IF; + + PERFORM pg_advisory_xact_lock( + hashtextextended(target_team_id::text || ':' || normalized_email, 73042) + ); + IF ( + SELECT count(*) + FROM public.team_invites AS recent_invite + WHERE recent_invite.invited_by = current_user_id + AND recent_invite.created_at >= now() - interval '24 hours' + ) >= 50 THEN + RAISE EXCEPTION 'invite_rate_limited' USING ERRCODE = '54000'; + END IF; + IF EXISTS ( + SELECT 1 + FROM public.team_members AS member + JOIN auth.users AS account ON account.id = member.user_id + WHERE member.team_id = target_team_id + AND lower(account.email) = normalized_email + ) THEN + RAISE EXCEPTION 'already_team_member' USING ERRCODE = '23505'; + END IF; + + SELECT active_invite.* INTO existing_invite + FROM public.team_invites AS active_invite + WHERE active_invite.team_id = target_team_id + AND lower(active_invite.email) = normalized_email + AND active_invite.accepted_at IS NULL + ORDER BY active_invite.created_at DESC + LIMIT 1 + FOR UPDATE; + + IF existing_invite.id IS NOT NULL AND existing_invite.expires_at > now() THEN + IF existing_invite.role <> invited_role THEN + UPDATE public.team_invites + SET role = invited_role + WHERE id = existing_invite.id + RETURNING * INTO existing_invite; + END IF; + RETURN jsonb_build_object( + 'id', existing_invite.id, + 'team_id', existing_invite.team_id, + 'email', existing_invite.email, + 'token', existing_invite.token, + 'role', existing_invite.role, + 'expires_at', existing_invite.expires_at, + 'duplicate', true + ); + END IF; + IF existing_invite.id IS NOT NULL THEN + DELETE FROM public.team_invites WHERE id = existing_invite.id; + END IF; + + invite_token := public.generate_invite_token(); + INSERT INTO public.team_invites ( + team_id, invited_by, email, role, token + ) VALUES ( + target_team_id, current_user_id, normalized_email, invited_role, invite_token + ) + RETURNING * INTO created_invite; + + RETURN jsonb_build_object( + 'id', created_invite.id, + 'team_id', created_invite.team_id, + 'email', created_invite.email, + 'token', created_invite.token, + 'role', created_invite.role, + 'expires_at', created_invite.expires_at, + 'duplicate', false + ); +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 3. update_team_member_role (was 20260821000008) +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.update_team_member_role( + target_team_id uuid, + member_user_id uuid, + new_role text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + prior_role text; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + IF new_role IS NULL OR new_role NOT IN ('admin', 'member') THEN + RAISE EXCEPTION 'invalid_team_role' USING ERRCODE = '22023'; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73044)); + PERFORM public.require_team_role_v1(target_team_id, 'owner'); + SELECT role INTO prior_role FROM public.team_members + WHERE team_id = target_team_id AND user_id = member_user_id + FOR UPDATE; + IF prior_role IS NULL THEN + RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002'; + END IF; + IF prior_role = 'owner' OR member_user_id = current_user_id THEN + RAISE EXCEPTION 'team_owner_immutable' USING ERRCODE = '42501'; + END IF; + UPDATE public.team_members SET role = new_role + WHERE team_id = target_team_id AND user_id = member_user_id; + RETURN jsonb_build_object( + 'team_id', target_team_id, + 'user_id', member_user_id, + 'role', new_role + ); +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 4. remove_team_member (was 20260821000008) +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.remove_team_member( + target_team_id uuid, + member_user_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + is_leaving boolean; + caller_role text; + target_role text; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + is_leaving := member_user_id IS NOT DISTINCT FROM current_user_id; + PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73045)); + -- Removing someone else requires at least admin. Checked before the target + -- lookup so a non-member cannot learn who belongs to the team. + IF NOT is_leaving THEN + caller_role := public.require_team_role_v1(target_team_id, 'admin'); + END IF; + SELECT role INTO target_role FROM public.team_members + WHERE team_id = target_team_id AND user_id = member_user_id + FOR UPDATE; + IF target_role IS NULL THEN + RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002'; + END IF; + IF target_role = 'owner' THEN + RAISE EXCEPTION 'team_owner_cannot_leave' USING ERRCODE = '42501'; + END IF; + IF NOT is_leaving + AND target_role = 'admin' + AND NOT public.team_role_at_least_v1(caller_role, 'owner') THEN + RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501'; + END IF; + + DELETE FROM public.team_members + WHERE team_id = target_team_id AND user_id = member_user_id; + RETURN jsonb_build_object( + 'team_id', target_team_id, + 'user_id', member_user_id, + 'removed', true + ); +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 5. cancel_team_invite (was 20260821000008) +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.cancel_team_invite(invite_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + invite public.team_invites; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + SELECT * INTO invite FROM public.team_invites WHERE id = invite_id FOR UPDATE; + IF invite.id IS NULL OR invite.accepted_at IS NOT NULL THEN + RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002'; + END IF; + -- The inviter may withdraw their own invite; anyone else needs admin. + IF invite.invited_by IS DISTINCT FROM current_user_id THEN + PERFORM public.require_team_role_v1(invite.team_id, 'admin'); + END IF; + DELETE FROM public.team_invites WHERE id = invite.id; + RETURN jsonb_build_object('id', invite.id, 'cancelled', true); +END; +$$; + +-- CREATE OR REPLACE keeps existing ACLs; restate them so this file is the +-- complete contract for the redefined RPCs. +REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.update_team_member_role(uuid, uuid, text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.remove_team_member(uuid, uuid) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.cancel_team_invite(uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.create_team_invite(uuid, text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.update_team_member_role(uuid, uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.remove_team_member(uuid, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.cancel_team_invite(uuid) TO authenticated; diff --git a/server/supabase/migrations/20260929020000_unify_llm_quota_inflight.sql b/server/supabase/migrations/20260929020000_unify_llm_quota_inflight.sql new file mode 100644 index 0000000..e830514 --- /dev/null +++ b/server/supabase/migrations/20260929020000_unify_llm_quota_inflight.sql @@ -0,0 +1,830 @@ +-- ============================================================================ +-- 20260929020000_unify_llm_quota_inflight.sql +-- +-- One LLM quota ledger and one lock for every path that spends LLM allowance. +-- +-- Bug +-- Meeting-document generation held its in-flight unit in a different ledger +-- and under a different lock than llm-proxy / consume_quota: +-- * claim_meeting_document_generation_v1 (20260928000037) locked +-- hashtextextended(user:feature, 0) and counted daily_usage PLUS +-- meeting_document_generation_requests rows in status 'processing'; +-- it wrote nothing to daily_usage; +-- * reserve_llm_quota / finalize_llm_quota (20260928020800) and +-- consume_quota (20260928000131) locked hashtextextended(user:feature, +-- 20260928) and counted daily_usage only. +-- So an in-flight document unit was invisible to llm-proxy. With one Opus +-- unit left, a document claim passed (49 < 50), a Talk/command request then +-- reserved the same unit through llm-proxy (daily_usage 49 -> 50), and +-- commit_meeting_document_generation_v1 re-checked the allowance, saw +-- 50 >= 50 and raised generation_quota_exceeded: the paid Opus generation +-- was thrown away (or, with overage credits, a credit the claim promised +-- was covered by the base allowance was spent silently). The two paths also +-- never serialised against each other because the lock keys differed. +-- +-- Fix (single source of truth for LLM quota state) +-- 1. public.daily_usage_lock_v1(user, feature) is the only place that knows +-- the per-user/per-feature advisory-lock key for the daily_usage ledger. +-- reserve_llm_quota, finalize_llm_quota, consume_quota and the three +-- meeting-document RPCs all take the lock through it. +-- 2. A meeting-document claim now reserves its unit with reserve_llm_quota +-- (a fresh server-generated reservation id, stored on the request row in +-- llm_reservation_id). The unit is in daily_usage from the moment of the +-- claim, so llm-proxy and consume_quota see it. +-- commit -> finalize_llm_quota(id, true) (the unit stays spent; +-- consumedFrom comes from the reservation) +-- fail -> finalize_llm_quota(id, false) (daily_usage -1, overage +-- credit refunded when the unit came from overage) +-- The separate 'processing'-row in-flight count and commit's allowance +-- re-check / daily_usage insert are gone, so usage is counted once. +-- 3. Crashed workers: the reservation lease (10 minutes) is reclaimed by the +-- next reserve for that user/feature, exactly like llm-proxy. That +-- replaces the old "processing rows older than 10 minutes stop counting" +-- rule. +-- +-- Single-path behaviour is unchanged: "allowed" is still +-- usage + in-flight < base limit + overage, because daily_usage now includes +-- in-flight units. What moves: document usage is recorded at claim instead of +-- commit, and an overage credit is taken at claim and refunded on failure +-- (the llm-proxy model). +-- +-- Compatibility +-- * RPC signatures and return shapes are unchanged. +-- * Request rows that were already 'processing' when this migration ran have +-- llm_reservation_id NULL. commit charges them through the same ledger at +-- commit time (the previous behaviour), and fail has nothing to release. +-- The same path covers a reservation whose lease expired and was +-- reclaimed before commit arrived. +-- * The reservation id is generated server-side (gen_random_uuid), never the +-- client's idempotency key: idempotency keys are unique only per user, +-- llm_quota_reservations.id is a global primary key. +-- +-- Only service_role may call the quota functions. Local verification: +-- tests/meeting-document-generation-quota.integration.sql. +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- 1. The single lock for the daily_usage quota ledger. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.daily_usage_lock_v1( + p_user_id uuid, + p_feature text +) RETURNS void +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF p_user_id IS NULL OR p_feature IS NULL THEN + RAISE EXCEPTION 'invalid_daily_usage_lock' USING ERRCODE = '22023'; + END IF; + -- Seed 20260928 is the key consume_quota and reserve_llm_quota already used, + -- so existing sessions of those functions keep serialising with new ones. + PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || p_feature, 20260928)); +END; +$$; + +REVOKE ALL ON FUNCTION public.daily_usage_lock_v1(uuid, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.daily_usage_lock_v1(uuid, text) TO service_role; + +COMMENT ON FUNCTION public.daily_usage_lock_v1(uuid, text) IS + 'Transaction advisory lock guarding the daily_usage quota ledger for one user/feature. Every quota path (consume_quota, reserve/finalize_llm_quota, meeting-document claim/commit/fail) must take it through this function.'; + +-- ---------------------------------------------------------------------------- +-- 2. Request rows point at the reservation that holds their unit. +-- ---------------------------------------------------------------------------- +ALTER TABLE public.meeting_document_generation_requests + ADD COLUMN IF NOT EXISTS llm_reservation_id uuid + REFERENCES public.llm_quota_reservations(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS meeting_document_generation_llm_reservation_idx + ON public.meeting_document_generation_requests(llm_reservation_id) + WHERE llm_reservation_id IS NOT NULL; + +-- The 'processing'-row in-flight count is replaced by the reservation ledger. +DROP INDEX IF EXISTS public.meeting_document_generation_in_flight_idx; + +-- ---------------------------------------------------------------------------- +-- 3. reserve_llm_quota / finalize_llm_quota / consume_quota: same bodies as +-- 20260928020800 and 20260928000131, lock taken through the helper. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.reserve_llm_quota( + p_user_id uuid, + p_reservation_id uuid, + p_feature text, + p_base_limit integer, + p_period text +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + existing public.llm_quota_reservations%ROWTYPE; + expired public.llm_quota_reservations%ROWTYPE; + subscription_tier text := 'free'; + overage integer := 0; + new_overage integer; + current_count integer := 0; + consumed_from text; +BEGIN + IF p_user_id IS NULL + OR p_reservation_id IS NULL + OR p_feature IS NULL + OR p_feature NOT IN ('llm_haiku', 'llm_sonnet', 'llm_opus') + OR p_base_limit IS NULL + OR p_base_limit < -1 + OR p_period IS NULL + OR p_period NOT IN ('daily', 'weekly') THEN + RAISE EXCEPTION 'invalid_llm_quota_reservation' USING ERRCODE = '22023'; + END IF; + + PERFORM public.daily_usage_lock_v1(p_user_id, p_feature); + + SELECT * INTO existing + FROM public.llm_quota_reservations + WHERE id = p_reservation_id + FOR UPDATE; + + IF FOUND THEN + IF existing.user_id <> p_user_id OR existing.feature <> p_feature THEN + RAISE EXCEPTION 'llm_quota_reservation_conflict' USING ERRCODE = 'PT409'; + END IF; + RETURN jsonb_build_object( + 'allowed', existing.status IN ('reserved', 'completed'), + 'reservation_id', existing.id, + 'status', existing.status, + 'current', existing.current_count, + 'limit', existing.quota_limit, + 'period', existing.quota_period, + 'tier', existing.tier, + 'overage_credits', existing.overage_after, + 'consumed_from', existing.consumed_from + ); + END IF; + + -- Reclaim crashed requests before calculating the next allowance. + FOR expired IN + SELECT * + FROM public.llm_quota_reservations + WHERE user_id = p_user_id + AND feature = p_feature + AND status = 'reserved' + AND lease_expires_at <= now() + FOR UPDATE + LOOP + UPDATE public.daily_usage + SET count = greatest(count - 1, 0) + WHERE user_id = expired.user_id + AND date = expired.usage_date + AND feature = expired.feature; + + IF expired.consumed_from = 'overage' THEN + UPDATE public.subscriptions + SET overage_credits = overage_credits + 1, + updated_at = now() + WHERE user_id = expired.user_id; + END IF; + + UPDATE public.llm_quota_reservations + SET status = 'released', finalized_at = now(), release_reason = 'lease_expired' + WHERE id = expired.id; + END LOOP; + + SELECT coalesce(tier, 'free'), coalesce(overage_credits, 0) + INTO subscription_tier, overage + FROM public.subscriptions + WHERE user_id = p_user_id + FOR UPDATE; + + IF NOT FOUND THEN + subscription_tier := 'free'; + overage := 0; + END IF; + + -- Not available: never spend credits on a model the tier does not include. + IF p_base_limit = 0 THEN + RETURN jsonb_build_object( + 'allowed', false, + 'reservation_id', NULL, + 'status', 'denied', + 'current', 0, + 'limit', 0, + 'period', p_period, + 'tier', subscription_tier, + 'overage_credits', overage, + 'consumed_from', 'none' + ); + END IF; + + -- daily_usage already includes units held by in-flight reservations + -- (llm-proxy requests and meeting-document claims alike). + SELECT coalesce(sum(count), 0)::integer INTO current_count + FROM public.daily_usage + WHERE user_id = p_user_id + AND feature = p_feature + AND date >= CASE WHEN p_period = 'weekly' THEN CURRENT_DATE - 6 ELSE CURRENT_DATE END + AND date <= CURRENT_DATE; + + IF p_base_limit = -1 THEN + consumed_from := 'unlimited'; + ELSIF current_count < p_base_limit THEN + consumed_from := 'base'; + ELSE + UPDATE public.subscriptions + SET overage_credits = overage_credits - 1, + updated_at = now() + WHERE user_id = p_user_id + AND overage_credits > 0 + RETURNING overage_credits INTO new_overage; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'allowed', false, + 'reservation_id', NULL, + 'status', 'denied', + 'current', current_count, + 'limit', p_base_limit, + 'period', p_period, + 'tier', subscription_tier, + 'overage_credits', 0, + 'consumed_from', 'none' + ); + END IF; + + overage := new_overage; + consumed_from := 'overage'; + END IF; + + INSERT INTO public.daily_usage(user_id, date, feature, count) + VALUES (p_user_id, CURRENT_DATE, p_feature, 1) + ON CONFLICT (user_id, date, feature) + DO UPDATE SET count = public.daily_usage.count + 1; + + current_count := current_count + 1; + INSERT INTO public.llm_quota_reservations( + id, user_id, feature, consumed_from, tier, quota_period, quota_limit, + current_count, overage_after, lease_expires_at + ) VALUES ( + p_reservation_id, p_user_id, p_feature, consumed_from, subscription_tier, p_period, p_base_limit, + current_count, overage, now() + interval '10 minutes' + ); + + RETURN jsonb_build_object( + 'allowed', true, + 'reservation_id', p_reservation_id, + 'status', 'reserved', + 'current', current_count, + 'limit', p_base_limit, + 'period', p_period, + 'tier', subscription_tier, + 'overage_credits', overage, + 'consumed_from', consumed_from + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.finalize_llm_quota( + p_reservation_id uuid, + p_succeeded boolean +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + reservation public.llm_quota_reservations%ROWTYPE; + final_status text; +BEGIN + IF p_reservation_id IS NULL OR p_succeeded IS NULL THEN + RAISE EXCEPTION 'invalid_llm_quota_finalize' USING ERRCODE = '22023'; + END IF; + + SELECT * INTO reservation + FROM public.llm_quota_reservations + WHERE id = p_reservation_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'llm_quota_reservation_not_found' USING ERRCODE = 'P0002'; + END IF; + + PERFORM public.daily_usage_lock_v1(reservation.user_id, reservation.feature); + SELECT * INTO reservation + FROM public.llm_quota_reservations + WHERE id = p_reservation_id + FOR UPDATE; + + IF reservation.status <> 'reserved' THEN + RETURN jsonb_build_object('reservation_id', reservation.id, 'status', reservation.status); + END IF; + + IF p_succeeded THEN + final_status := 'completed'; + ELSE + UPDATE public.daily_usage + SET count = greatest(count - 1, 0) + WHERE user_id = reservation.user_id + AND date = reservation.usage_date + AND feature = reservation.feature; + + IF reservation.consumed_from = 'overage' THEN + UPDATE public.subscriptions + SET overage_credits = overage_credits + 1, + updated_at = now() + WHERE user_id = reservation.user_id; + END IF; + final_status := 'released'; + END IF; + + UPDATE public.llm_quota_reservations + SET status = final_status, + finalized_at = now(), + release_reason = CASE WHEN p_succeeded THEN NULL ELSE 'provider_failed' END + WHERE id = reservation.id; + + RETURN jsonb_build_object('reservation_id', reservation.id, 'status', final_status); +END; +$$; + +REVOKE ALL ON FUNCTION public.reserve_llm_quota(uuid, uuid, text, integer, text) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.finalize_llm_quota(uuid, boolean) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.reserve_llm_quota(uuid, uuid, text, integer, text) TO service_role; +GRANT EXECUTE ON FUNCTION public.finalize_llm_quota(uuid, boolean) TO service_role; + +CREATE OR REPLACE FUNCTION public.consume_quota( + p_user_id uuid, + p_feature text, + p_base_limit integer, + p_period text DEFAULT 'daily' +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + v_window_start date; + v_current integer := 0; + v_overage integer := 0; + v_new_overage integer; + v_consumed_from text; +BEGIN + IF p_user_id IS NULL + OR p_feature IS NULL + OR p_base_limit IS NULL + OR p_base_limit < -1 + OR p_period IS NULL + OR p_period NOT IN ('daily', 'weekly') THEN + RAISE EXCEPTION 'invalid_quota_consumption' USING ERRCODE = '22023'; + END IF; + + -- Serialise read-then-increment for this user/feature. + PERFORM public.daily_usage_lock_v1(p_user_id, p_feature); + + SELECT coalesce(overage_credits, 0) INTO v_overage + FROM public.subscriptions + WHERE user_id = p_user_id + FOR UPDATE; + v_overage := coalesce(v_overage, 0); + + -- Not available: never spend credits on a feature the tier does not include. + IF p_base_limit = 0 THEN + RETURN jsonb_build_object( + 'allowed', false, + 'current', 0, + 'limit', 0, + 'overage_credits', v_overage, + 'consumed_from', 'none' + ); + END IF; + + v_window_start := CASE WHEN p_period = 'weekly' THEN CURRENT_DATE - 6 ELSE CURRENT_DATE END; + + SELECT coalesce(sum(count), 0)::integer INTO v_current + FROM public.daily_usage + WHERE user_id = p_user_id + AND feature = p_feature + AND date >= v_window_start + AND date <= CURRENT_DATE; + + IF p_base_limit = -1 THEN + v_consumed_from := 'unlimited'; + ELSIF v_current < p_base_limit THEN + v_consumed_from := 'base'; + ELSE + UPDATE public.subscriptions + SET overage_credits = overage_credits - 1, + updated_at = now() + WHERE user_id = p_user_id + AND overage_credits > 0 + RETURNING overage_credits INTO v_new_overage; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'allowed', false, + 'current', v_current, + 'limit', p_base_limit, + 'overage_credits', 0, + 'consumed_from', 'none' + ); + END IF; + + v_overage := v_new_overage; + v_consumed_from := 'overage'; + END IF; + + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (p_user_id, CURRENT_DATE, p_feature, 1) + ON CONFLICT (user_id, date, feature) DO UPDATE + SET count = public.daily_usage.count + 1; + + RETURN jsonb_build_object( + 'allowed', true, + 'current', v_current + 1, + 'limit', p_base_limit, + 'overage_credits', v_overage, + 'consumed_from', v_consumed_from + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.consume_quota(uuid, text, integer, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.consume_quota(uuid, text, integer, text) TO service_role; + +-- ---------------------------------------------------------------------------- +-- 4. Meeting-document claim: reserve the unit in the shared ledger. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.claim_meeting_document_generation_v1( + p_actor_id uuid, + p_idempotency_key uuid, + p_meeting_id uuid, + p_template_id uuid, + p_title text, + p_model text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public, auth, extensions +AS $$ +DECLARE + meeting_row public.meetings; + template_row public.user_templates; + transcript_value text; + transcript_digest text; + request_digest text; + request_row public.meeting_document_generation_requests; + inserted boolean := false; + tier_value text := 'free'; + quota_feature_value text; + quota_limit_value integer; + quota_period_value text; + reservation_id_value uuid; + reservation jsonb; + is_replay boolean := false; + safe_title text := trim(p_title); +BEGIN + IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_meeting_id IS NULL OR p_template_id IS NULL THEN + RAISE EXCEPTION 'generation_identifiers_required' USING ERRCODE = '22023'; + END IF; + IF char_length(safe_title) NOT BETWEEN 1 AND 160 THEN + RAISE EXCEPTION 'invalid_document_title' USING ERRCODE = '22023'; + END IF; + IF p_model NOT IN ('claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6') THEN + RAISE EXCEPTION 'invalid_generation_model' USING ERRCODE = '22023'; + END IF; + + SELECT * INTO meeting_row FROM public.meetings WHERE id = p_meeting_id; + IF meeting_row.id IS NULL THEN + RAISE EXCEPTION 'meeting_not_found' USING ERRCODE = 'P0002'; + END IF; + IF meeting_row.user_id <> p_actor_id AND NOT ( + meeting_row.team_id IS NOT NULL + AND ( + EXISTS ( + SELECT 1 FROM public.teams + WHERE id = meeting_row.team_id AND owner_id = p_actor_id + ) + OR EXISTS ( + SELECT 1 FROM public.team_members + WHERE team_id = meeting_row.team_id + AND user_id = p_actor_id + AND role IN ('owner', 'admin') + ) + ) + ) THEN + RAISE EXCEPTION 'meeting_generation_forbidden' USING ERRCODE = '42501'; + END IF; + + SELECT * INTO template_row + FROM public.user_templates + WHERE id = p_template_id + AND user_id = p_actor_id + AND template_kind = 'meeting_document'; + IF template_row.id IS NULL THEN + RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002'; + END IF; + + SELECT nullif(string_agg( + CASE WHEN nullif(trim(transcript.speaker), '') IS NULL + THEN transcript.text + ELSE trim(transcript.speaker) || ': ' || transcript.text + END, + E'\n' ORDER BY transcript.segment_index + ), '') + INTO transcript_value + FROM public.transcripts AS transcript + WHERE transcript.meeting_id = meeting_row.id; + + transcript_value := coalesce( + transcript_value, + nullif(trim(meeting_row.edited_transcript), ''), + nullif(trim(meeting_row.raw_transcript), '') + ); + IF transcript_value IS NULL THEN + RAISE EXCEPTION 'meeting_transcript_required' USING ERRCODE = '22023'; + END IF; + IF char_length(transcript_value) > 48000 THEN + RAISE EXCEPTION 'meeting_transcript_too_large' USING ERRCODE = '22023'; + END IF; + + SELECT coalesce(subscription.tier, 'free') + INTO tier_value + FROM public.subscriptions AS subscription + WHERE subscription.user_id = p_actor_id; + tier_value := coalesce(tier_value, 'free'); + + IF tier_value = 'free' AND p_model <> 'claude-haiku-4-5-20251001' THEN + RAISE EXCEPTION 'generation_model_not_allowed' USING ERRCODE = '42501'; + END IF; + + quota_feature_value := CASE + WHEN p_model LIKE '%sonnet%' THEN 'llm_sonnet' + WHEN p_model LIKE '%opus%' THEN 'llm_opus' + ELSE 'llm_haiku' + END; + quota_limit_value := CASE + WHEN tier_value = 'free' AND quota_feature_value = 'llm_haiku' THEN 250 + WHEN tier_value = 'free' THEN 0 + WHEN tier_value = 'pro' AND quota_feature_value = 'llm_haiku' THEN 1500 + WHEN tier_value = 'pro' AND quota_feature_value = 'llm_sonnet' THEN 300 + WHEN tier_value = 'pro' AND quota_feature_value = 'llm_opus' THEN 50 + WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_haiku' THEN -1 + WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_sonnet' THEN 1500 + WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_opus' THEN 300 + WHEN tier_value = 'team' AND quota_feature_value = 'llm_haiku' THEN -1 + WHEN tier_value = 'team' AND quota_feature_value = 'llm_sonnet' THEN 3000 + WHEN tier_value = 'team' AND quota_feature_value = 'llm_opus' THEN 600 + WHEN tier_value = 'enterprise' THEN -1 + ELSE 0 + END; + quota_period_value := CASE WHEN tier_value = 'free' THEN 'weekly' ELSE 'daily' END; + IF quota_limit_value = 0 THEN + RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001'; + END IF; + + -- The shared ledger lock: claims, commits, fails, llm-proxy reservations and + -- consume_quota for one user/feature all serialise here. + PERFORM public.daily_usage_lock_v1(p_actor_id, quota_feature_value); + + SELECT EXISTS ( + SELECT 1 FROM public.meeting_document_generation_requests + WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key + ) INTO is_replay; + + -- A replay never starts provider work, so it takes no unit. + IF NOT is_replay THEN + reservation_id_value := gen_random_uuid(); + reservation := public.reserve_llm_quota( + p_actor_id, reservation_id_value, quota_feature_value, quota_limit_value, quota_period_value + ); + IF (reservation->>'allowed')::boolean IS NOT TRUE THEN + RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001'; + END IF; + END IF; + + transcript_digest := encode(extensions.digest(transcript_value, 'sha256'), 'hex'); + request_digest := encode(extensions.digest( + jsonb_build_object( + 'meeting_id', meeting_row.id, + 'template_id', template_row.id, + 'template_revision', template_row.revision, + 'transcript_hash', transcript_digest, + 'title', safe_title, + 'model', p_model + )::text, + 'sha256' + ), 'hex'); + + INSERT INTO public.meeting_document_generation_requests( + user_id, idempotency_key, meeting_id, template_id, request_hash, + document_title, model, quota_feature, quota_limit, quota_period, + template_revision, template_type, transcript_hash, status, llm_reservation_id + ) + VALUES ( + p_actor_id, p_idempotency_key, meeting_row.id, template_row.id, request_digest, + safe_title, p_model, quota_feature_value, quota_limit_value, quota_period_value, + template_row.revision, template_row.template_type, transcript_digest, 'processing', + reservation_id_value + ) + ON CONFLICT DO NOTHING + RETURNING true INTO inserted; + + SELECT * INTO request_row + FROM public.meeting_document_generation_requests + WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key; + + IF NOT coalesce(inserted, false) THEN + IF request_row.request_hash <> request_digest THEN + RAISE EXCEPTION 'generation_idempotency_conflict' USING ERRCODE = '22023'; + END IF; + -- A concurrent claim for the same key won the insert; give our unit back. + IF reservation_id_value IS NOT NULL THEN + PERFORM public.finalize_llm_quota(reservation_id_value, false); + END IF; + END IF; + + RETURN jsonb_build_object( + 'claimed', coalesce(inserted, false), + 'status', request_row.status, + 'documentId', request_row.document_id, + 'meetingTitle', coalesce(meeting_row.title, 'Meeting'), + 'documentTitle', request_row.document_title, + 'templateType', request_row.template_type, + 'systemPrompt', CASE WHEN coalesce(inserted, false) THEN template_row.system_prompt ELSE NULL END, + 'transcript', CASE WHEN coalesce(inserted, false) THEN transcript_value ELSE NULL END, + 'model', request_row.model + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text) + TO service_role; + +-- ---------------------------------------------------------------------------- +-- 5. Meeting-document fail: release the reserved unit. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.fail_meeting_document_generation_v1( + p_actor_id uuid, + p_idempotency_key uuid, + p_error_code text +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + changed integer; + held_reservation uuid; +BEGIN + IF p_error_code NOT IN ( + 'provider_unavailable', 'provider_timeout', 'provider_request_failed', + 'provider_invalid_response', 'quota_exceeded', 'commit_failed' + ) THEN + RAISE EXCEPTION 'invalid_generation_error_code' USING ERRCODE = '22023'; + END IF; + UPDATE public.meeting_document_generation_requests + SET status = 'failed', error_code = p_error_code, completed_at = now() + WHERE user_id = p_actor_id + AND idempotency_key = p_idempotency_key + AND status = 'processing' + RETURNING llm_reservation_id INTO held_reservation; + GET DIAGNOSTICS changed = ROW_COUNT; + + -- Rows claimed before 20260929020000 hold no reservation: nothing to release. + -- finalize is a no-op for a reservation whose lease was already reclaimed. + IF changed > 0 AND held_reservation IS NOT NULL THEN + PERFORM public.finalize_llm_quota(held_reservation, false); + END IF; + RETURN changed > 0; +END; +$$; + +REVOKE ALL ON FUNCTION public.fail_meeting_document_generation_v1(uuid, uuid, text) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.fail_meeting_document_generation_v1(uuid, uuid, text) + TO service_role; + +-- ---------------------------------------------------------------------------- +-- 6. Meeting-document commit: settle the reserved unit, never re-evaluate it. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.commit_meeting_document_generation_v1( + p_actor_id uuid, + p_idempotency_key uuid, + p_content text, + p_latency_ms integer, + p_input_tokens integer DEFAULT NULL, + p_output_tokens integer DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + request_row public.meeting_document_generation_requests; + document_row public.meeting_documents; + settled jsonb; + reservation jsonb; + charged_reservation uuid; + consumed_from text; +BEGIN + IF char_length(trim(p_content)) NOT BETWEEN 1 AND 100000 + OR p_latency_ms NOT BETWEEN 0 AND 600000 + OR (p_input_tokens IS NOT NULL AND p_input_tokens < 0) + OR (p_output_tokens IS NOT NULL AND p_output_tokens < 0) THEN + RAISE EXCEPTION 'invalid_generation_result' USING ERRCODE = '22023'; + END IF; + + SELECT * INTO request_row + FROM public.meeting_document_generation_requests + WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key + FOR UPDATE; + IF request_row.user_id IS NULL THEN + RAISE EXCEPTION 'generation_request_not_found' USING ERRCODE = 'P0002'; + END IF; + IF request_row.status = 'succeeded' THEN + SELECT * INTO document_row FROM public.meeting_documents WHERE id = request_row.document_id; + RETURN jsonb_build_object('idempotent', true, 'document', to_jsonb(document_row)); + END IF; + IF request_row.status <> 'processing' THEN + RAISE EXCEPTION 'generation_request_not_committable' USING ERRCODE = '55000'; + END IF; + + PERFORM public.daily_usage_lock_v1(p_actor_id, request_row.quota_feature); + + charged_reservation := request_row.llm_reservation_id; + IF charged_reservation IS NOT NULL THEN + -- The claim already holds the unit: spend it (no allowance re-check). + settled := public.finalize_llm_quota(charged_reservation, true); + END IF; + + IF settled->>'status' = 'completed' THEN + SELECT reservation_row.consumed_from + INTO consumed_from + FROM public.llm_quota_reservations AS reservation_row + WHERE reservation_row.id = charged_reservation; + ELSE + -- No unit is held: a row claimed before 20260929020000, or a reservation + -- whose lease expired and was reclaimed before commit arrived. Charge one + -- unit now through the same ledger (the previous commit-time rule). + charged_reservation := gen_random_uuid(); + reservation := public.reserve_llm_quota( + p_actor_id, charged_reservation, request_row.quota_feature, + request_row.quota_limit, request_row.quota_period + ); + IF (reservation->>'allowed')::boolean IS NOT TRUE THEN + RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001'; + END IF; + PERFORM public.finalize_llm_quota(charged_reservation, true); + consumed_from := reservation->>'consumed_from'; + END IF; + + INSERT INTO public.meeting_documents( + meeting_id, user_id, template_type, title, content, prompt_used, + llm_model, llm_latency_ms, template_id, generation_idempotency_key + ) + VALUES ( + request_row.meeting_id, + p_actor_id, + request_row.template_type, + request_row.document_title, + trim(p_content), + 'template:' || request_row.template_id::text || '@' || request_row.template_revision::text, + request_row.model, + p_latency_ms, + request_row.template_id, + p_idempotency_key + ) + RETURNING * INTO document_row; + + INSERT INTO public.meeting_document_generation_audit( + user_id, meeting_id, template_id, document_id, idempotency_key, + model, template_revision, transcript_hash, input_tokens, output_tokens, latency_ms + ) + VALUES ( + p_actor_id, request_row.meeting_id, request_row.template_id, document_row.id, + p_idempotency_key, request_row.model, request_row.template_revision, + request_row.transcript_hash, p_input_tokens, p_output_tokens, p_latency_ms + ); + + UPDATE public.meeting_document_generation_requests + SET status = 'succeeded', + document_id = document_row.id, + llm_reservation_id = charged_reservation, + error_code = NULL, + completed_at = now() + WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key; + + RETURN jsonb_build_object( + 'idempotent', false, + 'consumedFrom', consumed_from, + 'document', to_jsonb(document_row) + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.commit_meeting_document_generation_v1(uuid, uuid, text, integer, integer, integer) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.commit_meeting_document_generation_v1(uuid, uuid, text, integer, integer, integer) + TO service_role; diff --git a/server/supabase/migrations/20260929030000_knowledge_chunks_vector_index.sql b/server/supabase/migrations/20260929030000_knowledge_chunks_vector_index.sql new file mode 100644 index 0000000..192104f --- /dev/null +++ b/server/supabase/migrations/20260929030000_knowledge_chunks_vector_index.sql @@ -0,0 +1,102 @@ +-- ============================================================================ +-- Knowledge vector search: rank exactly within the caller's visible chunks +-- ============================================================================ +-- +-- Problem (20260410000004_pgvector_knowledge.sql) +-- * idx_knowledge_chunks_embedding was an IVFFlat index (lists = 100) built +-- in the same migration that added the embedding column, so it was +-- trained on zero vectors and its centroids are meaningless. Nothing ever +-- ran the REINDEX that the comment asked for. +-- * match_knowledge_chunks ended with +-- ORDER BY kc.embedding <=> query_embedding LIMIT match_count +-- which lets the planner walk that ANN index across ALL tenants with the +-- default ivfflat.probes = 1, and only then apply the user/team filter, +-- the similarity threshold and RLS. A user whose chunks are a small share +-- of the table usually got 0-1 rows although exact matches existed, so +-- search-knowledge returned `results: []` and answers were generated +-- without the user's documents. +-- +-- Fix +-- 1) Drop the broken IVFFlat index. It is not replaced by another ANN index +-- (HNSW etc.): match_knowledge_chunks below never orders the whole table +-- by distance, so an ANN index could not serve it and would only add +-- write cost to embed-chunks. Per-caller candidate sets (own documents + +-- teams the caller belongs to) are small and are reached through +-- idx_knowledge_chunks_document. +-- 2) match_knowledge_chunks first materializes the ids of the documents the +-- caller can see, computes the exact cosine distance for their chunks +-- only, and ranks inside that set. The MATERIALIZED CTEs are an +-- optimization fence: the planner cannot turn the ORDER BY back into a +-- global approximate index scan, so recall is exact regardless of what +-- indexes exist on knowledge_chunks. +-- 3) match_count is clamped to [0, 50] (NULL -> default 5). LIMIT NULL used +-- to mean "no limit". The search-knowledge edge function already caps +-- requests at 20, so it is unaffected. +-- +-- Signature, return columns, SECURITY INVOKER (RLS still applies) and the +-- existing EXECUTE grants are unchanged (CREATE OR REPLACE keeps the ACL). +-- ============================================================================ + +DROP INDEX IF EXISTS public.idx_knowledge_chunks_embedding; + +CREATE OR REPLACE FUNCTION public.match_knowledge_chunks( + query_embedding vector(1536), + match_count integer DEFAULT 5, + similarity_threshold double precision DEFAULT 0.5 +) +RETURNS TABLE ( + id uuid, + document_id uuid, + chunk_index integer, + content text, + similarity double precision +) +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + -- Upper bound on rows one call may return. Keep >= search-knowledge's + -- MAX_MATCH_COUNT (20). + max_match_count CONSTANT integer := 50; + effective_count integer := LEAST(GREATEST(COALESCE(match_count, 5), 0), max_match_count); +BEGIN + IF effective_count = 0 OR query_embedding IS NULL THEN + RETURN; + END IF; + + RETURN QUERY + WITH visible_documents AS MATERIALIZED ( + SELECT kd.id AS visible_document_id + FROM public.knowledge_documents kd + WHERE + kd.user_id = auth.uid() + OR ( + kd.team_id IS NOT NULL + AND kd.team_id IN ( + SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = auth.uid() + ) + ) + ), + candidates AS MATERIALIZED ( + SELECT + kc.id AS chunk_id, + kc.document_id AS chunk_document_id, + kc.chunk_index AS chunk_position, + kc.content AS chunk_content, + (kc.embedding <=> query_embedding)::double precision AS distance + FROM visible_documents vd + INNER JOIN public.knowledge_chunks kc ON kc.document_id = vd.visible_document_id + WHERE kc.embedding IS NOT NULL + ) + SELECT + c.chunk_id, + c.chunk_document_id, + c.chunk_position, + c.chunk_content, + (1 - c.distance)::double precision AS similarity + FROM candidates c + WHERE (1 - c.distance) > similarity_threshold + ORDER BY c.distance, c.chunk_id + LIMIT effective_count; +END; +$$; diff --git a/server/supabase/migrations/20260929040000_meeting_rerecord_failure_paths.sql b/server/supabase/migrations/20260929040000_meeting_rerecord_failure_paths.sql new file mode 100644 index 0000000..3645b6b --- /dev/null +++ b/server/supabase/migrations/20260929040000_meeting_rerecord_failure_paths.sql @@ -0,0 +1,345 @@ +-- A re-record that fails AFTER upload must not break the meeting it re-records. +-- +-- 20260929002700 stopped begin from wiping the previous content and taught +-- cancel to fall back to 'completed', but the two post-upload failure paths +-- were left as defined in 20260821000022: +-- * mobile_mark_meeting_processing_failure(p_terminal => true) +-- * mobile_fail_meeting_recording (terminal upload failure, queued item +-- discarded by the user) +-- Both set status = 'error' unconditionally. After mobile_begin_meeting_processing +-- had already pointed meetings.audio_storage_key / duration_ms at the failed +-- capture, a completed meeting was left marked failed on every device, with +-- playback pointing at audio that never produced its transcript, and the failed +-- capture's audio_files row stayed linked (so the retention trigger never +-- queued it for purge). +-- +-- One rule now covers every failure path of a meeting that still holds earlier +-- content (mobile_meeting_has_content_v1): +-- status -> 'completed', error_message cleared +-- audio_storage_key, duration_ms +-- -> restored from the capture that produced the content (the +-- audio of the latest succeeded transcription job, or for +-- content that came from elsewhere, the linked audio that +-- no mobile capture job owns / the transcript span) +-- failed capture -> its audio_files row is detached (meeting_id = NULL), +-- which marks it deleted and queues its object for purge +-- (20260929000004). A later retry of the same local item +-- inserts a fresh row for the same content-addressed key, +-- which cancels that purge. +-- A meeting without earlier content keeps the original 'error' outcome and its +-- audio stays linked so the queued item can be retried. +-- +-- started_at is deliberately NOT frozen during a re-record: memo offsets taken +-- while re-recording (computeMemoTimestamp) are anchored to it. Restoring the +-- pre-re-record value needs a stored copy, which this schema does not have. +-- +-- Signatures, grants and return shapes of the public RPCs are unchanged. + +BEGIN; + +-- Single definition of "this meeting still holds content from an earlier +-- recording" (previously inlined in mobile_cancel_meeting_recording). +CREATE OR REPLACE FUNCTION public.mobile_meeting_has_content_v1(p_meeting_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.meetings m + WHERE m.id = p_meeting_id + AND ( + m.raw_transcript IS NOT NULL + OR m.edited_transcript IS NOT NULL + OR m.minutes_markdown IS NOT NULL + OR m.minutes_json IS NOT NULL + ) + ) + OR EXISTS ( + SELECT 1 FROM public.transcripts t WHERE t.meeting_id = p_meeting_id + ); +$$; + +-- Internal: return a meeting with earlier content to 'completed' after its +-- current capture failed. Only called from the SECURITY DEFINER RPCs below, +-- which have already verified that p_user_id = auth.uid() owns the meeting. +CREATE OR REPLACE FUNCTION public.mobile_restore_meeting_after_failed_capture_v1( + p_meeting_id uuid, + p_user_id uuid, + p_failed_audio_file_id uuid +) +RETURNS public.meetings +LANGUAGE plpgsql +SET search_path = '' +AS $$ +DECLARE + kept_job public.processing_jobs; + kept_audio public.audio_files; + failed_key text; + detached_keys text[]; + restored_duration bigint; + result public.meetings; +BEGIN + -- 1) The capture that produced the content still on the meeting. A successful + -- completion detaches every older audio row, so the newest succeeded job + -- whose audio is still linked is the live one. + SELECT j.* INTO kept_job + FROM public.processing_jobs j + JOIN public.audio_files a ON a.id = j.audio_file_id + WHERE j.user_id = p_user_id + AND j.meeting_id = p_meeting_id + AND j.kind = 'transcription' + AND j.status = 'succeeded' + AND a.user_id = p_user_id + AND a.meeting_id = p_meeting_id + AND a.upload_status = 'uploaded' + AND a.id IS DISTINCT FROM p_failed_audio_file_id + ORDER BY j.completed_at DESC NULLS LAST, j.updated_at DESC, j.id + LIMIT 1; + + IF kept_job.id IS NOT NULL THEN + SELECT * INTO kept_audio + FROM public.audio_files + WHERE id = kept_job.audio_file_id; + ELSE + -- Content that did not come from a mobile capture (desktop sync, import): + -- keep the newest uploaded audio no mobile capture job owns. + SELECT a.* INTO kept_audio + FROM public.audio_files a + WHERE a.user_id = p_user_id + AND a.meeting_id = p_meeting_id + AND a.upload_status = 'uploaded' + AND a.id IS DISTINCT FROM p_failed_audio_file_id + AND NOT EXISTS ( + SELECT 1 FROM public.processing_jobs j + WHERE j.audio_file_id = a.id + AND j.meeting_id = p_meeting_id + AND j.kind = 'transcription' + AND j.status <> 'succeeded' + ) + ORDER BY a.created_at DESC, a.id + LIMIT 1; + END IF; + + SELECT a.storage_key INTO failed_key + FROM public.audio_files a + WHERE a.id = p_failed_audio_file_id + AND a.user_id = p_user_id; + + -- 2) Detach the failed capture(s): only rows positively identified as a + -- capture that did not produce the content (the failed job's audio, an + -- upload that never finished, audio of a job that did not succeed). + -- Anything else linked (audio of content from elsewhere) is left alone. + WITH detached AS ( + UPDATE public.audio_files a + SET meeting_id = NULL + WHERE a.user_id = p_user_id + AND a.meeting_id = p_meeting_id + AND a.id IS DISTINCT FROM kept_audio.id + AND ( + a.id = p_failed_audio_file_id + OR a.upload_status <> 'uploaded' + OR EXISTS ( + SELECT 1 FROM public.processing_jobs j + WHERE j.audio_file_id = a.id + AND j.meeting_id = p_meeting_id + AND j.kind = 'transcription' + AND j.status <> 'succeeded' + ) + ) + RETURNING a.storage_key + ) + SELECT COALESCE(array_agg(d.storage_key), ARRAY[]::text[]) + INTO detached_keys + FROM detached d; + + -- 3) Timing of the live capture: what completion recorded, else the audio, + -- else the transcript span. Unknown -> leave the column as it is. + restored_duration := COALESCE( + CASE + WHEN (kept_job.result ->> 'duration_ms') ~ '^[0-9]{1,18}$' + THEN (kept_job.result ->> 'duration_ms')::bigint + END, + kept_audio.duration_ms, + ( + SELECT max(t.timestamp_ms::bigint + COALESCE(t.duration_ms, 0)) + FROM public.transcripts t + WHERE t.meeting_id = p_meeting_id + ) + ); + + UPDATE public.meetings m + SET status = 'completed', + error_message = NULL, + ended_at = COALESCE(m.ended_at, now()), + duration_ms = COALESCE(restored_duration, m.duration_ms), + -- Only repoint playback that currently targets the failed capture; a key + -- the failure never touched (upload failed before processing began) stays. + audio_storage_key = CASE + WHEN m.audio_storage_key = ANY (detached_keys) + OR m.audio_storage_key = failed_key + THEN kept_audio.storage_key + ELSE m.audio_storage_key + END + WHERE m.id = p_meeting_id + AND m.user_id = p_user_id + RETURNING m.* INTO result; + + RETURN result; +END; +$$; + +-- Same as 20260821000022, plus: a terminal failure of a meeting with earlier +-- content restores it instead of marking it 'error'. +CREATE OR REPLACE FUNCTION public.mobile_mark_meeting_processing_failure( + p_meeting_id uuid, + p_idempotency_key text, + p_error_code text, + p_error_message text, + p_terminal boolean DEFAULT false +) +RETURNS public.processing_jobs +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + result public.processing_jobs; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501'; + END IF; + IF length(COALESCE(p_error_code, '')) NOT BETWEEN 1 AND 64 + OR length(COALESCE(p_error_message, '')) NOT BETWEEN 1 AND 500 THEN + RAISE EXCEPTION 'invalid processing failure' USING ERRCODE = '22023'; + END IF; + + UPDATE public.processing_jobs + SET status = CASE WHEN p_terminal THEN 'failed' ELSE 'queued' END, + progress = CASE WHEN p_terminal THEN progress ELSE LEAST(progress, 69) END, + error_code = p_error_code, + error_message = p_error_message, + completed_at = CASE WHEN p_terminal THEN now() ELSE NULL END + WHERE user_id = current_user_id + AND meeting_id = p_meeting_id + AND idempotency_key = p_idempotency_key + AND kind = 'transcription' + AND status <> 'succeeded' + RETURNING * INTO result; + + IF result.id IS NULL THEN + RAISE EXCEPTION 'processing job not found' USING ERRCODE = '22023'; + END IF; + + IF p_terminal AND public.mobile_meeting_has_content_v1(p_meeting_id) THEN + PERFORM public.mobile_restore_meeting_after_failed_capture_v1( + p_meeting_id, current_user_id, result.audio_file_id + ); + ELSE + UPDATE public.meetings + SET status = CASE WHEN p_terminal THEN 'error' ELSE 'processing' END, + error_message = CASE WHEN p_terminal THEN p_error_message ELSE NULL END + WHERE id = p_meeting_id AND user_id = current_user_id; + END IF; + + RETURN result; +END; +$$; + +-- Same as 20260821000022, plus the earlier-content restore. +CREATE OR REPLACE FUNCTION public.mobile_fail_meeting_recording( + p_meeting_id uuid, + p_error_message text +) +RETURNS public.meetings +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + result public.meetings; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501'; + END IF; + IF length(COALESCE(p_error_message, '')) NOT BETWEEN 1 AND 500 THEN + RAISE EXCEPTION 'invalid recording failure' USING ERRCODE = '22023'; + END IF; + -- Ownership first (and lock the row) so the restore helper only ever runs + -- on the caller's own meeting. + PERFORM 1 FROM public.meetings + WHERE id = p_meeting_id AND user_id = current_user_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'meeting not found or not owned' USING ERRCODE = '42501'; + END IF; + + IF public.mobile_meeting_has_content_v1(p_meeting_id) THEN + RETURN public.mobile_restore_meeting_after_failed_capture_v1( + p_meeting_id, current_user_id, NULL + ); + END IF; + + UPDATE public.meetings + SET status = 'error', + ended_at = COALESCE(ended_at, now()), + error_message = p_error_message + WHERE id = p_meeting_id AND user_id = current_user_id + RETURNING * INTO result; + RETURN result; +END; +$$; + +-- Same behavior as 20260929002700; the content rule now comes from the shared +-- helper instead of an inlined copy. +CREATE OR REPLACE FUNCTION public.mobile_cancel_meeting_recording(p_meeting_id uuid) +RETURNS public.meetings +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + has_content boolean; + result public.meetings; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501'; + END IF; + has_content := public.mobile_meeting_has_content_v1(p_meeting_id); + UPDATE public.meetings m + SET status = CASE WHEN has_content THEN 'completed' ELSE 'error' END, + ended_at = now(), + error_message = CASE + WHEN has_content THEN NULL + ELSE 'Recording was cancelled before processing.' + END + WHERE m.id = p_meeting_id + AND m.user_id = current_user_id + AND m.status = 'recording' + RETURNING m.* INTO result; + IF result.id IS NULL THEN + RAISE EXCEPTION 'active meeting recording not found' USING ERRCODE = '22023'; + END IF; + RETURN result; +END; +$$; + +-- Helpers are internal: never callable through PostgREST. +REVOKE ALL ON FUNCTION public.mobile_meeting_has_content_v1(uuid) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.mobile_restore_meeting_after_failed_capture_v1(uuid, uuid, uuid) + FROM PUBLIC, anon, authenticated; + +REVOKE ALL ON FUNCTION public.mobile_mark_meeting_processing_failure(uuid, text, text, text, boolean) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.mobile_fail_meeting_recording(uuid, text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.mobile_cancel_meeting_recording(uuid) FROM PUBLIC, anon; + +GRANT EXECUTE ON FUNCTION public.mobile_mark_meeting_processing_failure(uuid, text, text, text, boolean) TO authenticated; +GRANT EXECUTE ON FUNCTION public.mobile_fail_meeting_recording(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.mobile_cancel_meeting_recording(uuid) TO authenticated; + +COMMIT; diff --git a/server/supabase/migrations/20260929100011_payple_cancellation_order_scope.sql b/server/supabase/migrations/20260929100011_payple_cancellation_order_scope.sql new file mode 100644 index 0000000..bc10cfa --- /dev/null +++ b/server/supabase/migrations/20260929100011_payple_cancellation_order_scope.sql @@ -0,0 +1,380 @@ +-- ============================================================================ +-- Payple: a refund of an older order must not revoke the current period +-- +-- apply_payment_provider_event checked a non-entitled (revoking) event only +-- against subscriptions.provider and provider_resource_id. For Payple that is +-- the billing-key payer id, shared by every order charged on the key, and +-- p_provider_order_id was ignored for revocations. A confirmed cancellation +-- webhook for last month's order (a console refund of a duplicate or courtesy +-- charge) therefore reset the whole subscription to free, dropping the period +-- paid by the current order and stopping renewals. +-- +-- The payple-webhook edge function now ignores such cancellations itself +-- (webhook-policy.ts, reason 'canceled_order_not_current'). This migration +-- closes the remaining window where a renewal changes payple_pay_oid between +-- the edge function's read and this function's per-user advisory lock. +-- +-- Only the new order-scope guard is added; the body is otherwise identical to +-- 20260821000003_payment_provider_serialization.sql. CREATE OR REPLACE keeps +-- the existing owner and grants (service_role only). +-- ============================================================================ + +BEGIN; + +-- Apply one authoritative provider event. Provider ownership is strict: an +-- event can never overwrite another provider. A cancellation can affect only +-- the exact provider resource currently owning the entitlement. +CREATE OR REPLACE FUNCTION public.apply_payment_provider_event( + p_user_id uuid, + p_provider text, + p_event_id text, + p_event_created_at timestamptz, + p_event_type text, + p_payload_digest text, + p_provider_resource_id text, + p_tier text, + p_status text, + p_entitled boolean, + p_current_period_start timestamptz DEFAULT NULL, + p_current_period_end timestamptz DEFAULT NULL, + p_cancel_at timestamptz DEFAULT NULL, + p_auto_renewing boolean DEFAULT NULL, + p_provider_customer_id text DEFAULT NULL, + p_provider_order_id text DEFAULT NULL, + p_store_product_id text DEFAULT NULL, + p_store_purchase_id uuid DEFAULT NULL, + p_operation_id uuid DEFAULT NULL +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + v_event public.payment_provider_events%ROWTYPE; + v_cursor public.payment_provider_cursors%ROWTYPE; + v_subscription public.subscriptions%ROWTYPE; + v_result jsonb; +BEGIN + IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN + RAISE EXCEPTION 'unknown_user'; + END IF; + IF p_provider NOT IN ('stripe', 'payple', 'google_play', 'app_store', 'admin') THEN + RAISE EXCEPTION 'invalid_provider'; + END IF; + IF p_event_id IS NULL OR length(trim(p_event_id)) NOT BETWEEN 3 AND 255 THEN + RAISE EXCEPTION 'invalid_event_id'; + END IF; + IF p_event_created_at IS NULL OR p_event_created_at > now() + interval '10 minutes' THEN + RAISE EXCEPTION 'invalid_event_created_at'; + END IF; + IF p_event_type IS NULL OR length(trim(p_event_type)) NOT BETWEEN 1 AND 100 THEN + RAISE EXCEPTION 'invalid_event_type'; + END IF; + IF p_payload_digest IS NULL OR p_payload_digest !~ '^[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'invalid_payload_digest'; + END IF; + IF p_provider_resource_id IS NULL + OR length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN + RAISE EXCEPTION 'invalid_provider_resource_id'; + END IF; + IF p_tier NOT IN ('free', 'pro', 'pro_plus') THEN + RAISE EXCEPTION 'invalid_tier'; + END IF; + IF p_entitled AND p_tier = 'free' THEN + RAISE EXCEPTION 'entitled_tier_must_be_paid'; + END IF; + IF p_status IS NULL OR p_status NOT IN ( + 'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete', + 'incomplete_expired', 'paused', 'on_hold', 'expired', 'refunded', 'pending' + ) THEN + RAISE EXCEPTION 'invalid_status'; + END IF; + IF p_current_period_start IS NOT NULL + AND p_current_period_end IS NOT NULL + AND p_current_period_end < p_current_period_start THEN + RAISE EXCEPTION 'invalid_subscription_period'; + END IF; + IF p_provider_customer_id IS NOT NULL AND length(p_provider_customer_id) > 255 THEN + RAISE EXCEPTION 'invalid_provider_customer_id'; + END IF; + IF p_provider_order_id IS NOT NULL AND length(p_provider_order_id) > 255 THEN + RAISE EXCEPTION 'invalid_provider_order_id'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031)); + + INSERT INTO public.payment_provider_events ( + provider, + event_id, + user_id, + provider_resource_id, + event_type, + event_created_at, + payload_digest + ) VALUES ( + p_provider, + trim(p_event_id), + p_user_id, + trim(p_provider_resource_id), + trim(p_event_type), + p_event_created_at, + p_payload_digest + ) + ON CONFLICT (provider, event_id) DO NOTHING + RETURNING * INTO v_event; + + IF v_event.id IS NULL THEN + SELECT * + INTO v_event + FROM public.payment_provider_events + WHERE provider = p_provider + AND event_id = trim(p_event_id) + FOR UPDATE; + IF v_event.user_id <> p_user_id + OR v_event.provider_resource_id <> trim(p_provider_resource_id) + OR v_event.event_type <> trim(p_event_type) + OR v_event.payload_digest <> p_payload_digest THEN + RAISE EXCEPTION 'provider_event_payload_mismatch'; + END IF; + RETURN coalesce( + v_event.result, + jsonb_build_object( + 'applied', false, + 'duplicate', true, + 'reason', 'event_processing_in_progress' + ) + ) || jsonb_build_object('duplicate', true); + END IF; + + SELECT * + INTO v_cursor + FROM public.payment_provider_cursors + WHERE user_id = p_user_id + AND provider = p_provider + FOR UPDATE; + + IF v_cursor.user_id IS NOT NULL AND ( + v_cursor.last_event_created_at > p_event_created_at + OR ( + v_cursor.last_event_created_at = p_event_created_at + AND v_cursor.last_event_id >= trim(p_event_id) + ) + ) THEN + v_result := jsonb_build_object( + 'applied', false, + 'duplicate', false, + 'reason', 'stale_provider_event' + ); + UPDATE public.payment_provider_events + SET disposition = 'ignored', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; + END IF; + + -- A Payple revocation is order-scoped. Every order charged on one billing + -- key shares provider_resource_id (the payer id), so the resource-ownership + -- check below cannot tell last month's order from the current one. Only a + -- cancellation of the order that funds the current period + -- (subscriptions.payple_pay_oid) may revoke it. This runs before the cursor + -- advances so an ignored refund of an older order cannot make a later + -- legitimate event look stale. Callers that revoke without naming an order + -- (payple-renew scheduled expiry, payple-manage) are unaffected. + IF NOT p_entitled + AND p_provider = 'payple' + AND nullif(trim(p_provider_order_id), '') IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.subscriptions + WHERE user_id = p_user_id + AND provider = 'payple' + AND payple_pay_oid IS DISTINCT FROM trim(p_provider_order_id) + ) THEN + v_result := jsonb_build_object( + 'applied', false, + 'duplicate', false, + 'reason', 'canceled_order_not_current' + ); + UPDATE public.payment_provider_events + SET disposition = 'ignored', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; + END IF; + + INSERT INTO public.payment_provider_cursors ( + user_id, provider, last_event_created_at, last_event_id + ) VALUES ( + p_user_id, p_provider, p_event_created_at, trim(p_event_id) + ) + ON CONFLICT (user_id, provider) DO UPDATE + SET last_event_created_at = EXCLUDED.last_event_created_at, + last_event_id = EXCLUDED.last_event_id, + updated_at = now(); + + SELECT * + INTO v_subscription + FROM public.subscriptions + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_subscription.id IS NULL THEN + INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider) + VALUES (p_user_id, 'free', 'active', 'none', 'none') + RETURNING * INTO v_subscription; + END IF; + + IF p_operation_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM public.payment_provider_operations + WHERE id = p_operation_id + AND user_id = p_user_id + AND provider = p_provider + ) THEN + RAISE EXCEPTION 'invalid_payment_operation'; + END IF; + + IF p_entitled AND EXISTS ( + SELECT 1 + FROM public.payment_provider_operations + WHERE user_id = p_user_id + AND provider <> p_provider + AND state IN ('reserved', 'external_created', 'charged') + AND expires_at > now() + ) THEN + v_result := jsonb_build_object( + 'applied', false, + 'duplicate', false, + 'reason', 'other_provider_operation_in_progress' + ); + UPDATE public.payment_provider_events + SET disposition = 'rejected', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; + END IF; + + IF p_entitled AND v_subscription.provider NOT IN ('none', p_provider) THEN + v_result := jsonb_build_object( + 'applied', false, + 'duplicate', false, + 'reason', 'active_subscription_other_provider', + 'owner_provider', v_subscription.provider + ); + UPDATE public.payment_provider_events + SET disposition = 'rejected', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; + END IF; + + IF NOT p_entitled AND v_subscription.provider <> p_provider THEN + v_result := jsonb_build_object( + 'applied', false, + 'duplicate', false, + 'reason', 'provider_not_owner', + 'owner_provider', v_subscription.provider + ); + UPDATE public.payment_provider_events + SET disposition = 'ignored', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; + END IF; + + IF NOT p_entitled + AND v_subscription.provider_resource_id IS DISTINCT FROM trim(p_provider_resource_id) THEN + v_result := jsonb_build_object( + 'applied', false, + 'duplicate', false, + 'reason', 'provider_resource_not_owner' + ); + UPDATE public.payment_provider_events + SET disposition = 'ignored', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; + END IF; + + IF p_entitled THEN + UPDATE public.subscriptions + SET tier = p_tier, + status = p_status, + current_period_start = p_current_period_start, + current_period_end = p_current_period_end, + cancel_at = p_cancel_at, + provider = p_provider, + provider_resource_id = trim(p_provider_resource_id), + provider_event_id = trim(p_event_id), + provider_event_created_at = p_event_created_at, + auto_renewing = p_auto_renewing, + stripe_customer_id = CASE + WHEN p_provider = 'stripe' THEN coalesce(nullif(trim(p_provider_customer_id), ''), stripe_customer_id) + ELSE stripe_customer_id + END, + stripe_subscription_id = CASE + WHEN p_provider = 'stripe' THEN trim(p_provider_resource_id) + ELSE stripe_subscription_id + END, + payple_payer_id = CASE + WHEN p_provider = 'payple' AND p_provider_customer_id = '' THEN NULL + WHEN p_provider = 'payple' AND p_provider_customer_id IS NOT NULL + THEN trim(p_provider_customer_id) + ELSE payple_payer_id + END, + payple_pay_oid = CASE + WHEN p_provider = 'payple' THEN coalesce(nullif(trim(p_provider_order_id), ''), payple_pay_oid) + ELSE payple_pay_oid + END, + store_product_id = CASE + WHEN p_provider IN ('google_play', 'app_store') THEN p_store_product_id + ELSE NULL + END, + store_purchase_id = CASE + WHEN p_provider IN ('google_play', 'app_store') THEN p_store_purchase_id + ELSE NULL + END, + renewal_failures = CASE WHEN p_provider = 'payple' THEN 0 ELSE renewal_failures END, + updated_at = now() + WHERE user_id = p_user_id; + ELSE + UPDATE public.subscriptions + SET tier = 'free', + status = p_status, + current_period_start = coalesce(p_current_period_start, current_period_start), + current_period_end = coalesce(p_current_period_end, current_period_end), + cancel_at = coalesce(p_cancel_at, p_current_period_end, now()), + provider = 'none', + provider_resource_id = NULL, + provider_event_id = trim(p_event_id), + provider_event_created_at = p_event_created_at, + auto_renewing = false, + store_product_id = NULL, + store_purchase_id = NULL, + updated_at = now() + WHERE user_id = p_user_id; + END IF; + + IF p_operation_id IS NOT NULL THEN + UPDATE public.payment_provider_operations + SET state = 'applied', + external_reference = coalesce( + nullif(trim(p_provider_order_id), ''), + nullif(trim(p_provider_resource_id), ''), + external_reference + ), + error_code = NULL, + updated_at = now() + WHERE id = p_operation_id; + END IF; + + v_result := jsonb_build_object( + 'applied', true, + 'duplicate', false, + 'provider', CASE WHEN p_entitled THEN p_provider ELSE 'none' END, + 'tier', CASE WHEN p_entitled THEN p_tier ELSE 'free' END, + 'status', p_status, + 'entitled', p_entitled + ); + UPDATE public.payment_provider_events + SET disposition = 'applied', result = v_result, processed_at = now() + WHERE id = v_event.id; + RETURN v_result; +END; +$$; + +COMMIT; diff --git a/server/supabase/tests/knowledge-chunks-vector-search.integration.sql b/server/supabase/tests/knowledge-chunks-vector-search.integration.sql new file mode 100644 index 0000000..3f89dac --- /dev/null +++ b/server/supabase/tests/knowledge-chunks-vector-search.integration.sql @@ -0,0 +1,307 @@ +\set ON_ERROR_STOP on + +-- Regression for 20260929030000_knowledge_chunks_vector_index.sql. +-- match_knowledge_chunks must return the caller's exact top-k chunks (own +-- documents + documents of teams they belong to), even when other tenants own +-- almost every row of knowledge_chunks and the planner is pushed towards +-- index scans. Before the fix an IVFFlat index trained on an empty table was +-- scanned across all tenants with probes = 1 and filtered by tenant only +-- afterwards, so the caller usually got 0-1 rows although exact matches +-- existed. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION pg_temp.act_as(uid uuid) +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config( + 'request.jwt.claims', + json_build_object('sub', uid, 'role', 'authenticated')::text, + true + ); +$$; + +-- Uniform random direction in [-1, 1]^1536 (deterministic after setseed). +CREATE OR REPLACE FUNCTION pg_temp.rand_vec() +RETURNS public.vector +LANGUAGE sql +VOLATILE +AS $$ + SELECT array_agg(random() * 2 - 1 ORDER BY i)::public.vector + FROM generate_series(1, 1536) AS g(i); +$$; + +-- base plus a small random perturbation (still very similar to base). +CREATE OR REPLACE FUNCTION pg_temp.near_vec(base public.vector, eps double precision) +RETURNS public.vector +LANGUAGE sql +VOLATILE +AS $$ + SELECT array_agg(x + eps * (random() * 2 - 1) ORDER BY i)::public.vector + FROM unnest(base::real[]) WITH ORDINALITY AS t(x, i); +$$; + +-- Ids returned by match_knowledge_chunks, in result order. +CREATE OR REPLACE FUNCTION pg_temp.match_ids(q public.vector, k integer, threshold double precision) +RETURNS uuid[] +LANGUAGE sql +VOLATILE +AS $$ + SELECT coalesce(array_agg(m.id ORDER BY m.ord), ARRAY[]::uuid[]) + FROM public.match_knowledge_chunks(q, k, threshold) + WITH ORDINALITY AS m(id, document_id, chunk_index, content, similarity, ord); +$$; + +-- ── 0) the broken IVFFlat index is gone ────────────────────────────────── + +SELECT pg_temp.assert_true( + NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'knowledge_chunks' + AND indexdef ILIKE '%ivfflat%' + ), + 'knowledge_chunks has no IVFFlat index trained on an empty table' +); + +-- ── fixtures (as postgres) ─────────────────────────────────────────────── + +SELECT setseed(0.4242); + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES + ( + '39000000-0000-4000-8000-00000000000a', 'authenticated', 'authenticated', + 'rag-searcher@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Searcher"}'::jsonb, now(), now() + ), + ( + '39000000-0000-4000-8000-00000000000b', 'authenticated', 'authenticated', + 'rag-other-tenant@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Other"}'::jsonb, now(), now() + ), + ( + '39000000-0000-4000-8000-00000000000c', 'authenticated', 'authenticated', + 'rag-teammate@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Teammate"}'::jsonb, now(), now() + ); + +-- Team T: searcher + teammate. Team T2: other tenant only. +INSERT INTO public.teams (id, name, owner_id) VALUES + ('39100000-0000-4000-8000-00000000000a', 'RAG Team', '39000000-0000-4000-8000-00000000000c'), + ('39100000-0000-4000-8000-00000000000b', 'Other Team', '39000000-0000-4000-8000-00000000000b'); +INSERT INTO public.team_members (team_id, user_id, role) VALUES + ('39100000-0000-4000-8000-00000000000a', '39000000-0000-4000-8000-00000000000c', 'owner'), + ('39100000-0000-4000-8000-00000000000a', '39000000-0000-4000-8000-00000000000a', 'member'), + ('39100000-0000-4000-8000-00000000000b', '39000000-0000-4000-8000-00000000000b', 'owner'); + +INSERT INTO public.knowledge_documents (id, user_id, team_id, title) VALUES + -- visible to the searcher + ('39200000-0000-4000-8000-00000000000a', '39000000-0000-4000-8000-00000000000a', NULL, + 'Searcher personal doc'), + ('39200000-0000-4000-8000-00000000000c', '39000000-0000-4000-8000-00000000000c', + '39100000-0000-4000-8000-00000000000a', 'Teammate shared doc'), + -- invisible to the searcher + ('39200000-0000-4000-8000-00000000000b', '39000000-0000-4000-8000-00000000000b', NULL, + 'Other tenant personal doc'), + ('39200000-0000-4000-8000-0000000000bb', '39000000-0000-4000-8000-00000000000b', + '39100000-0000-4000-8000-00000000000b', 'Other tenant team doc'); + +CREATE TEMP TABLE fixture_query ON COMMIT DROP AS +SELECT pg_temp.rand_vec() AS q; +GRANT SELECT ON fixture_query TO authenticated; + +-- Searcher: 60 random chunks + chunk 60 identical to the query. +INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding) +SELECT '39200000-0000-4000-8000-00000000000a', g, 'searcher chunk ' || g, pg_temp.rand_vec() +FROM generate_series(0, 59) AS g; +INSERT INTO public.knowledge_chunks (id, document_id, chunk_index, content, embedding) +SELECT '39300000-0000-4000-8000-00000000000a', '39200000-0000-4000-8000-00000000000a', 60, + 'searcher exact match', q +FROM fixture_query; + +-- Team doc: 4 random chunks + one close to the query. +INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding) +SELECT '39200000-0000-4000-8000-00000000000c', g, 'team chunk ' || g, pg_temp.rand_vec() +FROM generate_series(0, 3) AS g; +INSERT INTO public.knowledge_chunks (id, document_id, chunk_index, content, embedding) +SELECT '39300000-0000-4000-8000-00000000000c', '39200000-0000-4000-8000-00000000000c', 4, + 'team near match', pg_temp.near_vec(q, 0.05) +FROM fixture_query; + +-- Other tenant owns almost the whole table, including an exact match of its +-- own and near matches in a team the searcher is not in. +INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding) +SELECT '39200000-0000-4000-8000-00000000000b', g, 'other chunk ' || g, pg_temp.rand_vec() +FROM generate_series(0, 1999) AS g; +INSERT INTO public.knowledge_chunks (id, document_id, chunk_index, content, embedding) +SELECT '39300000-0000-4000-8000-00000000000b', '39200000-0000-4000-8000-00000000000b', 2000, + 'other exact match', q +FROM fixture_query; +INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding) +SELECT '39200000-0000-4000-8000-0000000000bb', g, 'other team near ' || g, pg_temp.near_vec(q, 0.02) +FROM fixture_query, generate_series(0, 9) AS g; +-- A chunk without an embedding must never be returned. +INSERT INTO public.knowledge_chunks (document_id, chunk_index, content) +VALUES ('39200000-0000-4000-8000-00000000000a', 61, 'searcher unembedded'); + +ANALYZE public.knowledge_chunks; +ANALYZE public.knowledge_documents; + +-- Exact brute-force ranking over the searcher's visible chunks. +CREATE TEMP TABLE fixture_expected ON COMMIT DROP AS +SELECT + (SELECT array_agg(r.id ORDER BY r.distance, r.id) FROM ( + SELECT kc.id, kc.embedding <=> f.q AS distance + FROM public.knowledge_chunks kc, fixture_query f + WHERE kc.embedding IS NOT NULL + AND kc.document_id IN ('39200000-0000-4000-8000-00000000000a', + '39200000-0000-4000-8000-00000000000c') + ORDER BY 2, 1 + LIMIT 5 + ) r) AS top5, + (SELECT array_agg(r.id ORDER BY r.distance, r.id) FROM ( + SELECT kc.id, kc.embedding <=> f.q AS distance + FROM public.knowledge_chunks kc, fixture_query f + WHERE kc.embedding IS NOT NULL + AND kc.document_id IN ('39200000-0000-4000-8000-00000000000a', + '39200000-0000-4000-8000-00000000000c') + ORDER BY 2, 1 + LIMIT 20 + ) r) AS top20; +GRANT SELECT ON fixture_expected TO authenticated; + +SELECT pg_temp.assert_true( + (SELECT top5[1:2] FROM fixture_expected) + = ARRAY['39300000-0000-4000-8000-00000000000a', + '39300000-0000-4000-8000-00000000000c']::uuid[], + 'fixture sanity: exact match then team near match rank first' +); + +-- ── 1) searcher gets the exact top-k of their visible chunks ───────────── + +CREATE OR REPLACE FUNCTION pg_temp.assert_searcher_results(label text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + q public.vector := (SELECT f.q FROM fixture_query f); + got uuid[]; +BEGIN + got := pg_temp.match_ids(q, 5, -1); + PERFORM pg_temp.assert_true( + got = (SELECT top5 FROM fixture_expected), + format('%s: top-5 equals exact ranking (got %s)', label, got) + ); + + got := pg_temp.match_ids(q, 20, -1); + PERFORM pg_temp.assert_true( + got = (SELECT top20 FROM fixture_expected), + format('%s: top-20 equals exact ranking (got %s rows)', label, cardinality(got)) + ); + + -- default threshold (0.5) as used by search-knowledge: only the two + -- planted matches qualify; the other tenant's exact/near matches never leak. + got := pg_temp.match_ids(q, 20, 0.5); + PERFORM pg_temp.assert_true( + got = ARRAY['39300000-0000-4000-8000-00000000000a', + '39300000-0000-4000-8000-00000000000c']::uuid[], + format('%s: threshold keeps only the caller''s planted matches (got %s)', label, got) + ); + + PERFORM pg_temp.assert_true( + NOT EXISTS ( + SELECT 1 FROM public.match_knowledge_chunks(q, 50, -1) m + WHERE m.document_id NOT IN ('39200000-0000-4000-8000-00000000000a', + '39200000-0000-4000-8000-00000000000c') + OR m.content = 'searcher unembedded' + ), + format('%s: only visible, embedded chunks are returned', label) + ); + + -- match_count is clamped: 66 visible embedded chunks, cap 50. + PERFORM pg_temp.assert_true( + cardinality(pg_temp.match_ids(q, 1000, -1)) = 50, + format('%s: match_count is capped at 50', label) + ); + PERFORM pg_temp.assert_true( + cardinality(pg_temp.match_ids(q, NULL, -1)) = 5, + format('%s: NULL match_count falls back to 5 instead of unlimited', label) + ); + PERFORM pg_temp.assert_true( + cardinality(pg_temp.match_ids(q, 0, -1)) = 0 + AND cardinality(pg_temp.match_ids(q, -3, -1)) = 0, + format('%s: non-positive match_count returns nothing', label) + ); +END; +$$; + +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('39000000-0000-4000-8000-00000000000a'); +-- Push the planner towards index-ordered scans, the worst case for recall: +-- with seq scans and explicit sorts penalised, an ORDER BY distance LIMIT k +-- query is planned as an ANN index scan whenever one is available (what +-- happens in production once knowledge_chunks is large). +SET LOCAL enable_seqscan = off; +SET LOCAL enable_sort = off; + +SELECT pg_temp.assert_searcher_results('no ANN index'); + +-- ── 2) still exact when an ANN index exists and is attractive ──────────── +-- A properly trained IVFFlat index scanned with probes = 1 visits ~2% of all +-- rows across tenants. The function must not let the planner use it for the +-- ranking (the pre-fix shape did). + +RESET ROLE; +CREATE INDEX knowledge_chunks_embedding_probe_test + ON public.knowledge_chunks + USING ivfflat (embedding public.vector_cosine_ops) + WITH (lists = 50); +SET LOCAL ivfflat.probes = 1; + +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('39000000-0000-4000-8000-00000000000a'); + +SELECT pg_temp.assert_searcher_results('trained IVFFlat index present'); + +-- ── 3) the other tenant sees only their own chunks ─────────────────────── + +SELECT pg_temp.act_as('39000000-0000-4000-8000-00000000000b'); + +SELECT pg_temp.assert_true( + (SELECT pg_temp.match_ids(f.q, 1, 0.5) FROM fixture_query f) + = ARRAY['39300000-0000-4000-8000-00000000000b']::uuid[], + 'other tenant gets their own exact match first' +); +SELECT pg_temp.assert_true( + NOT EXISTS ( + SELECT 1 FROM fixture_query f, public.match_knowledge_chunks(f.q, 50, -1) m + WHERE m.document_id NOT IN ('39200000-0000-4000-8000-00000000000b', + '39200000-0000-4000-8000-0000000000bb') + ), + 'other tenant never sees the searcher''s or team T''s chunks' +); + +-- ── 4) anonymous callers get nothing ───────────────────────────────────── + +SELECT set_config('request.jwt.claims', '', true); +SELECT pg_temp.assert_true( + (SELECT cardinality(pg_temp.match_ids(f.q, 50, -1)) FROM fixture_query f) = 0, + 'caller without a user id sees no chunks' +); + +ROLLBACK; diff --git a/server/supabase/tests/meeting-document-generation-quota.integration.sql b/server/supabase/tests/meeting-document-generation-quota.integration.sql index faf646d..6952255 100644 --- a/server/supabase/tests/meeting-document-generation-quota.integration.sql +++ b/server/supabase/tests/meeting-document-generation-quota.integration.sql @@ -6,6 +6,13 @@ -- provider call before commit rejected N-1 of them. A 'processing' request now -- holds one unit of the allowance until it is failed, committed or its lease -- expires. +-- +-- Regression (red-team r3-13, 20260929020000): the held unit lived in a second +-- ledger ('processing' rows) under a second advisory-lock key, so llm-proxy's +-- reserve_llm_quota could take the same last unit while a document was being +-- generated, and commit then threw the paid generation away with +-- generation_quota_exceeded. The claim now reserves through +-- reserve_llm_quota; commit/fail settle that reservation. BEGIN; @@ -34,6 +41,26 @@ EXCEPTION WHEN OTHERS THEN END; $$; +-- Returns the commit payload, or {"error": SQLERRM} when the commit raises. +CREATE OR REPLACE FUNCTION pg_temp.try_commit(p_actor uuid, p_key uuid) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN public.commit_meeting_document_generation_v1(p_actor, p_key, 'Generated body', 10, 1, 1); +EXCEPTION WHEN OTHERS THEN + RETURN jsonb_build_object('error', SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION pg_temp.usage_today(p_actor uuid, p_feature text) +RETURNS integer +LANGUAGE sql +AS $$ + SELECT coalesce(sum(count), 0)::integer FROM public.daily_usage + WHERE user_id = p_actor AND date = CURRENT_DATE AND feature = p_feature; +$$; + INSERT INTO auth.users ( id, aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data, created_at, updated_at @@ -47,19 +74,28 @@ INSERT INTO auth.users ( '37000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', 'meeting-doc-quota-unlimited@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '37000000-0000-4000-8000-000000000003', 'authenticated', 'authenticated', + 'meeting-doc-quota-pro@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() ); UPDATE public.subscriptions SET tier = 'free', status = 'active', overage_credits = 0 WHERE user_id = '37000000-0000-4000-8000-000000000001'; UPDATE public.subscriptions SET tier = 'pro_plus', status = 'active', overage_credits = 0 WHERE user_id = '37000000-0000-4000-8000-000000000002'; +UPDATE public.subscriptions SET tier = 'pro', status = 'active', overage_credits = 0 +WHERE user_id = '37000000-0000-4000-8000-000000000003'; INSERT INTO public.meetings (id, user_id, title, status, raw_transcript) VALUES ('37100000-0000-4000-8000-000000000001', '37000000-0000-4000-8000-000000000001', 'Quota fixture meeting', 'completed', 'Speaker one talked about the roadmap.'), ('37100000-0000-4000-8000-000000000002', '37000000-0000-4000-8000-000000000002', - 'Unlimited fixture meeting', 'completed', 'Speaker two talked about hiring.'); + 'Unlimited fixture meeting', 'completed', 'Speaker two talked about hiring.'), + ('37100000-0000-4000-8000-000000000003', '37000000-0000-4000-8000-000000000003', + 'Pro fixture meeting', 'completed', 'Speaker three talked about the launch.'); INSERT INTO public.user_templates ( id, user_id, template_kind, name, template_type, system_prompt, is_builtin @@ -67,7 +103,9 @@ INSERT INTO public.user_templates ( ('37200000-0000-4000-8000-000000000001', '37000000-0000-4000-8000-000000000001', 'meeting_document', 'Quota fixture template', 'custom', 'Summarize the meeting.', false), ('37200000-0000-4000-8000-000000000002', '37000000-0000-4000-8000-000000000002', - 'meeting_document', 'Unlimited fixture template', 'custom', 'Summarize the meeting.', false); + 'meeting_document', 'Unlimited fixture template', 'custom', 'Summarize the meeting.', false), + ('37200000-0000-4000-8000-000000000003', '37000000-0000-4000-8000-000000000003', + 'meeting_document', 'Pro fixture template', 'custom', 'Summarize the meeting.', false); -- One weekly Haiku unit left for the free user. INSERT INTO public.daily_usage (user_id, date, feature, count) @@ -83,6 +121,7 @@ DECLARE parallel jsonb; replay jsonb; after_release jsonb; + committed jsonb; after_commit jsonb; overage_claim jsonb; overage_parallel jsonb; @@ -105,17 +144,21 @@ BEGIN replay->>'error' IS NULL AND NOT (replay->>'claimed')::boolean AND replay->>'status' = 'processing', 'replaying the in-flight key reports processing: ' || replay::text ); + PERFORM pg_temp.assert_true(pg_temp.usage_today(actor, 'llm_haiku') = 250, + 'a replay takes no additional unit'); -- A failed request releases the unit it held. PERFORM public.fail_meeting_document_generation_v1(actor, '37300000-0000-4000-8000-000000000001', 'provider_timeout'); + PERFORM pg_temp.assert_true(pg_temp.usage_today(actor, 'llm_haiku') = 249, + 'failure gives the held unit back to the ledger'); after_release := pg_temp.try_claim(actor, '37300000-0000-4000-8000-000000000003', meeting, template, haiku); PERFORM pg_temp.assert_true((after_release->>'claimed')::boolean, 'failure releases the in-flight unit: ' || after_release::text); -- Commit converts the held unit into recorded usage; the allowance is spent. - PERFORM public.commit_meeting_document_generation_v1( - actor, '37300000-0000-4000-8000-000000000003', 'Generated body', 10, 1, 1 - ); + committed := pg_temp.try_commit(actor, '37300000-0000-4000-8000-000000000003'); + PERFORM pg_temp.assert_true(committed->>'consumedFrom' = 'base', + 'commit reports the unit the claim held: ' || committed::text); SELECT count INTO usage_count FROM public.daily_usage WHERE user_id = actor AND date = CURRENT_DATE AND feature = 'llm_haiku'; PERFORM pg_temp.assert_true(usage_count = 250, 'commit records exactly one unit'); @@ -132,10 +175,13 @@ BEGIN PERFORM pg_temp.assert_true(overage_parallel->>'error' = 'generation_quota_exceeded', 'a single overage credit is not spent twice by parallel claims: ' || overage_parallel::text); - -- A crashed request stops holding its unit once its lease expires. - UPDATE public.meeting_document_generation_requests - SET created_at = now() - interval '11 minutes' - WHERE user_id = actor AND idempotency_key = '37300000-0000-4000-8000-000000000005'; + -- A crashed request stops holding its unit once its reservation lease expires. + UPDATE public.llm_quota_reservations AS reservation + SET lease_expires_at = now() - interval '1 minute' + FROM public.meeting_document_generation_requests AS request + WHERE request.user_id = actor + AND request.idempotency_key = '37300000-0000-4000-8000-000000000005' + AND reservation.id = request.llm_reservation_id; after_lease := pg_temp.try_claim(actor, '37300000-0000-4000-8000-000000000007', meeting, template, haiku); PERFORM pg_temp.assert_true((after_lease->>'claimed')::boolean, 'an expired in-flight lease no longer holds a unit: ' || after_lease::text); @@ -159,4 +205,146 @@ BEGIN END; $$; +-- Cross-path (r3-13): a meeting document and llm-proxy share one ledger. +DO $$ +DECLARE + actor constant uuid := '37000000-0000-4000-8000-000000000003'; + meeting constant uuid := '37100000-0000-4000-8000-000000000003'; + template constant uuid := '37200000-0000-4000-8000-000000000003'; + opus constant text := 'claude-opus-4-6'; + claim jsonb; + proxy jsonb; + committed jsonb; + blocked jsonb; + legacy jsonb; + proxy_reservation constant uuid := '37600000-0000-4000-8000-000000000001'; + credits integer; +BEGIN + -- One Opus unit left today (Pro: 50/day). + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (actor, CURRENT_DATE, 'llm_opus', 49); + + -- 1) Document first, then a Talk/command request through llm-proxy. + claim := pg_temp.try_claim(actor, '37500000-0000-4000-8000-000000000001', meeting, template, opus); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, + 'the document claim takes the last Opus unit: ' || claim::text); + PERFORM pg_temp.assert_true(pg_temp.usage_today(actor, 'llm_opus') = 50, + 'the claimed unit is visible in daily_usage while the provider call runs'); + + proxy := public.reserve_llm_quota(actor, gen_random_uuid(), 'llm_opus', 50, 'daily'); + PERFORM pg_temp.assert_true((proxy->>'allowed')::boolean IS FALSE, + 'llm-proxy cannot take the unit held by an in-flight document: ' || proxy::text); + PERFORM pg_temp.assert_true( + (SELECT count(*) FROM public.meeting_documents WHERE user_id = actor) = 0, + 'no document exists before commit' + ); + + committed := pg_temp.try_commit(actor, '37500000-0000-4000-8000-000000000001'); + PERFORM pg_temp.assert_true( + committed->>'error' IS NULL AND committed->>'consumedFrom' = 'base', + 'the paid Opus generation is committed with the base unit the claim held: ' || committed::text + ); + PERFORM pg_temp.assert_true(pg_temp.usage_today(actor, 'llm_opus') = 50, + 'commit does not count the unit a second time'); + + -- consume_quota sees the same ledger. + PERFORM pg_temp.assert_true( + (public.consume_quota(actor, 'llm_opus', 50, 'daily')->>'allowed')::boolean IS FALSE, + 'consume_quota counts document units' + ); + + -- 2) llm-proxy first, then a document: the claim sees the proxy's unit. + UPDATE public.daily_usage SET count = 49 + WHERE user_id = actor AND date = CURRENT_DATE AND feature = 'llm_opus'; + proxy := public.reserve_llm_quota(actor, proxy_reservation, 'llm_opus', 50, 'daily'); + PERFORM pg_temp.assert_true((proxy->>'allowed')::boolean, 'llm-proxy takes the last unit'); + blocked := pg_temp.try_claim(actor, '37500000-0000-4000-8000-000000000002', meeting, template, opus); + PERFORM pg_temp.assert_true(blocked->>'error' = 'generation_quota_exceeded', + 'a document claim cannot take the unit held by llm-proxy: ' || blocked::text); + PERFORM public.finalize_llm_quota(proxy_reservation, false); + + -- 3) Overage is taken at claim and refunded when the generation fails. + UPDATE public.daily_usage SET count = 50 + WHERE user_id = actor AND date = CURRENT_DATE AND feature = 'llm_opus'; + UPDATE public.subscriptions SET overage_credits = 1 WHERE user_id = actor; + claim := pg_temp.try_claim(actor, '37500000-0000-4000-8000-000000000003', meeting, template, opus); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'an overage credit covers the claim'); + SELECT overage_credits INTO credits FROM public.subscriptions WHERE user_id = actor; + PERFORM pg_temp.assert_true(credits = 0, 'the credit is held at claim time'); + proxy := public.reserve_llm_quota(actor, gen_random_uuid(), 'llm_opus', 50, 'daily'); + PERFORM pg_temp.assert_true((proxy->>'allowed')::boolean IS FALSE, + 'llm-proxy cannot spend the credit held by an in-flight document'); + PERFORM public.fail_meeting_document_generation_v1(actor, '37500000-0000-4000-8000-000000000003', 'provider_timeout'); + SELECT overage_credits INTO credits FROM public.subscriptions WHERE user_id = actor; + PERFORM pg_temp.assert_true(credits = 1, 'a failed generation refunds the overage credit'); + PERFORM pg_temp.assert_true(pg_temp.usage_today(actor, 'llm_opus') = 50, + 'a failed generation gives its unit back'); + + -- 4) A row claimed before this migration (no reservation) is still charged + -- once, at commit, through the same ledger. + UPDATE public.daily_usage SET count = 49 + WHERE user_id = actor AND date = CURRENT_DATE AND feature = 'llm_opus'; + UPDATE public.subscriptions SET overage_credits = 0 WHERE user_id = actor; + claim := pg_temp.try_claim(actor, '37500000-0000-4000-8000-000000000004', meeting, template, opus); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'legacy fixture claim'); + -- Simulate the pre-migration shape: no reservation, unit not in daily_usage. + PERFORM public.finalize_llm_quota( + (SELECT llm_reservation_id FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '37500000-0000-4000-8000-000000000004'), + false + ); + UPDATE public.meeting_document_generation_requests SET llm_reservation_id = NULL + WHERE user_id = actor AND idempotency_key = '37500000-0000-4000-8000-000000000004'; + legacy := pg_temp.try_commit(actor, '37500000-0000-4000-8000-000000000004'); + PERFORM pg_temp.assert_true(legacy->>'consumedFrom' = 'base', + 'a legacy in-flight row is charged at commit: ' || legacy::text); + PERFORM pg_temp.assert_true(pg_temp.usage_today(actor, 'llm_opus') = 50, + 'a legacy commit charges exactly one unit'); + PERFORM pg_temp.assert_true( + (SELECT llm_reservation_id IS NOT NULL FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '37500000-0000-4000-8000-000000000004'), + 'the legacy commit records the reservation that charged it' + ); +END; +$$; + +-- One lock key for the whole ledger: every quota path takes it through +-- daily_usage_lock_v1 and no quota function hardcodes an advisory-lock key. +-- (fail_meeting_document_generation_v1 locks inside finalize_llm_quota.) +DO $$ +DECLARE + offenders text; +BEGIN + SELECT string_agg(p.proname, ', ' ORDER BY p.proname) + INTO offenders + FROM pg_proc AS p + JOIN pg_namespace AS n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname IN ( + 'reserve_llm_quota', 'finalize_llm_quota', 'consume_quota', + 'claim_meeting_document_generation_v1', 'commit_meeting_document_generation_v1', + 'fail_meeting_document_generation_v1' + ) + AND ( + p.prosrc LIKE '%pg_advisory%' + OR ( + p.proname <> 'fail_meeting_document_generation_v1' + AND p.prosrc NOT LIKE '%daily_usage_lock_v1%' + ) + OR ( + p.proname = 'fail_meeting_document_generation_v1' + AND p.prosrc NOT LIKE '%finalize_llm_quota%' + ) + ); + PERFORM pg_temp.assert_true(offenders IS NULL, + 'quota functions must lock only through daily_usage_lock_v1: ' || coalesce(offenders, '')); + + PERFORM pg_temp.assert_true( + NOT has_function_privilege('anon', 'public.daily_usage_lock_v1(uuid, text)', 'EXECUTE') + AND NOT has_function_privilege('authenticated', 'public.daily_usage_lock_v1(uuid, text)', 'EXECUTE'), + 'the ledger lock is not callable by clients' + ); +END; +$$; + ROLLBACK; diff --git a/server/supabase/tests/meeting-document-llm-quota-lease.integration.sql b/server/supabase/tests/meeting-document-llm-quota-lease.integration.sql new file mode 100644 index 0000000..b4ba0cb --- /dev/null +++ b/server/supabase/tests/meeting-document-llm-quota-lease.integration.sql @@ -0,0 +1,341 @@ +\set ON_ERROR_STOP on + +-- Regression (red-team r3-10): meeting-document claims and llm-proxy +-- reservations used two separate quota ledgers. A claim held its unit only as +-- a 'processing' row in meeting_document_generation_requests (advisory lock +-- seed 0), while reserve_llm_quota counted only daily_usage (seed 20260928). +-- An llm-proxy request could therefore take the unit a running document +-- generation already held; the document was paid for and then rejected by +-- commit with generation_quota_exceeded. +-- +-- Since 20260929020000_unify_llm_quota_inflight a claim takes a +-- reserve_llm_quota lease, so both paths share one ledger (daily_usage + +-- llm_quota_reservations) and one lock (daily_usage_lock_v1). This file covers +-- the cross-path interleavings (document <-> llm-proxy in both orders, a mixed +-- burst at the limit, late commit after lease reclaim, legacy claims); +-- meeting-document-generation-quota.integration.sql covers the single path. +-- +-- Local only: psql against the local Supabase stack. Runs in a transaction and +-- rolls back. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +-- Returns the claim payload, or {"error": SQLERRM} when the claim raises. +CREATE OR REPLACE FUNCTION pg_temp.try_claim(p_actor uuid, p_key uuid, p_meeting uuid, p_template uuid, p_model text) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN public.claim_meeting_document_generation_v1( + p_actor, p_key, p_meeting, p_template, 'Lease fixture document', p_model + ); +EXCEPTION WHEN OTHERS THEN + RETURN jsonb_build_object('error', SQLERRM); +END; +$$; + +-- Returns the commit payload, or {"error": SQLERRM} when the commit raises. +CREATE OR REPLACE FUNCTION pg_temp.try_commit(p_actor uuid, p_key uuid) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN public.commit_meeting_document_generation_v1(p_actor, p_key, 'Generated body', 10, 1, 1); +EXCEPTION WHEN OTHERS THEN + RETURN jsonb_build_object('error', SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION pg_temp.usage_of(p_actor uuid, p_feature text) +RETURNS integer +LANGUAGE sql +AS $$ + SELECT coalesce(sum(count), 0)::integer FROM public.daily_usage + WHERE user_id = p_actor AND feature = p_feature AND date = CURRENT_DATE; +$$; + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES + ( + '38000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', + 'meeting-doc-lease-pro@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '38000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', + 'meeting-doc-lease-free@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '38000000-0000-4000-8000-000000000003', 'authenticated', 'authenticated', + 'meeting-doc-lease-unlimited@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ); + +UPDATE public.subscriptions SET tier = 'pro', status = 'active', overage_credits = 0 +WHERE user_id = '38000000-0000-4000-8000-000000000001'; +UPDATE public.subscriptions SET tier = 'free', status = 'active', overage_credits = 0 +WHERE user_id = '38000000-0000-4000-8000-000000000002'; +UPDATE public.subscriptions SET tier = 'pro_plus', status = 'active', overage_credits = 0 +WHERE user_id = '38000000-0000-4000-8000-000000000003'; + +INSERT INTO public.meetings (id, user_id, title, status, raw_transcript) +VALUES + ('38100000-0000-4000-8000-000000000001', '38000000-0000-4000-8000-000000000001', + 'Lease fixture meeting', 'completed', 'Speaker one talked about the roadmap.'), + ('38100000-0000-4000-8000-000000000002', '38000000-0000-4000-8000-000000000002', + 'Lease fixture meeting', 'completed', 'Speaker two talked about hiring.'), + ('38100000-0000-4000-8000-000000000003', '38000000-0000-4000-8000-000000000003', + 'Lease fixture meeting', 'completed', 'Speaker three talked about budgets.'); + +INSERT INTO public.user_templates ( + id, user_id, template_kind, name, template_type, system_prompt, is_builtin +) VALUES + ('38200000-0000-4000-8000-000000000001', '38000000-0000-4000-8000-000000000001', + 'meeting_document', 'Lease fixture template', 'custom', 'Summarize the meeting.', false), + ('38200000-0000-4000-8000-000000000002', '38000000-0000-4000-8000-000000000002', + 'meeting_document', 'Lease fixture template', 'custom', 'Summarize the meeting.', false), + ('38200000-0000-4000-8000-000000000003', '38000000-0000-4000-8000-000000000003', + 'meeting_document', 'Lease fixture template', 'custom', 'Summarize the meeting.', false); + +-- Pro user, one daily Opus unit left (49/50). +INSERT INTO public.daily_usage (user_id, date, feature, count) +VALUES ('38000000-0000-4000-8000-000000000001', CURRENT_DATE, 'llm_opus', 49); + +-- 1. A running document holds the last unit against llm-proxy, and its commit +-- is not thrown away afterwards (the reported bug). +DO $$ +DECLARE + actor constant uuid := '38000000-0000-4000-8000-000000000001'; + meeting constant uuid := '38100000-0000-4000-8000-000000000001'; + template constant uuid := '38200000-0000-4000-8000-000000000001'; + opus constant text := 'claude-opus-4-6'; + claim jsonb; + proxy jsonb; + committed jsonb; + request_row public.meeting_document_generation_requests; +BEGIN + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000001', meeting, template, opus); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'last Opus unit can be claimed: ' || claim::text); + + SELECT * INTO request_row FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000001'; + PERFORM pg_temp.assert_true(request_row.llm_reservation_id IS NOT NULL, + 'the claim records the LLM quota lease it holds'); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_opus') = 50, + 'the in-flight claim is visible in the shared ledger'); + + -- The bug: llm-proxy used to see 49/50 here and reserve a second paid call. + proxy := public.reserve_llm_quota(actor, gen_random_uuid(), 'llm_opus', 50, 'daily'); + PERFORM pg_temp.assert_true(NOT (proxy->>'allowed')::boolean, + 'llm-proxy cannot take the unit a running document holds: ' || proxy::text); + + committed := pg_temp.try_commit(actor, '38300000-0000-4000-8000-000000000001'); + PERFORM pg_temp.assert_true(committed->>'error' IS NULL AND committed->'document' IS NOT NULL, + 'the paid document is committed, not rejected: ' || committed::text); + PERFORM pg_temp.assert_true(committed->>'consumedFrom' = 'base', 'commit reports the base allowance'); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_opus') = 50, + 'commit completes the lease without charging a second unit'); + PERFORM pg_temp.assert_true( + (SELECT status FROM public.llm_quota_reservations WHERE id = request_row.llm_reservation_id) = 'completed', + 'commit completes the lease'); + + -- Idempotent commit replay does not charge again. + committed := pg_temp.try_commit(actor, '38300000-0000-4000-8000-000000000001'); + PERFORM pg_temp.assert_true((committed->>'idempotent')::boolean, 'commit replay is idempotent'); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_opus') = 50, 'commit replay charges nothing'); +END; +$$; + +-- 2. The reverse order: an llm-proxy reservation holds the last unit, so the +-- document claim is rejected before any provider work. +UPDATE public.daily_usage SET count = 49 +WHERE user_id = '38000000-0000-4000-8000-000000000001' AND date = CURRENT_DATE AND feature = 'llm_opus'; + +DO $$ +DECLARE + actor constant uuid := '38000000-0000-4000-8000-000000000001'; + meeting constant uuid := '38100000-0000-4000-8000-000000000001'; + template constant uuid := '38200000-0000-4000-8000-000000000001'; + proxy_id constant uuid := '38500000-0000-4000-8000-000000000001'; + proxy jsonb; + claim jsonb; +BEGIN + proxy := public.reserve_llm_quota(actor, proxy_id, 'llm_opus', 50, 'daily'); + PERFORM pg_temp.assert_true((proxy->>'allowed')::boolean, 'llm-proxy takes the last unit'); + + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000002', meeting, template, 'claude-opus-4-6'); + PERFORM pg_temp.assert_true(claim->>'error' = 'generation_quota_exceeded', + 'a document claim cannot take the unit llm-proxy holds: ' || claim::text); + PERFORM pg_temp.assert_true(NOT EXISTS ( + SELECT 1 FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000002' + ), 'a rejected claim leaves no request row'); + + -- Once llm-proxy releases its lease, the document can be claimed. + PERFORM public.finalize_llm_quota(proxy_id, false); + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000002', meeting, template, 'claude-opus-4-6'); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'released unit can be claimed: ' || claim::text); + + -- Replaying the in-flight key neither reserves again nor errors. + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000002', meeting, template, 'claude-opus-4-6'); + PERFORM pg_temp.assert_true( + claim->>'error' IS NULL AND NOT (claim->>'claimed')::boolean AND claim->>'status' = 'processing', + 'replaying the in-flight key reports processing: ' || claim::text); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_opus') = 50, 'a replay holds no extra unit'); + + -- Failure releases the unit back to the shared ledger, once. + PERFORM public.fail_meeting_document_generation_v1(actor, '38300000-0000-4000-8000-000000000002', 'provider_timeout'); + PERFORM public.fail_meeting_document_generation_v1(actor, '38300000-0000-4000-8000-000000000002', 'provider_timeout'); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_opus') = 49, + 'failing a claim releases exactly one unit'); + proxy := public.reserve_llm_quota(actor, gen_random_uuid(), 'llm_opus', 50, 'daily'); + PERFORM pg_temp.assert_true((proxy->>'allowed')::boolean, 'the released unit is usable by llm-proxy'); +END; +$$; + +-- 3. Interleaved burst: document claims and llm-proxy reservations together +-- never exceed the remaining allowance, and one overage credit is spent once. +UPDATE public.daily_usage SET count = 45 +WHERE user_id = '38000000-0000-4000-8000-000000000001' AND date = CURRENT_DATE AND feature = 'llm_opus'; +UPDATE public.llm_quota_reservations SET status = 'completed' +WHERE user_id = '38000000-0000-4000-8000-000000000001' AND status = 'reserved'; +UPDATE public.subscriptions SET overage_credits = 1 +WHERE user_id = '38000000-0000-4000-8000-000000000001'; + +DO $$ +DECLARE + actor constant uuid := '38000000-0000-4000-8000-000000000001'; + meeting constant uuid := '38100000-0000-4000-8000-000000000001'; + template constant uuid := '38200000-0000-4000-8000-000000000001'; + granted integer := 0; + outcome jsonb; + i integer; +BEGIN + FOR i IN 1..10 LOOP + IF i % 2 = 0 THEN + outcome := public.reserve_llm_quota(actor, gen_random_uuid(), 'llm_opus', 50, 'daily'); + IF (outcome->>'allowed')::boolean THEN granted := granted + 1; END IF; + ELSE + outcome := pg_temp.try_claim(actor, gen_random_uuid(), meeting, template, 'claude-opus-4-6'); + IF coalesce((outcome->>'claimed')::boolean, false) THEN granted := granted + 1; END IF; + END IF; + END LOOP; + PERFORM pg_temp.assert_true(granted = 6, + '5 base units + 1 overage credit admit exactly 6 paid calls, got ' || granted); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_opus') = 51, 'shared ledger records every admitted call'); + PERFORM pg_temp.assert_true( + (SELECT overage_credits FROM public.subscriptions WHERE user_id = actor) = 0, + 'the overage credit is spent once'); +END; +$$; + +-- 4. Lease handling: an expired claim lease stops holding its unit, and a late +-- commit re-checks the allowance instead of charging past it. +DO $$ +DECLARE + actor constant uuid := '38000000-0000-4000-8000-000000000002'; + meeting constant uuid := '38100000-0000-4000-8000-000000000002'; + template constant uuid := '38200000-0000-4000-8000-000000000002'; + haiku constant text := 'claude-haiku-4-5-20251001'; + claim jsonb; + proxy jsonb; + committed jsonb; + late_id uuid; +BEGIN + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (actor, CURRENT_DATE - 3, 'llm_haiku', 249); + + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000010', meeting, template, haiku); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'last weekly Haiku unit can be claimed'); + + -- Crashed worker: its lease expires, and the next reservation reclaims it. + SELECT llm_reservation_id INTO late_id FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000010'; + UPDATE public.llm_quota_reservations SET lease_expires_at = now() - interval '1 minute' WHERE id = late_id; + + proxy := public.reserve_llm_quota(actor, gen_random_uuid(), 'llm_haiku', 250, 'weekly'); + PERFORM pg_temp.assert_true((proxy->>'allowed')::boolean, + 'an expired document lease no longer holds a unit: ' || proxy::text); + + -- The late commit finds its lease released and the allowance spent: reject + -- rather than charge a unit that is no longer there. + committed := pg_temp.try_commit(actor, '38300000-0000-4000-8000-000000000010'); + PERFORM pg_temp.assert_true(committed->>'error' = 'generation_quota_exceeded', + 'a late commit past the allowance is rejected: ' || committed::text); + + -- With an overage credit the late commit is charged again and succeeds. + UPDATE public.subscriptions SET overage_credits = 1 WHERE user_id = actor; + committed := pg_temp.try_commit(actor, '38300000-0000-4000-8000-000000000010'); + PERFORM pg_temp.assert_true(committed->>'error' IS NULL AND committed->>'consumedFrom' = 'overage', + 'a late commit re-charges through the shared ledger: ' || committed::text); + PERFORM pg_temp.assert_true( + (SELECT overage_credits FROM public.subscriptions WHERE user_id = actor) = 0, + 'the late commit spent the overage credit'); + PERFORM pg_temp.assert_true( + (SELECT sum(count) FROM public.daily_usage WHERE user_id = actor AND feature = 'llm_haiku') = 251, + 'the late commit is recorded once'); +END; +$$; + +-- 5. Claims made before this migration (no lease) still commit through the +-- shared ledger, and fail without touching it. +DO $$ +DECLARE + actor constant uuid := '38000000-0000-4000-8000-000000000003'; + meeting constant uuid := '38100000-0000-4000-8000-000000000003'; + template constant uuid := '38200000-0000-4000-8000-000000000003'; + lease uuid; + claim jsonb; + committed jsonb; +BEGIN + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000020', meeting, template, 'claude-sonnet-4-6'); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'pro_plus Sonnet claim'); + + -- Turn it into a legacy claim: no lease, nothing recorded yet. + SELECT llm_reservation_id INTO lease FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000020'; + UPDATE public.meeting_document_generation_requests SET llm_reservation_id = NULL + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000020'; + PERFORM public.finalize_llm_quota(lease, false); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_sonnet') = 0, 'legacy claim holds nothing'); + + committed := pg_temp.try_commit(actor, '38300000-0000-4000-8000-000000000020'); + PERFORM pg_temp.assert_true(committed->>'error' IS NULL AND committed->>'consumedFrom' = 'base', + 'a legacy claim commits: ' || committed::text); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_sonnet') = 1, 'a legacy commit records one unit'); + + -- Legacy failure is a no-op on the ledger. + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000021', meeting, template, 'claude-sonnet-4-6'); + SELECT llm_reservation_id INTO lease FROM public.meeting_document_generation_requests + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000021'; + UPDATE public.meeting_document_generation_requests SET llm_reservation_id = NULL + WHERE user_id = actor AND idempotency_key = '38300000-0000-4000-8000-000000000021'; + PERFORM public.finalize_llm_quota(lease, false); + PERFORM public.fail_meeting_document_generation_v1(actor, '38300000-0000-4000-8000-000000000021', 'provider_timeout'); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_sonnet') = 1, 'a legacy failure changes nothing'); + + -- Unlimited allowances are never throttled and still release on failure. + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000022', meeting, template, 'claude-haiku-4-5-20251001'); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'unlimited Haiku claim'); + claim := pg_temp.try_claim(actor, '38300000-0000-4000-8000-000000000023', meeting, template, 'claude-haiku-4-5-20251001'); + PERFORM pg_temp.assert_true((claim->>'claimed')::boolean, 'unlimited allowances are not throttled'); + PERFORM public.fail_meeting_document_generation_v1(actor, '38300000-0000-4000-8000-000000000023', 'provider_timeout'); + PERFORM pg_temp.assert_true(pg_temp.usage_of(actor, 'llm_haiku') = 1, 'unlimited failure releases its unit'); +END; +$$; + +ROLLBACK; diff --git a/server/supabase/tests/mobile-rerecord-failure-paths.integration.sql b/server/supabase/tests/mobile-rerecord-failure-paths.integration.sql new file mode 100644 index 0000000..e1b7b92 --- /dev/null +++ b/server/supabase/tests/mobile-rerecord-failure-paths.integration.sql @@ -0,0 +1,341 @@ +\set ON_ERROR_STOP on + +-- A re-record that fails after upload restores the meeting it re-records +-- (migration 20260929040000). Local only: psql against the local Supabase +-- stack. Runs in a transaction and rolls back. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES ( + '91000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', + 'rerecord-failure-owner@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() +); + +-- M1: completed by a mobile capture (A1, job J1). +-- M2: first recording, no content yet. +-- M3: content from elsewhere (desktop): transcript rows, no mobile job, no audio row. +INSERT INTO public.meetings ( + id, user_id, title, status, raw_transcript, minutes_markdown, + duration_ms, audio_storage_key +) VALUES + ( + '92000000-0000-4000-8000-000000000001', + '91000000-0000-4000-8000-000000000001', + 'Completed meeting', 'completed', 'old transcript', 'old minutes', + 1000, '91000000-0000-4000-8000-000000000001/imports/a1.wav' + ), + ( + '92000000-0000-4000-8000-000000000002', + '91000000-0000-4000-8000-000000000001', + 'First recording', 'recording', NULL, NULL, NULL, NULL + ), + ( + '92000000-0000-4000-8000-000000000003', + '91000000-0000-4000-8000-000000000001', + 'Desktop meeting', 'completed', 'desktop transcript', NULL, + 5000, 'desktop/original.wav' + ); + +INSERT INTO public.transcripts (meeting_id, segment_index, timestamp_ms, duration_ms, text) +VALUES + ('92000000-0000-4000-8000-000000000001', 0, 0, 1000, 'old transcript'), + ('92000000-0000-4000-8000-000000000003', 0, 0, 2000, 'desktop segment 0'), + ('92000000-0000-4000-8000-000000000003', 1, 2000, 3000, 'desktop segment 1'); + +INSERT INTO public.audio_files ( + id, user_id, meeting_id, source, original_name, storage_key, mime_type, + size_bytes, duration_ms, sha256, upload_status +) VALUES + ( + '93000000-0000-4000-8000-000000000001', + '91000000-0000-4000-8000-000000000001', + '92000000-0000-4000-8000-000000000001', + 'recording', 'a1.wav', + '91000000-0000-4000-8000-000000000001/imports/a1.wav', + 'audio/wav', 32044, 1000, repeat('1', 64), 'uploaded' + ), + ( + '93000000-0000-4000-8000-000000000002', + '91000000-0000-4000-8000-000000000001', + '92000000-0000-4000-8000-000000000001', + 'recording', 'a2.wav', + '91000000-0000-4000-8000-000000000001/imports/a2.wav', + 'audio/wav', 64044, 2000, repeat('2', 64), 'uploaded' + ), + ( + '93000000-0000-4000-8000-000000000003', + '91000000-0000-4000-8000-000000000001', + '92000000-0000-4000-8000-000000000001', + 'recording', 'a3.wav', + '91000000-0000-4000-8000-000000000001/imports/a3.wav', + 'audio/wav', 96044, 3000, repeat('3', 64), 'uploaded' + ), + ( + '93000000-0000-4000-8000-000000000004', + '91000000-0000-4000-8000-000000000001', + '92000000-0000-4000-8000-000000000002', + 'recording', 'first.wav', + '91000000-0000-4000-8000-000000000001/imports/first.wav', + 'audio/wav', 32044, 1000, repeat('4', 64), 'uploaded' + ), + ( + '93000000-0000-4000-8000-000000000005', + '91000000-0000-4000-8000-000000000001', + '92000000-0000-4000-8000-000000000003', + 'recording', 'desktop-rerecord.wav', + '91000000-0000-4000-8000-000000000001/imports/desktop-rerecord.wav', + 'audio/wav', 32044, 9000, repeat('5', 64), 'failed' + ); + +INSERT INTO public.processing_jobs ( + user_id, audio_file_id, meeting_id, kind, status, progress, attempt_count, + idempotency_key, result, started_at, completed_at +) VALUES ( + '91000000-0000-4000-8000-000000000001', + '93000000-0000-4000-8000-000000000001', + '92000000-0000-4000-8000-000000000001', + 'transcription', 'succeeded', 100, 1, + 'mobile-meeting:m1:' || repeat('1', 64), + jsonb_build_object('audio_file_id', '93000000-0000-4000-8000-000000000001', 'duration_ms', 1000), + now() - interval '1 day', now() - interval '1 day' +); + +SET LOCAL ROLE authenticated; +SELECT set_config( + 'request.jwt.claims', + '{"sub":"91000000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); + +-- 1) Re-record M1 with A2; STT fails terminally after upload. +SELECT public.mobile_begin_meeting_recording('92000000-0000-4000-8000-000000000001'); +SELECT public.mobile_queue_meeting_recording('92000000-0000-4000-8000-000000000001', 2000); +SELECT public.mobile_begin_meeting_processing( + '92000000-0000-4000-8000-000000000001', + '93000000-0000-4000-8000-000000000002', + 'mobile-meeting:m1:' || repeat('2', 64) +); +SELECT pg_temp.assert_true( + (SELECT audio_storage_key FROM public.meetings + WHERE id = '92000000-0000-4000-8000-000000000001') + = '91000000-0000-4000-8000-000000000001/imports/a2.wav', + 'precondition: processing points the meeting at the new capture' +); +SELECT public.mobile_mark_meeting_processing_failure( + '92000000-0000-4000-8000-000000000001', + 'mobile-meeting:m1:' || repeat('2', 64), + 'transcription', 'STT failed', true +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.meetings + WHERE id = '92000000-0000-4000-8000-000000000001' + AND status = 'completed' + AND error_message IS NULL + AND raw_transcript = 'old transcript' + AND minutes_markdown = 'old minutes' + AND duration_ms = 1000 + AND audio_storage_key = '91000000-0000-4000-8000-000000000001/imports/a1.wav' + ), + 'terminal failure of a re-record restores the completed meeting and its audio' +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.processing_jobs + WHERE idempotency_key = 'mobile-meeting:m1:' || repeat('2', 64) + AND status = 'failed' AND error_message = 'STT failed' + ), + 'the failed job still records the failure' +); +RESET ROLE; +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.audio_files + WHERE id = '93000000-0000-4000-8000-000000000002' + AND meeting_id IS NULL AND upload_status = 'deleted' + ), + 'the failed capture audio is detached' +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.audio_purge_queue + WHERE user_id = '91000000-0000-4000-8000-000000000001' + AND storage_key = '91000000-0000-4000-8000-000000000001/imports/a2.wav' + ), + 'the failed capture audio is queued for purge' +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.audio_files + WHERE id = '93000000-0000-4000-8000-000000000001' + AND meeting_id = '92000000-0000-4000-8000-000000000001' + AND upload_status = 'uploaded' + ), + 'the audio behind the kept transcript stays linked' +); +SELECT pg_temp.assert_true( + (SELECT meeting_id FROM public.audio_files + WHERE id = '93000000-0000-4000-8000-000000000003') + = '92000000-0000-4000-8000-000000000001', + 'uploaded audio no failed job owns is not detached by an unrelated failure' +); + +-- 2) Re-record M1 with A3; the user discards the queued item. +SET LOCAL ROLE authenticated; +SELECT set_config( + 'request.jwt.claims', + '{"sub":"91000000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); +SELECT public.mobile_begin_meeting_recording('92000000-0000-4000-8000-000000000001'); +SELECT public.mobile_queue_meeting_recording('92000000-0000-4000-8000-000000000001', 3000); +SELECT public.mobile_begin_meeting_processing( + '92000000-0000-4000-8000-000000000001', + '93000000-0000-4000-8000-000000000003', + 'mobile-meeting:m1:' || repeat('3', 64) +); +SELECT pg_temp.assert_true( + (SELECT status FROM public.mobile_fail_meeting_recording( + '92000000-0000-4000-8000-000000000001', 'Queued audio processing was cancelled' + )) = 'completed', + 'fail_recording reports the restored status' +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.meetings + WHERE id = '92000000-0000-4000-8000-000000000001' + AND status = 'completed' + AND error_message IS NULL + AND duration_ms = 1000 + AND audio_storage_key = '91000000-0000-4000-8000-000000000001/imports/a1.wav' + ), + 'a discarded re-record restores the completed meeting and its audio' +); +RESET ROLE; +SELECT pg_temp.assert_true( + (SELECT meeting_id FROM public.audio_files + WHERE id = '93000000-0000-4000-8000-000000000003') IS NULL, + 'the discarded capture audio is detached' +); + +-- 3) A first recording keeps the error outcome and its audio for retry. +SET LOCAL ROLE authenticated; +SELECT set_config( + 'request.jwt.claims', + '{"sub":"91000000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); +SELECT public.mobile_queue_meeting_recording('92000000-0000-4000-8000-000000000002', 1000); +SELECT public.mobile_begin_meeting_processing( + '92000000-0000-4000-8000-000000000002', + '93000000-0000-4000-8000-000000000004', + 'mobile-meeting:m2:' || repeat('4', 64) +); +SELECT public.mobile_mark_meeting_processing_failure( + '92000000-0000-4000-8000-000000000002', + 'mobile-meeting:m2:' || repeat('4', 64), + 'transcription', 'STT failed', true +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.meetings + WHERE id = '92000000-0000-4000-8000-000000000002' + AND status = 'error' + AND error_message = 'STT failed' + ), + 'terminal failure of a first recording still reports an error' +); +SELECT pg_temp.assert_true( + (SELECT status FROM public.mobile_fail_meeting_recording( + '92000000-0000-4000-8000-000000000002', 'Recording failed' + )) = 'error', + 'fail_recording of a first recording still reports an error' +); +RESET ROLE; +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.audio_files + WHERE id = '93000000-0000-4000-8000-000000000004' + AND meeting_id = '92000000-0000-4000-8000-000000000002' + AND upload_status = 'uploaded' + ), + 'a failed first recording keeps its audio linked for retry' +); + +-- 4) Content from elsewhere: a re-record whose upload failed (no job) keeps the +-- original playback key and restores the duration from the transcript span. +SET LOCAL ROLE authenticated; +SELECT set_config( + 'request.jwt.claims', + '{"sub":"91000000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); +SELECT public.mobile_begin_meeting_recording('92000000-0000-4000-8000-000000000003'); +SELECT public.mobile_queue_meeting_recording('92000000-0000-4000-8000-000000000003', 9000); +SELECT public.mobile_fail_meeting_recording( + '92000000-0000-4000-8000-000000000003', 'Audio upload failed' +); +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 FROM public.meetings + WHERE id = '92000000-0000-4000-8000-000000000003' + AND status = 'completed' + AND error_message IS NULL + AND raw_transcript = 'desktop transcript' + AND duration_ms = 5000 + AND audio_storage_key = 'desktop/original.wav' + ), + 'a failed re-record of foreign content keeps its key and transcript duration' +); +RESET ROLE; +SELECT pg_temp.assert_true( + (SELECT meeting_id FROM public.audio_files + WHERE id = '93000000-0000-4000-8000-000000000005') IS NULL, + 'the failed upload row of the re-record is detached' +); + +-- 5) Cancel before processing keeps the 20260929002700 behavior. +SET LOCAL ROLE authenticated; +SELECT set_config( + 'request.jwt.claims', + '{"sub":"91000000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); +SELECT public.mobile_begin_meeting_recording('92000000-0000-4000-8000-000000000001'); +SELECT pg_temp.assert_true( + (SELECT status FROM public.mobile_cancel_meeting_recording( + '92000000-0000-4000-8000-000000000001' + )) = 'completed', + 'cancel of a re-record still returns to completed' +); + +-- 6) The helpers are not reachable through the API roles. +RESET ROLE; +SELECT pg_temp.assert_true( + NOT has_function_privilege('authenticated', 'public.mobile_meeting_has_content_v1(uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.mobile_meeting_has_content_v1(uuid)', 'EXECUTE') + AND NOT has_function_privilege( + 'authenticated', 'public.mobile_restore_meeting_after_failed_capture_v1(uuid, uuid, uuid)', 'EXECUTE' + ) + AND NOT has_function_privilege( + 'anon', 'public.mobile_restore_meeting_after_failed_capture_v1(uuid, uuid, uuid)', 'EXECUTE' + ), + 'restore helpers are internal' +); + +ROLLBACK; diff --git a/server/supabase/tests/payple-cancellation-order-scope.integration.sql b/server/supabase/tests/payple-cancellation-order-scope.integration.sql new file mode 100644 index 0000000..1b7ed56 --- /dev/null +++ b/server/supabase/tests/payple-cancellation-order-scope.integration.sql @@ -0,0 +1,207 @@ +\set ON_ERROR_STOP on + +-- Regression: a confirmed refund of an older Payple order (same billing key) +-- must not revoke the period funded by the current order. +-- Covers apply_payment_provider_event from +-- 20260929100011_payple_cancellation_order_scope.sql. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES ( + '20000000-0000-4000-8000-000000000031', 'authenticated', 'authenticated', + 'payple-order-scope@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() +); + +DO $$ +DECLARE + c_user constant uuid := '20000000-0000-4000-8000-000000000031'; + c_payer constant text := 'payer-order-scope'; + c_oid1 constant text := 'D3RO-20260801090000-scope01'; + c_oid2 constant text := 'D3RO-20260901090000-scope02'; + v_result jsonb; + v_period_end timestamptz; + v_cursor_before timestamptz; + v_cursor_after timestamptz; + v_sub public.subscriptions%ROWTYPE; + v_disposition text; +BEGIN + -- Month 1: checkout order oid1. + v_result := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'payment:' || c_oid1, + p_event_created_at => now() - interval '40 days', + p_event_type => 'payment.completed', + p_payload_digest => repeat('a', 64), + p_provider_resource_id => c_payer, + p_tier => 'pro', + p_status => 'active', + p_entitled => true, + p_current_period_start => now() - interval '40 days', + p_current_period_end => now() - interval '10 days', + p_auto_renewing => true, + p_provider_customer_id => c_payer, + p_provider_order_id => c_oid1 + ); + PERFORM pg_temp.assert_true((v_result->>'applied')::boolean, 'month 1 applied'); + + -- Month 2: renewal order oid2 on the same billing key. + v_period_end := now() + interval '20 days'; + v_result := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'payment:' || c_oid2, + p_event_created_at => now() - interval '10 days', + p_event_type => 'payment.completed', + p_payload_digest => repeat('b', 64), + p_provider_resource_id => c_payer, + p_tier => 'pro', + p_status => 'active', + p_entitled => true, + p_current_period_start => now() - interval '10 days', + p_current_period_end => v_period_end, + p_auto_renewing => true, + p_provider_customer_id => c_payer, + p_provider_order_id => c_oid2 + ); + PERFORM pg_temp.assert_true((v_result->>'applied')::boolean, 'month 2 applied'); + + SELECT last_event_created_at INTO v_cursor_before + FROM public.payment_provider_cursors + WHERE user_id = c_user AND provider = 'payple'; + + -- Support refunds oid1; Payple confirms the cancellation webhook. + v_result := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'cancel:' || c_oid1 || ':승인취소완료', + p_event_created_at => now(), + p_event_type => 'webhook.payment_canceled', + p_payload_digest => repeat('c', 64), + p_provider_resource_id => c_payer, + p_tier => 'free', + p_status => 'canceled', + p_entitled => false, + p_current_period_start => NULL, + p_current_period_end => now(), + p_cancel_at => now(), + p_auto_renewing => false, + p_provider_customer_id => c_payer, + p_provider_order_id => c_oid1 + ); + PERFORM pg_temp.assert_true( + NOT (v_result->>'applied')::boolean + AND v_result->>'reason' = 'canceled_order_not_current', + 'refund of an older order is ignored, got ' || v_result::text + ); + + SELECT * INTO v_sub FROM public.subscriptions WHERE user_id = c_user; + PERFORM pg_temp.assert_true( + v_sub.tier = 'pro' + AND v_sub.provider = 'payple' + AND v_sub.auto_renewing + AND v_sub.payple_pay_oid = c_oid2 + AND v_sub.current_period_end = v_period_end + AND v_sub.provider_resource_id = c_payer, + 'current paid period and renewals are preserved' + ); + + SELECT disposition INTO v_disposition + FROM public.payment_provider_events + WHERE provider = 'payple' AND event_id = 'cancel:' || c_oid1 || ':승인취소완료'; + PERFORM pg_temp.assert_true(v_disposition = 'ignored', 'ignored event is recorded'); + + SELECT last_event_created_at INTO v_cursor_after + FROM public.payment_provider_cursors + WHERE user_id = c_user AND provider = 'payple'; + PERFORM pg_temp.assert_true( + v_cursor_after = v_cursor_before, + 'ignored refund does not advance the ordering cursor' + ); + + -- Refunding the current order still revokes entitlement. + v_result := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'cancel:' || c_oid2 || ':승인취소완료', + p_event_created_at => now(), + p_event_type => 'webhook.payment_canceled', + p_payload_digest => repeat('d', 64), + p_provider_resource_id => c_payer, + p_tier => 'free', + p_status => 'canceled', + p_entitled => false, + p_current_period_start => NULL, + p_current_period_end => now(), + p_cancel_at => now(), + p_auto_renewing => false, + p_provider_customer_id => c_payer, + p_provider_order_id => c_oid2 + ); + PERFORM pg_temp.assert_true( + (v_result->>'applied')::boolean, + 'refund of the current order is applied, got ' || v_result::text + ); + SELECT * INTO v_sub FROM public.subscriptions WHERE user_id = c_user; + PERFORM pg_temp.assert_true( + v_sub.tier = 'free' AND v_sub.provider = 'none' AND NOT v_sub.auto_renewing, + 'current order refund revokes the subscription' + ); + + -- Revocations that name no order (scheduled expiry, manage) are unaffected. + v_result := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'payment:D3RO-20260910090000-scope03', + p_event_created_at => now() + interval '1 second', + p_event_type => 'payment.completed', + p_payload_digest => repeat('e', 64), + p_provider_resource_id => c_payer, + p_tier => 'pro', + p_status => 'active', + p_entitled => true, + p_current_period_start => now(), + p_current_period_end => now() + interval '1 month', + p_auto_renewing => false, + p_provider_customer_id => c_payer, + p_provider_order_id => 'D3RO-20260910090000-scope03' + ); + PERFORM pg_temp.assert_true((v_result->>'applied')::boolean, 're-entitled'); + v_result := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'scheduled-expire:order-scope', + p_event_created_at => now() + interval '2 seconds', + p_event_type => 'subscription.scheduled_expiry', + p_payload_digest => repeat('f', 64), + p_provider_resource_id => c_payer, + p_tier => 'free', + p_status => 'expired', + p_entitled => false, + p_provider_order_id => NULL + ); + PERFORM pg_temp.assert_true( + (v_result->>'applied')::boolean, + 'order-less revocation still applies, got ' || v_result::text + ); +END; +$$; + +ROLLBACK; + +SELECT 'payple_cancellation_order_scope_ok' AS result; diff --git a/server/supabase/tests/team-rpc-null-role-guard.integration.sql b/server/supabase/tests/team-rpc-null-role-guard.integration.sql new file mode 100644 index 0000000..4583d1a --- /dev/null +++ b/server/supabase/tests/team-rpc-null-role-guard.integration.sql @@ -0,0 +1,338 @@ +\set ON_ERROR_STOP on + +-- Regression: team management RPCs must reject callers that hold no membership +-- row in the target team (see migrations/20260929010000_team_rpc_null_role_guard.sql). +-- +-- Before that migration each RPC read the caller role with +-- SELECT role INTO caller_role FROM team_members WHERE ... user_id = auth.uid() +-- and compared it with `<>` / `NOT IN`. For a non-member caller_role is NULL, +-- every guard evaluated to NULL, plpgsql skipped the RAISE, and any signed-in +-- user who knew a team id could mint an admin invite for a second account, +-- change member roles, remove members and cancel invites. +-- +-- Local only: psql against the local Supabase stack. Runs in a transaction and +-- rolls back. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION pg_temp.act_as(user_id uuid) +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config( + 'request.jwt.claims', + pg_catalog.json_build_object('sub', user_id, 'role', 'authenticated')::text, + true + ); +$$; + +-- Runs `statement` and requires it to fail with SQLSTATE 42501 and the given +-- error message. Anything else (success, another error) fails the test. +CREATE OR REPLACE FUNCTION pg_temp.expect_forbidden( + statement text, + expected_message text, + label text +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + BEGIN + EXECUTE statement; + EXCEPTION + WHEN insufficient_privilege THEN + IF SQLERRM <> expected_message THEN + RAISE EXCEPTION 'assertion_failed: % raised % instead of %', + label, SQLERRM, expected_message; + END IF; + RETURN; + END; + RAISE EXCEPTION 'assertion_failed: % was allowed', label; +END; +$$; + +-- --------------------------------------------------------------------------- +-- 1. Pure role policy: a missing role (NULL) never satisfies any requirement. +-- --------------------------------------------------------------------------- +SELECT pg_temp.assert_true( + public.team_role_at_least_v1(NULL, 'member') IS FALSE, + 'no membership does not satisfy member' +); +SELECT pg_temp.assert_true( + public.team_role_at_least_v1(NULL, 'owner') IS FALSE, + 'no membership does not satisfy owner' +); +SELECT pg_temp.assert_true( + public.team_role_at_least_v1('owner', NULL) IS FALSE, + 'an unknown requirement is never satisfied' +); +SELECT pg_temp.assert_true( + public.team_role_at_least_v1('superuser', 'member') IS FALSE, + 'an unknown role does not satisfy member' +); +SELECT pg_temp.assert_true( + public.team_role_at_least_v1('owner', 'admin') + AND public.team_role_at_least_v1('owner', 'owner') + AND public.team_role_at_least_v1('admin', 'admin') + AND public.team_role_at_least_v1('admin', 'member') + AND public.team_role_at_least_v1('member', 'member'), + 'role hierarchy owner > admin > member is honoured' +); +SELECT pg_temp.assert_true( + NOT public.team_role_at_least_v1('admin', 'owner') + AND NOT public.team_role_at_least_v1('member', 'admin'), + 'lower roles do not satisfy higher requirements' +); +SELECT pg_temp.assert_true( + NOT has_function_privilege('authenticated', 'public.require_team_role_v1(uuid, text)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.require_team_role_v1(uuid, text)', 'EXECUTE'), + 'role guard helper is internal to the team RPCs' +); + +-- --------------------------------------------------------------------------- +-- 2. Fixtures: team T with owner O, admin A, member M; outsider X with a second +-- account X2; a pending invite for I. +-- --------------------------------------------------------------------------- +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) +SELECT + fixture.id, 'authenticated', 'authenticated', fixture.email, + crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() +FROM (VALUES + ('43100000-0000-4000-8000-000000000001'::uuid, 'null-guard-owner@example.invalid'), + ('43100000-0000-4000-8000-000000000002'::uuid, 'null-guard-admin@example.invalid'), + ('43100000-0000-4000-8000-000000000003'::uuid, 'null-guard-member@example.invalid'), + ('43100000-0000-4000-8000-000000000004'::uuid, 'null-guard-outsider@example.invalid'), + ('43100000-0000-4000-8000-000000000005'::uuid, 'null-guard-outsider-2@example.invalid'), + ('43100000-0000-4000-8000-000000000006'::uuid, 'null-guard-invitee@example.invalid') +) AS fixture(id, email); + +SET LOCAL ROLE authenticated; + +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000001'); +SELECT (public.create_team('Null Role Guard Team')->>'id')::uuid AS team_id \gset +SELECT set_config('test.team_id', :'team_id', true); + +SELECT public.create_team_invite(:'team_id', 'null-guard-admin@example.invalid', 'admin') + ->>'token' AS admin_token \gset +SELECT public.create_team_invite(:'team_id', 'null-guard-member@example.invalid', 'member') + ->>'token' AS member_token \gset +SELECT (public.create_team_invite(:'team_id', 'null-guard-invitee@example.invalid', 'member') + ->>'id')::uuid AS pending_invite_id \gset +SELECT set_config('test.pending_invite_id', :'pending_invite_id', true); + +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000002'); +SELECT public.accept_team_invite(:'admin_token'); +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003'); +SELECT public.accept_team_invite(:'member_token'); + +-- --------------------------------------------------------------------------- +-- 3. Non-member X: every management RPC is refused. +-- --------------------------------------------------------------------------- +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000004'); + +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.create_team_invite(%L::uuid, %L, %L)', + :'team_id', 'null-guard-outsider-2@example.invalid', 'admin' + ), + 'team_admin_required', + 'non-member minting an admin invite' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.create_team_invite(%L::uuid, %L, %L)', + :'team_id', 'null-guard-outsider-2@example.invalid', 'member' + ), + 'team_admin_required', + 'non-member minting a member invite' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', + :'team_id', '43100000-0000-4000-8000-000000000003', 'admin' + ), + 'team_owner_required', + 'non-member promoting a member' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', + :'team_id', '43100000-0000-4000-8000-000000000002', 'member' + ), + 'team_owner_required', + 'non-member demoting an admin' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', + :'team_id', '43100000-0000-4000-8000-000000000003' + ), + 'team_admin_required', + 'non-member removing a member' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', + :'team_id', '43100000-0000-4000-8000-000000000002' + ), + 'team_admin_required', + 'non-member removing an admin' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', + :'team_id', '43100000-0000-4000-8000-000000000099' + ), + 'team_admin_required', + 'non-member probing membership of an unknown user' +); +SELECT pg_temp.expect_forbidden( + format('SELECT public.cancel_team_invite(%L::uuid)', :'pending_invite_id'), + 'team_admin_required', + 'non-member cancelling a pending invite' +); + +-- The second attacker account has nothing to accept. +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000005'); +SELECT pg_temp.assert_true( + (SELECT count(*) FROM public.list_team_members(:'team_id')) = 0, + 'second outsider account sees no team members' +); + +-- --------------------------------------------------------------------------- +-- 4. Existing role rules still hold for members. +-- --------------------------------------------------------------------------- +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003'); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.create_team_invite(%L::uuid, %L, %L)', + :'team_id', 'null-guard-outsider-2@example.invalid', 'member' + ), + 'team_admin_required', + 'regular member inviting' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', + :'team_id', '43100000-0000-4000-8000-000000000002', 'member' + ), + 'team_owner_required', + 'regular member demoting an admin' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', + :'team_id', '43100000-0000-4000-8000-000000000002' + ), + 'team_admin_required', + 'regular member removing an admin' +); +SELECT pg_temp.expect_forbidden( + format('SELECT public.cancel_team_invite(%L::uuid)', :'pending_invite_id'), + 'team_admin_required', + 'regular member cancelling another user''s invite' +); + +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000002'); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.create_team_invite(%L::uuid, %L, %L)', + :'team_id', 'null-guard-outsider-2@example.invalid', 'admin' + ), + 'owner_required_for_admin_invite', + 'admin minting another admin invite' +); +SELECT pg_temp.expect_forbidden( + format( + 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', + :'team_id', '43100000-0000-4000-8000-000000000003', 'admin' + ), + 'team_owner_required', + 'admin changing roles' +); +SELECT pg_temp.assert_true( + (public.create_team_invite(:'team_id', 'null-guard-outsider-2@example.invalid', 'member') + ->>'role') = 'member', + 'admin can still invite a member' +); + +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000001'); +SELECT pg_temp.assert_true( + public.update_team_member_role( + :'team_id', '43100000-0000-4000-8000-000000000003', 'admin' + )->>'role' = 'admin' + AND public.update_team_member_role( + :'team_id', '43100000-0000-4000-8000-000000000003', 'member' + )->>'role' = 'member', + 'owner can still change roles' +); +SELECT pg_temp.assert_true( + (public.cancel_team_invite(:'pending_invite_id')->>'cancelled')::boolean, + 'owner can still cancel an invite' +); + +-- A member can still leave on their own; leaving twice reports not found. +SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003'); +SELECT pg_temp.assert_true( + (public.remove_team_member(:'team_id', '43100000-0000-4000-8000-000000000003') + ->>'removed')::boolean, + 'member can leave the team' +); +DO $$ +BEGIN + PERFORM public.remove_team_member( + current_setting('test.team_id')::uuid, + '43100000-0000-4000-8000-000000000003' + ); + RAISE EXCEPTION 'assertion_failed: leaving twice succeeded'; +EXCEPTION + WHEN no_data_found THEN NULL; +END; +$$; + +RESET ROLE; + +-- --------------------------------------------------------------------------- +-- 5. State: the outsider changed nothing. +-- --------------------------------------------------------------------------- +SELECT pg_temp.assert_true( + NOT EXISTS ( + SELECT 1 FROM public.team_invites + WHERE invited_by = '43100000-0000-4000-8000-000000000004' + ), + 'outsider created no invite' +); +SELECT pg_temp.assert_true( + (SELECT role FROM public.team_members + WHERE team_id = :'team_id' + AND user_id = '43100000-0000-4000-8000-000000000002') = 'admin', + 'admin role is unchanged' +); +SELECT pg_temp.assert_true( + NOT EXISTS ( + SELECT 1 FROM public.team_members + WHERE team_id = :'team_id' + AND user_id IN ( + '43100000-0000-4000-8000-000000000004', + '43100000-0000-4000-8000-000000000005' + ) + ), + 'neither outsider account joined the team' +); + +ROLLBACK;