A five-second phone recording took over a minute: stt-proxy waited up to
60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU
Whisper needs 30-90 s per clip. With a direct provider configured the gateway
now gets 5 s plus the clip length (30 s cap).
The direct OpenAI fallback never produced a result. The production key held
characters that are not valid in an HTTP header, so every request threw while
being built; provider keys are now stripped of BOM/zero-width characters and a
still-invalid key counts as not configured. whisper-1 verbose_json reports the
language by name, which the result contract rejected; names now map to codes.
Fail-closed responses list each provider's failure (status or error class,
no secrets) so an outage can be diagnosed without log access.
Preset custom_instructions rows are not client-writable, yet the phone runs
every command with the row's prompt. sync_set_builtin_instruction_prompt_v1
changes only the prompt of the caller's preset (NULL restores the default),
sync_list_builtin_instructions_v1 reports whether each prompt is the default,
and the defaults live in one function.
knowledge_documents deletions now leave a sync tombstone and have a cursor
index and Realtime, so the desktop mirror can follow them. pg_cron runs
prune_sync_tombstones_v1 every day at 03:17 UTC with the 180-day window.
sync_tombstones logs every owner-scoped delete on the synced tables so an
offline device can apply it later; filtered Realtime channels never deliver
DELETE, so the tombstone INSERT is also the live deletion signal.
history.revision now follows content changes made without a revision (the
desktop mirror), meeting_memos gains updated_at, and cursor indexes back
keyset pulls. Templates get client-keyed upsert/delete wrappers over the
revision-checked mobile RPCs.
export_account_portability serialised whole rows while the v1 archive is an
exact key set; columns added later made every account with meetings fail
export. Each dataset is projected back onto its v1 keys.
Stripe is not used. Keeping its checkout, portal and webhook paths meant a
second payment provider, a second return-URL format and dead UI.
- Delete the stripe-checkout, stripe-portal and stripe-webhook functions and
their config; billing-catalog serves Payple prices only, and the web parser
rejects a catalog that still mixes in Stripe prices.
- Web: drop the Stripe checkout/portal buttons, provider toggle and return
notices; billing shows Payple only. Past rows with provider='stripe' are
still displayed ("Stripe (종료)") with a support contact instead of a portal.
- Desktop: delete the Stripe checkout modal, payment IPC channels, preload
namespace and their types; "Remove ads with Pro" opens the web billing page
via license.openBilling. Support/refund copy names Payple.
- billingUrl() loses the Stripe-only success/canceled result option; the
Deno contract is regenerated.
- Migrations and the DB's accepted provider values are untouched (history).
- Docs and the backlog record the removal (MON-04, EXT-STRIPE-01, GAP-BILL-03).
Verified: typecheck (desktop/web/admin/api-client/mobile), contract:check,
deno check all functions, deno test 80/80, desktop 1478/1480 on the Electron
runtime (2 known environment failures), web and admin builds, release
metadata and mobile boundary self-tests, eslint on changed files.
Since 2026-09-19 the site bridge worker served every path from Pages, so the
API server on the NAS had no public route: admin login (API_SERVER_URL)
and stt-proxy (D3RO_API_URL) called https://d3ro.chanpaca.net/api/... and
got the landing page's 405.
- The worker passes /app, /api and /health through to the domain origin
(the kd-nas tunnel) and serves everything else from Pages.
- The kd-nas tunnel sends path ^/app on d3ro.chanpaca.net to the web app
(NAS 3002); other paths keep going to the API (NAS 5050). No extra
hostname or DNS record is needed, so WEB_APP_ORIGIN is removed.
- API_PATH_PREFIXES joins WEB_APP_BASE_PATH in packages/core/src/web-urls.ts
(contract regenerated).
Verified live: /app/login 200, /health and /api/health 200, API login 401 for
an unknown account (was 405), landing/legal/404 unchanged, git/sso/admin
hosts unaffected.
apps/web was never deployed, so /billing on the public domain returned the
landing page and d3ro.dev (desktop "upgrade") did not resolve.
- apps/web runs with basePath /app and output standalone; /download and
/releases redirect to the site's #download. A Dockerfile and a d3ro-web
compose service (port 3002) deploy it to the NAS with the other images.
- The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host)
and rewrites upstream redirects; everything else still goes to Pages.
With no origin configured /app answers 503 instead of the landing page.
- Desktop upgrade, desktop Stripe return, mobile subscription management,
the web checkout/portal returns and the site all use billingUrl(); the
return query is success=1 / canceled=1, which the billing page reads.
The billing page highlights ?tier=pro|pro_plus, and signing in from a
billing link returns to the same plan.
- auth/callback pins the redirect origin in production and rejects
protocol-relative next= values (open redirect).
- Mobile legal links use SITE_URLS (fixes the missing slash on /terms).
- Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal
mounts; deploy scripts add the web image and the SUPABASE_* values the NAS
compose already required; .dockerignore keeps app .env files out of images.
- Supabase auth redirects allow /app/** (remote dashboard must match).
Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
Prices, quotas and site URLs were copied by hand into the edge functions,
admin, desktop and the landing site, and the copies disagreed (Payple billed
9,900/29,900 KRW, admin labels said 12,900/24,900 KRW and $9.9/$19.9, the
site said 2,900/8,900 KRW).
- packages/core/src/plan-catalog.ts is the single source for PLAN_PRICE_KRW
(Free 0 / Pro 2,900 / Pro+ 8,900 a month) and PLAN_QUOTA.
- packages/core/src/web-urls.ts is the single source for the public origin,
the /app web-app base path, SITE_URLS and billingUrl().
- Deno cannot bundle packages/core, so scripts/ci/sync-core-contract.mjs
generates _shared/core-contract.generated.ts; `npm run contract:check`
fails on drift (same pattern as version:sync).
- Payple checkout, renewal and webhook amount checks now bill the catalog
price, so existing subscribers move to the new price at their next renewal.
quota.ts, team-contract.ts and the tests read the generated values.
- Admin MRR/ARR is computed in KRW from the catalog; license labels, the
release link and desktop PREMIUM_LLM limits derive from core; the site
imports prices and quotas directly.
Policy: docs/REFACTOR_POLICY.md Wave 3, W3-1 and W3-2.
d3ro.chanpaca.net answered an empty Cloudflare 404 because the account had no
Pages project for the landing site, and attaching the custom domain still needs
a DNS CNAME that the local Cloudflare credentials cannot create. Add a small
Workers route bridge that serves the Pages deployment on that hostname so the
domain works now and keeps following Pages deploys, and record the cleanup
left to do.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
Every notification depended on Firebase Cloud Messaging, so a missing
Firebase project, which is the current state, meant no notification could be
delivered on any platform. Web Push and token-based Apple Push are now first
class transports alongside FCM, chosen per registered device, and a scheduled
Cloudflare Worker drain retries an outbox so a provider outage delays rather
than drops a message.
- payple-renew: 만료 구독 조회 → paypleBilling → 기간 갱신
- 3회 연속 실패 시 tier=free, status=expired 다운그레이드
- migration: subscriptions.renewal_failures 컬럼 추가
- payple-webhook: 결제 완료 시 renewal_failures=0 리셋
- GitHub Actions: 매일 01:00 UTC 스케줄
SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1).
## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치
- 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..."
- 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK.
빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서).
- 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지).
14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체.
nanoid 의존성 + electron.vite.config exclude 제거.
drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능).
## Bug 5: supabase_realtime publication 누락
- 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT
- 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가.
데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음.
- 픽스: 20260411000002_realtime_publication.sql 신규.
pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/
meeting_documents/history/dictionary 5개). supabase db push 적용.
## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스)
- 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token)
명시 호출 (채널 구성 이전).
- ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요.
블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지.
## 실증 결과
- A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈
- B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0
- C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode
- D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존
- E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건)
검증: desktop tsc --noEmit ✅, dev 재기동 ✅, push 최초 성공 ✅
빅뱅 Phase 5 실증 중 발견한 3개 버그 해결:
1. implicit flow 응답 무시:
- supabase-js가 persistSession:false 일 때 PKCE code_verifier 저장 못해
implicit flow로 fallback, fragment(#access_token=...&refresh_token=...)로
토큰 전달. handleDeepLink는 ?code=만 파싱하고 있어 무시됨.
- handleDeepLink에 fragment parser 추가 (access_token + refresh_token)
- CloudSyncService.handleAuthTokens() 신규 — setSession() 후
기존 _onAuthenticated SSOT로 수렴
- 에러 query/fragment(error, error_description) 상세 로깅
- 토큰 값은 로그에 안 찍히도록 (query=yes/no, fragment=yes/no 만)
2. Supabase RLS 무한 재귀:
- team_members_read_same_team 정책이 자기 테이블 재조회 →
Postgres RLS 엔진 무한 재귀 탐지 에러
- meetings / meeting_memos / meeting_documents / team_invites 정책이
team_members 서브쿼리 경유해서 전부 같이 터짐 + Realtime TIMED_OUT
- migration 20260411000001: SECURITY DEFINER 함수 2개
(user_team_ids, user_admin_team_ids) 신규 — Supabase 권장 패턴
- 영향 정책: team_members(4) + meetings(2) + meeting_memos(1) +
meeting_documents(2) + team_invites(1) 전부 함수 기반으로 재작성
- supabase db push 완료
3. LoginScreen stale 번들:
- Phase 1.5에서 import 제거했는데도 vite HMR/cache 어딘가에서
stale state 유지해서 렌더러에 계속 뜸
- LoginScreen.tsx 파일 자체 삭제 (vite 컴파일 대상 제거)
로그인 성공 확인: yunchan8804@gmail.com 으로 Google OAuth 완주 →
users/7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93/d3ro.db 생성 → Initial sync 시작.
- Node 22 + sox + supabase CLI 설치 (brew)
- npm install --force + @electron/rebuild@3 33.4.11 완료
- desktop/web typecheck + web build + api-client test 전부 통과
- supabase link --project-ref llnocwyqvhgwpdjcqqyw
- config.toml db.major_version 15→17 (원격 매치)
- db push: 8개 마이그레이션 전부 적용
- functions deploy: 10개 Edge Functions 전부 배포
- apps/web/.env.local + server/supabase/.env.local 생성 (gitignored)
- next build 재검증 — Supabase 런타임 주입 확인 (라우트 동적화)
묶음 Q — pgvector + 시맨틱 검색:
- migrations/20260410000004_pgvector_knowledge.sql
- vector extension, embedding vector(1536) 컬럼
- ivfflat cosine 인덱스
- match_knowledge_chunks(query_embedding, match_count, similarity_threshold) RPC
- RLS: user_id 또는 소속 팀 기준
- functions/embed-chunks: 문서 소유권 확인 후 OpenAI text-embedding-3-small 배치 호출 → knowledge_chunks.embedding 업데이트
- functions/search-knowledge: 쿼리 텍스트 → OpenAI 임베딩 → user 권한 RPC 호출 → 상위 청크 반환
- config.toml에 embed-chunks/search-knowledge 등록
- components/knowledge/knowledge-search.tsx: 검색창 + 결과 카드(유사도 %)
- /knowledge 페이지에 검색 UI 추가
묶음 R — /chat SSE 스트리밍:
- functions/llm-proxy: Anthropic Messages API stream 지원
- ANTHROPIC_API_KEY 없으면 SSE placeholder 스트림
- stream=true일 때 response.body 그대로 프록시 (text/event-stream)
- stream=false는 JSON 응답
- components/chat/chat-panel.tsx:
- stream=true로 요청
- ReadableStream 파싱 (SSE: data: {type:content_block_delta, delta:{text_delta}})
- assistantId 메시지를 progressive 업데이트, scrollToBottom
- 불필요한 LlmResponse 인터페이스 제거
묶음 S — DocumentEditor:
- components/meetings/document-editor.tsx
- 문서 박스 클릭 → MUI Dialog (fullWidth, maxWidth md)
- TextField multiline 20~40 rows, monospace
- 제목 편집 + 저장/삭제 버튼
- meetings/[id] 페이지 Documents 섹션을 DocumentEditor로 교체 (+ typoSx 미사용 import 제거)
묶음 T — /actions (VoiceAction 이식):
- components/actions/action-runner.tsx
- SYSTEM_PROMPT로 JSON 스키마 강제 (create_meeting/search_knowledge/create_memo/send_team_invite/unknown)
- LLM 응답에서 JSON 추출 → 파싱 → 확인 후 실행
- 각 type별 실행 로직 (meetings/memos INSERT, 검색은 안내만)
- app/(app)/actions/page.tsx + Sidebar Actions 메뉴 + AutoAwesomeIcon
묶음 U — Realtime + Storage + 메모 UI:
- CloudSyncService:
- RealtimeChannel import 추가
- startRealtime(): meetings/history/dictionary 변경 구독, debounce 후 pullAll 자동 트리거
- stopRealtime(), signIn 직후/세션 복원 시 자동 시작, signOut 시 종료
- apps/web/components/meetings/memo-form.tsx
- TextField + 저장 버튼, 회의 시작 기준 경과 ms 자동 계산
- Realtime 구독자에게 자동 전파
- meetings/[id] MEMOS 섹션에 MemoForm 렌더
- apps/web/components/record/mic-recorder.tsx
- STT 성공 후 Supabase Storage 'audio' 버킷에 {user_id}/{ts}.webm 업로드
- meetings 테이블에 INSERT (raw_transcript, audio_storage_key, duration_ms, ended_at)
- Storage/meetings 실패는 전사 결과는 유지하며 경고
묶음 V — 11개 locale nav 키:
- en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th 에 nav.chat/nav.knowledge/nav.actions 추가
- ko.json에 nav.actions 추가
- Sidebar에 Actions 메뉴(AutoAwesomeIcon) 등록
검증:
- desktop typecheck + build OK
- web typecheck + build OK (15 라우트: 기존 14 + /actions)
- api-client test 19 passed
- 회귀 없음
통계:
- 총 Edge Functions 12개 (embed-chunks/search-knowledge 추가)
- 총 SQL 마이그레이션 8개
- 웹 라우트 15개 (accept-invite/actions/billing/chat/dashboard/knowledge/login/meetings/meetings[id]/record/teams/teams[id]/auth-callback/root/_not-found)
묶음 A — UI 정리 + i18n 완성:
[A1] desktop SettingsModal에 CloudSyncSection 통합 (탭 6번째)
- CloudIcon import, settings.tabs.cloud 키 추가
- 기존 about 탭은 index 5 -> 6
[A2] apps/web Sidebar 공유 layout 리팩터링
- app/(app)/layout.tsx 신규 (route group)
- dashboard/meetings/record/teams/billing을 (app)/ 아래로 git mv
- app/(app)/layout.tsx에 auth 가드 + Sidebar 통합
- 기존 개별 page.tsx에서 Sidebar/auth 중복 제거
- app/(app)/dashboard/layout.tsx 제거 (루트 layout이 처리)
[A3] 11개 locale에 V2 새 키 추가 (en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th)
- nav.meetings/record/teams/billing/logout
- login.subtitle/google/github/terms
- settings.tabs.cloud
묶음 B — V2-4b pull 동기화:
- CloudSyncService.pullAll() 신규
- history/dictionary 테이블 원격에서 fetch
- last_sync_at 이후 updated_at만 필터
- Last-Write-Wins 충돌 해결 (remote.updated_at > local.updated_at)
- 로컬에 없는 행은 INSERT, 있는 행은 UPDATE (구체적 컬럼 지정)
- IPC CLOUD_SYNC.PULL_ALL 채널 + handler + preload api
- CloudSyncSection에 Pull 버튼 추가 (Push 옆에 위치)
묶음 C — V2-8b Stripe webhook 서명 검증:
- stripe-webhook Edge Function에 Web Crypto API 기반 HMAC-SHA256 검증
- Stripe-Signature 헤더 파싱 (t=, v1= 엔트리)
- Replay 방지 (timestamp tolerance 300초)
- constantTimeEqual로 타이밍 공격 방지
- crypto.subtle.importKey/sign으로 HMAC 계산
- stripe-portal Edge Function 신규 (Customer Portal)
- JWT 인증 -> 기존 customer_id 조회 -> billing_portal/sessions 생성
- return_url 지원
- apps/web/components/billing/portal-button.tsx (구독 관리 버튼)
- billing 페이지에 Free 외 tier 사용자에게 PortalButton 표시
- config.toml에 stripe-portal 함수 등록 (verify_jwt=true)
묶음 D — V2-6b packages/ui-native:
- @d3ro/ui-native 신규 패키지 (React Native 전용 DS)
- theme.ts: d3roNativePalette/Typo/Radius (MUI 없는 정적 값)
- components/MetalCard.tsx: View + 섀시 섀도우
- components/PhosphorText.tsx: Text + 앰버 glow (textShadow)
- components/Led.tsx: View 원 + glow
- components/PhysicalButton.tsx: Pressable + 누름 느낌
- React/React-Native는 peerDependencies
- apps/mobile/package.json에 @d3ro/ui-native를 file: 의존성으로 추가
(mobile은 npm workspace 제외이므로 file path 필요)
검증:
- desktop typecheck OK
- web typecheck OK
- web next build OK (11 라우트, (app) 그룹 반영)
- 회귀 없음
server/supabase/ 신규 디렉토리:
config.toml — Supabase CLI 설정
- 프로젝트 ID, DB 포트, auth providers (Google/GitHub/Apple),
edge_runtime, functions.*.verify_jwt 설정
migrations/ (PostgreSQL DDL, 시간순):
- 20260409000001_initial_schema.sql
12개 테이블: profiles, teams, team_members, meetings,
meeting_memos, meeting_documents, transcripts, history,
dictionary, memo_tags, daily_usage, subscriptions
+ 인덱스 + FK CASCADE + transcripts Realtime publication
- 20260409000002_rls_policies.sql
개인 전용(history/dictionary): user_id = auth.uid()
팀 공유(meetings 등): 본인 OR team_members 조회 subquery
daily_usage: 읽기만, 쓰기는 service_role RPC
- 20260409000003_auth_triggers.sql
handle_new_user — auth.users INSERT → profiles+subscriptions 자동 생성
moddatetime — 8개 테이블 updated_at 자동 갱신
increment_daily_usage — service_role 전용 쿼터 RPC
- 20260409000004_storage_buckets.sql
audio/exports/avatars 3개 버킷 + 경로 기반 접근 정책
(파일 경로가 {user_id}/...로 시작해야 쓰기 허용)
functions/ (Deno/TypeScript Edge Functions):
- _shared/cors.ts — CORS 헤더 + preflight 핸들러
- _shared/auth.ts — requireUser (JWT 검증 + User 반환)
- _shared/quota.ts — 티어별 쿼터 체크 + consume + service role client
- stt-proxy/index.ts — Google Cloud STT 래퍼 스캐폴딩
placeholder 응답, 실제 API 호출 코드는 주석으로 포함
- llm-proxy/index.ts — Anthropic Messages API 래퍼 스캐폴딩
티어별 허용 모델 정책 (free=Haiku, pro=Sonnet, team=Opus)
설계 문서:
- docs/v2/phase-V2-2.md — 상세 설계 (스키마/RLS/Edge Functions/Realtime)
- docs/v2/phase-V2-2-setup.md — 사용자 액션 가이드 (Supabase 계정/
OAuth 등록/CLI/배포/검증)
apps/desktop typecheck 통과 (server/는 Deno 런타임이라 별도).
실제 Supabase 프로젝트 배포는 사용자가 phase-V2-2-setup.md 따라 수행.