feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -8,9 +8,15 @@ node_modules/
dist/
**/dist/
build/
**/build/
out/
**/out/
test-results/
**/test-results/
**/playwright-report/
coverage/
**/coverage/
**/*.tsbuildinfo
*.log
*.tar
bin/

View file

@ -40,6 +40,12 @@ ADMIN_COOKIE_SECURE=true
# Use a distinct random value with at least 32 UTF-8 bytes.
D3RO_API_TOKEN=
# Offline Ed25519 license keys. Keep the private key on the admin server only.
# The matching public key is committed at apps/desktop/resources/license/production-public.pem.
# Multiline private PEM values may use escaped \n characters. Legacy fixture keys are development-only.
ADMIN_LICENSE_PRIVATE_KEY=
D3RO_ALLOW_LEGACY_DEV_LICENSES=false
# Supabase
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

View file

@ -16,10 +16,28 @@ module.exports = {
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
ignorePatterns: ['**/next-env.d.ts', '**/.next/**', '**/out/**', '**/dist/**'],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'error'
}
},
overrides: [
{
files: ['apps/desktop/src/main/**/*.ts'],
rules: {
'@typescript-eslint/no-require-imports': 'off'
}
},
{
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', 'apps/desktop/tests/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-console': 'off',
'require-yield': 'off'
}
}
]
}

View file

@ -2,8 +2,6 @@ name: deploy-site-windows
on:
workflow_dispatch:
push:
branches: [main]
jobs:
deploy-win:
@ -21,11 +19,10 @@ jobs:
git checkout -q -f FETCH_HEAD
git clean -qfdx
- name: 사이트 빌드 & 바이너리 동기화
- name: 사이트 빌드
run: |
npm install --prefix site
npm ci --prefix site
npm run build --prefix site
node scripts/ci/sync-and-publish-forgejo-release.mjs
- name: Cloudflare Pages 배포
env:

View file

@ -3,9 +3,6 @@ name: deploy-site
on:
push:
branches: [main]
tags: ["v*"]
release:
types: [published]
workflow_dispatch:
jobs:
@ -26,9 +23,9 @@ jobs:
- name: 의존성 설치 및 사이트 빌드
run: |
npm ci --prefix site || npm install --prefix site
npm ci --prefix site
npm run build --prefix site
node scripts/ci/sync-and-publish-forgejo-release.mjs || true
node -e "const fs=require('node:fs'); const v=require('./release/product-version.json').version; fs.writeFileSync('site/dist/release-identity.json', JSON.stringify({commit:process.env.GITHUB_SHA,version:v},null,2)+'\n')"
- name: Cloudflare Pages 배포 (d3ro.chanpaca.net)
env:
@ -46,5 +43,17 @@ jobs:
--branch main \
--commit-dirty=true
else
echo "CLOUDFLARE_API_TOKEN 없음"
echo "CLOUDFLARE_API_TOKEN 없음" >&2
exit 1
fi
- name: 공개 배포 식별자 검증
run: |
curl --fail --silent --show-error --retry 6 --retry-delay 5 \
https://d3ro.chanpaca.net/release-identity.json \
--output release-identity.live.json
node -e "const fs=require('node:fs'); const live=JSON.parse(fs.readFileSync('release-identity.live.json','utf8')); if(live.commit!==process.env.GITHUB_SHA) throw new Error('public commit mismatch'); const expected=require('./release/product-version.json').version; if(live.version!==expected) throw new Error('public version mismatch')"
curl --fail --silent --show-error https://d3ro.chanpaca.net/ --output /dev/null
curl --fail --silent --show-error https://d3ro.chanpaca.net/privacy/ --output /dev/null
curl --fail --silent --show-error https://d3ro.chanpaca.net/terms/ --output /dev/null
curl --fail --silent --show-error https://d3ro.chanpaca.net/delete-account/ --output /dev/null

View file

@ -1,9 +1,6 @@
name: Build macOS
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
notarize:

View file

@ -30,22 +30,80 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js 20 LTS
uses: actions/setup-node@v4
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version: 20
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Credential Scanner Self-Test
run: npm run security:secrets:test
- name: Hard-Coded Credential Scan
run: npm run security:secrets
- name: Mobile Release Boundary Self-Test
run: npm run release:mobile:boundary:test
- name: Mobile Release Configuration Self-Test
run: npm run release:mobile:config:test
- name: Mobile Build Configuration Self-Test
run: npm run release:mobile:build-config:test
- name: Play Store Asset Contract
run: npm run release:play:assets
- name: Lint Check
run: npm run lint
continue-on-error: true
- name: Typecheck All Workspaces
run: npm run typecheck
api-server-tests:
name: .NET API Server Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.302'
- name: Restore API Test Dependencies
run: dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
- name: Run API Authorization and Gateway Tests
run: dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
edge-functions-quality:
name: Supabase Edge Functions Typecheck & Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Deno 2.8.1
uses: denoland/setup-deno@v2
with:
deno-version: v2.8.1
- name: Check Every Edge Function Entrypoint
shell: bash
run: |
set -euo pipefail
for entrypoint in server/supabase/functions/*/index.ts; do
deno check --config server/supabase/functions/deno.json "$entrypoint"
done
- name: Run Edge Function Contract Tests
run: deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
# ──────────────────────────────────────────────────────────────────
# 2. Automated Test Matrix (Windows / macOS / Ubuntu)
# ──────────────────────────────────────────────────────────────────
@ -61,10 +119,10 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js 20 LTS
uses: actions/setup-node@v4
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version: 20
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install Dependencies
@ -93,10 +151,10 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js 20 LTS
uses: actions/setup-node@v4
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version: 20
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install Dependencies
@ -104,3 +162,162 @@ jobs:
- name: Build Target Workspace
run: ${{ matrix.cmd }}
# ──────────────────────────────────────────────────────────────────
# 4. Android x86_64 artifacts and native dependency gate
# ──────────────────────────────────────────────────────────────────
mobile-android:
name: Mobile Android (universal debug + bundled universal E2E)
needs: code-quality
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v4
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-provider: basic
- name: Install Dependencies
run: |
npm ci
npm --prefix apps/mobile-rn ci --workspaces=false
- name: Prepare Verified Whisper Model
run: node scripts/ci/prepare-whisper-model.mjs
- name: Test Mobile TypeScript and Jest
run: |
npm --prefix apps/mobile-rn run lint
npm --prefix apps/mobile-rn run typecheck
npm --prefix apps/mobile-rn run test:ci
- name: Build Universal Debug, CSPRNG Test, and Bundled Universal E2E APKs
working-directory: apps/mobile-rn/android
env:
D3RO_VERSION_NAME: 0.0.0-e2e.${{ github.run_number }}
D3RO_VERSION_CODE: ${{ github.run_number }}
run: ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64 --no-daemon
- name: Verify BuildConfig and APK Runtime Contracts
env:
D3RO_VERSION_NAME: 0.0.0-e2e.${{ github.run_number }}
D3RO_VERSION_CODE: ${{ github.run_number }}
run: |
set -euo pipefail
DEBUG_APK=apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk
E2E_APK=apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
test -f "$DEBUG_APK"
test -f "$E2E_APK"
node scripts/ci/verify-mobile-build-config.mjs debug \
| tee apps/mobile-rn/android/app/build/outputs/debug-build-config.json
node scripts/ci/verify-mobile-build-config.mjs e2e \
| tee apps/mobile-rn/android/app/build/outputs/e2e-build-config.json
node scripts/ci/verify-android-artifact.mjs --mode debug --apk "$DEBUG_APK" \
| tee apps/mobile-rn/android/app/build/outputs/debug-artifact-evidence.json
node scripts/ci/verify-android-artifact.mjs \
--mode e2e \
--apk "$E2E_APK" \
--expected-version-name "$D3RO_VERSION_NAME" \
--expected-version-code "$D3RO_VERSION_CODE" \
| tee apps/mobile-rn/android/app/build/outputs/e2e-artifact-evidence.json
node scripts/ci/verify-android-app-links.mjs \
| tee apps/mobile-rn/android/app/build/outputs/app-links-evidence.json
sha256sum "$DEBUG_APK" "$E2E_APK" | tee apps/mobile-rn/android/app/build/outputs/android-ci.sha256
- name: Upload Universal Android Artifacts
uses: actions/upload-artifact@v4
with:
name: d3ro-mobile-android-universal-e2e
path: |
apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk
apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
apps/mobile-rn/android/app/build/outputs/android-ci.sha256
apps/mobile-rn/android/app/build/outputs/*-build-config.json
apps/mobile-rn/android/app/build/outputs/*-artifact-evidence.json
apps/mobile-rn/android/app/build/outputs/app-links-evidence.json
if-no-files-found: error
# ──────────────────────────────────────────────────────────────────
# 5. Installed bundled APK on a clean API 35 x86_64 emulator
# ──────────────────────────────────────────────────────────────────
mobile-emulator-e2e:
name: Mobile Emulator E2E (API 35)
needs: mobile-android
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v4
- name: Download Bundled Android Artifact
uses: actions/download-artifact@v4
with:
name: d3ro-mobile-android-universal-e2e
path: mobile-artifact
- name: Install Verified Maestro CLI 2.7.0
run: |
curl -fsSL https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.7.0/maestro.zip -o /tmp/maestro.zip
echo 'a4ccab6b604617e7aef6db4f885666056eabe5cfa32befaa3bc994041b8fcbb5 /tmp/maestro.zip' | sha256sum -c -
unzip -q /tmp/maestro.zip -d "$RUNNER_TEMP/maestro"
echo "$RUNNER_TEMP/maestro/maestro/bin" >> "$GITHUB_PATH"
- name: Run Mandatory Clean-room and Optional External-account Journeys
uses: reactivecircus/android-emulator-runner@v2
env:
MOBILE_E2E_EMAIL: ${{ secrets.MOBILE_E2E_EMAIL }}
MOBILE_E2E_PASSWORD: ${{ secrets.MOBILE_E2E_PASSWORD }}
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel_6
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: |
set -euo pipefail
DEBUG_APK="$(find mobile-artifact -path '*/apk/debug/app-debug.apk' -print -quit)"
TEST_APK="$(find mobile-artifact -name app-debug-androidTest.apk -print -quit)"
E2E_APK="$(find mobile-artifact -name app-e2e.apk -print -quit)"
test -n "$DEBUG_APK"
test -n "$TEST_APK"
test -n "$E2E_APK"
maestro --version
bash scripts/ci/run-mobile-csprng-instrumentation.sh "$DEBUG_APK" "$TEST_APK"
bash scripts/ci/run-mobile-emulator-gate.sh "$E2E_APK"
- name: Upload Emulator Evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: d3ro-mobile-emulator-e2e
path: |
apps/mobile-rn/.maestro/*.junit.xml
apps/mobile-rn/.maestro-output/
if-no-files-found: warn

View file

@ -31,6 +31,9 @@ jobs:
working-directory: site
run: npm ci
- name: Verify Mobile Release Publication Boundary
run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test
- name: Build
working-directory: site
run: npm run build

View file

@ -1,9 +1,6 @@
name: Release & Code Signing CA Pipeline
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:

View file

@ -12,27 +12,92 @@ on:
version:
description: 'Release version (e.g. 1.0.0)'
required: true
default: '1.0.0'
default: '1.1.0'
permissions:
contents: write
packages: write
jobs:
release-preflight:
name: Release Preflight
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Setup .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.302'
- name: Setup Deno 2.8.1
uses: denoland/setup-deno@v2
with:
deno-version: v2.8.1
- name: Install JavaScript Dependencies
run: |
npm ci
npm --prefix apps/mobile-rn ci --workspaces=false
- name: Verify Source, Security, Tests, and Play Assets
run: |
npm run version:check
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
node scripts/ci/sync-version.mjs --check --tag "$GITHUB_REF_NAME"
fi
npm run release:metadata:test
npm run security:secrets:test
npm run security:secrets
npm run release:mobile:boundary:test
npm run release:mobile:config:test
npm run release:mobile:build-config:test
npm run release:play:assets
npm run lint
npm run typecheck
npm test
npm --prefix apps/mobile-rn run lint
npm --prefix apps/mobile-rn run typecheck
npm --prefix apps/mobile-rn run test:ci
- name: Check and Test Every Supabase Edge Function
shell: bash
run: |
set -euo pipefail
for entrypoint in server/supabase/functions/*/index.ts; do
deno check --config server/supabase/functions/deno.json "$entrypoint"
done
deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
- name: Test .NET API Authorization and Gateway Boundaries
run: |
dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
# ──────────────────────────────────────────────────────────────────
# 1. Package Windows Installer (.exe & .blockmap & latest.yml)
# ──────────────────────────────────────────────────────────────────
package-windows:
name: Package Windows Desktop App
needs: release-preflight
runs-on: windows-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js 20 LTS
uses: actions/setup-node@v4
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version: 20
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install Dependencies
@ -40,6 +105,7 @@ jobs:
- name: Build All Workspaces
run: |
npm run version:check
npm run typecheck
npm run build --workspace=@d3ro/desktop
@ -66,15 +132,16 @@ jobs:
# ──────────────────────────────────────────────────────────────────
package-macos:
name: Package macOS Desktop App
needs: release-preflight
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js 20 LTS
uses: actions/setup-node@v4
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version: 20
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install Dependencies
@ -82,6 +149,7 @@ jobs:
- name: Build All Workspaces
run: |
npm run version:check
npm run typecheck
npm run build --workspace=@d3ro/desktop
@ -108,10 +176,267 @@ jobs:
apps/desktop/release/*/latest-mac.yml
# ──────────────────────────────────────────────────────────────────
# 3. Build & Containerize Admin Dashboard
# 3. Package signed Android APK/AAB (arm64, production-only config)
# ──────────────────────────────────────────────────────────────────
package-android:
name: Package Android Mobile App
needs: release-preflight
runs-on: ubuntu-latest
environment: mobile-production-release
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Establish Trusted Mobile Release Identity
id: android-version
shell: bash
env:
DISPATCH_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git fetch --no-tags origin main
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
test "$GITHUB_REF" = "refs/heads/main"
test "$GITHUB_SHA" = "$(git rev-parse origin/main)"
VERSION_NAME="$DISPATCH_VERSION"
RELEASE_TAG="v$VERSION_NAME"
else
[[ "$GITHUB_REF" == refs/tags/v* ]]
VERSION_NAME="${GITHUB_REF_NAME#v}"
RELEASE_TAG="$GITHUB_REF_NAME"
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
fi
SSOT_VERSION="$(node -p "require('./release/product-version.json').version")"
VERSION_CODE="$(node -p "require('./release/product-version.json').androidVersionCode")"
[[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
test "$VERSION_NAME" = "$SSOT_VERSION"
test "$RELEASE_TAG" = "v$VERSION_NAME"
[[ "$VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]]
test "$VERSION_CODE" -le 2100000000
printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT"
printf 'code=%s\n' "$VERSION_CODE" >> "$GITHUB_OUTPUT"
printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
- name: Verify Immutable Checkout Identity
shell: bash
run: |
set -euo pipefail
test "$GITHUB_SHA" = "$(git rev-parse HEAD)"
test -z "$(git status --porcelain --untracked-files=all)"
git rev-parse "${GITHUB_SHA}^{tree}"
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Verify Mobile Release Boundary Source Contract
run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test
- name: Verify Mobile Release Configuration Contract
run: npm run release:mobile:config:test
- name: Verify Mobile Build Configuration Contract
run: npm run release:mobile:build-config:test
- name: Verify Play Store Asset Contract
run: npm run release:play:assets
- name: Require Restricted AAB Handoff Visibility
shell: bash
env:
REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
run: |
set -euo pipefail
test "$REPOSITORY_VISIBILITY" = "private"
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v4
- name: Install Pinned Official Bundletool
shell: bash
run: |
set -euo pipefail
curl --fail --silent --show-error --location \
--output "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \
https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
printf '%s %s\n' \
a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 \
"$RUNNER_TEMP/bundletool-all-1.18.3.jar" | sha256sum --check --strict
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-provider: basic
- name: Install Dependencies
run: |
npm ci
npm --prefix apps/mobile-rn ci --workspaces=false
- name: Materialize Release-only Configuration
shell: bash
env:
ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }}
run: |
set -euo pipefail
umask 077
test -n "$ANDROID_RELEASE_KEYSTORE_B64"
test -n "$ANDROID_GOOGLE_SERVICES_JSON_B64"
printf '%s' "$ANDROID_RELEASE_KEYSTORE_B64" | base64 --decode > apps/mobile-rn/android/app/release.keystore
printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_B64" | base64 --decode > apps/mobile-rn/android/app/google-services.json
test -s apps/mobile-rn/android/app/release.keystore
test -s apps/mobile-rn/android/app/google-services.json
- name: Prepare Verified Whisper Model
run: node scripts/ci/prepare-whisper-model.mjs
- name: Verify Production Firebase and AdMob Configuration
env:
D3RO_FIREBASE_EXPECTED_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER: ${{ secrets.FIREBASE_PROJECT_NUMBER }}
D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID: ${{ secrets.FIREBASE_MOBILESDK_APP_ID }}
run: |
export D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")"
export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")"
export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")"
npm run release:mobile:config
- name: Test Mobile TypeScript and Jest
run: |
npm --prefix apps/mobile-rn run lint
npm --prefix apps/mobile-rn run typecheck
npm --prefix apps/mobile-rn run test:ci
- name: Build Signed arm64 APK and AAB
working-directory: apps/mobile-rn/android
env:
D3RO_RELEASE_STORE_FILE: ${{ github.workspace }}/apps/mobile-rn/android/app/release.keystore
D3RO_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
D3RO_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
D3RO_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }}
D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }}
run: |
export D3RO_ADMOB_APP_ID="$(node -p "require('../../../release/android-release-identity.json').adMobAppId")"
export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobBannerUnitId")"
export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobRewardedUnitId")"
./gradlew :app:assembleRelease :app:bundleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon
- name: Materialize Release Evidence Signing Key
shell: bash
env:
ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64: ${{ secrets.ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64 }}
run: |
set -euo pipefail
umask 077
test -n "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64"
printf '%s' "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64" | base64 --decode > apps/mobile-rn/android/app/release-evidence-private.pem
test -s apps/mobile-rn/android/app/release-evidence-private.pem
- name: Verify Release BuildConfig, Signature, ABI, Bundle, Ads, and Offline Model
shell: bash
env:
D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }}
D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }}
run: |
set -euo pipefail
D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")"
D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")"
D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")"
ANDROID_UPLOAD_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').uploadCertificateSha256")"
PLAY_APP_SIGNING_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').playAppSigningCertificateSha256")"
APK=apps/mobile-rn/android/app/build/outputs/apk/release/app-release.apk
AAB=apps/mobile-rn/android/app/build/outputs/bundle/release/app-release.aab
test -f "$APK"
test -f "$AAB"
node scripts/ci/verify-mobile-build-config.mjs release \
| tee apps/mobile-rn/android/app/build/outputs/release-build-config.json
node scripts/ci/create-mobile-release-evidence.mjs \
--apk "$APK" \
--aab "$AAB" \
--bundletool "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \
--repository "$GITHUB_REPOSITORY" \
--commit-sha "$GITHUB_SHA" \
--tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \
--git-ref "$GITHUB_REF" \
--workflow-identity "$GITHUB_WORKFLOW_REF" \
--run-id "$GITHUB_RUN_ID" \
--run-attempt "$GITHUB_RUN_ATTEMPT" \
--runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
--expected-version-name "$D3RO_VERSION_NAME" \
--expected-version-code "$D3RO_VERSION_CODE" \
--private-key apps/mobile-rn/android/app/release-evidence-private.pem \
--snapshot-dir apps/mobile-rn/android/app/build/outputs/release-snapshot
VERIFIER_SHA256="$(sha256sum scripts/ci/verify-android-artifact.mjs | awk '{print $1}')"
BUNDLETOOL_SHA256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"
node scripts/ci/prepare-mobile-release-publication.mjs \
--source-root apps/mobile-rn/android/app/build/outputs/release-snapshot \
--apk apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.apk \
--aab apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.aab \
--evidence apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-evidence.json \
--public-key release/mobile-release-evidence-public.pem \
--destination-dir apps/mobile-rn/android/app/build/outputs/release-publication \
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
--expected-version-name "$D3RO_VERSION_NAME" \
--expected-version-code "$D3RO_VERSION_CODE" \
--expected-repository "$GITHUB_REPOSITORY" \
--expected-commit-sha "$GITHUB_SHA" \
--expected-tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \
--expected-git-ref "$GITHUB_REF" \
--expected-workflow-identity "$GITHUB_WORKFLOW_REF" \
--expected-run-id "$GITHUB_RUN_ID" \
--expected-run-attempt "$GITHUB_RUN_ATTEMPT" \
--expected-runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \
--expected-verifier-sha256 "$VERIFIER_SHA256" \
--expected-bundletool-sha256 "$BUNDLETOOL_SHA256"
node scripts/ci/verify-android-app-links.mjs \
--expected-play-app-signing-cert-sha256 "$PLAY_APP_SIGNING_CERT_SHA256" \
--forbidden-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
| tee apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
sha256sum apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \
| tee apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
- name: Remove Materialized Release Secrets
if: always()
shell: bash
run: rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem
- name: Upload Restricted Play Console AAB Handoff
uses: actions/upload-artifact@v4
with:
name: android-play-console-handoff
path: |
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab
apps/mobile-rn/android/app/build/outputs/release-publication/android-release-evidence.json
apps/mobile-rn/android/app/build/outputs/release-publication/android-publication-manifest.json
apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-verification.json
apps/mobile-rn/android/app/build/outputs/release-build-config.json
apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
retention-days: 7
if-no-files-found: error
# ──────────────────────────────────────────────────────────────────
# 4. Build & Containerize Admin Dashboard
# ──────────────────────────────────────────────────────────────────
package-admin-docker:
name: Build & Publish Admin Docker Image
needs: release-preflight
runs-on: ubuntu-latest
steps:
- name: Checkout Code
@ -137,21 +462,54 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.admin
file: ./apps/admin/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# ──────────────────────────────────────────────────────────────────
# 4. Create GitHub Release & Upload Checksums
# 5. Create GitHub Release & Upload Checksums
# ──────────────────────────────────────────────────────────────────
publish-release:
name: Publish Official GitHub Release
needs: [package-windows, package-macos, package-admin-docker]
needs: [package-windows, package-macos, package-android, package-admin-docker]
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Re-establish Trusted Release Identity
id: release-identity
shell: bash
env:
DISPATCH_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git fetch --no-tags origin main
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
test "$GITHUB_REF" = "refs/heads/main"
test "$GITHUB_SHA" = "$(git rev-parse origin/main)"
VERSION_NAME="$DISPATCH_VERSION"
RELEASE_TAG="v$VERSION_NAME"
else
[[ "$GITHUB_REF" == refs/tags/v* ]]
VERSION_NAME="${GITHUB_REF_NAME#v}"
RELEASE_TAG="$GITHUB_REF_NAME"
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
fi
SSOT_VERSION="$(node -p "require('./release/product-version.json').version")"
[[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
test "$VERSION_NAME" = "$SSOT_VERSION"
test "$RELEASE_TAG" = "v$VERSION_NAME"
printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT"
printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
- name: Setup Node.js 24
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Download Windows Artifacts
uses: actions/download-artifact@v4
@ -167,9 +525,17 @@ jobs:
- name: Generate SHA-256 Checksums
run: |
cd release-dist
sha256sum * > SHA256SUMS.txt || shasum -a 256 * > SHA256SUMS.txt
cat SHA256SUMS.txt
set -euo pipefail
find release-dist -type f ! -name SHA256SUMS.txt -print0 \
| sort -z \
| xargs -0 sha256sum > release-dist/SHA256SUMS.txt
cat release-dist/SHA256SUMS.txt
- name: Extract Canonical Release Notes
run: >-
node scripts/ci/extract-release-notes.mjs
--version "${{ steps.release-identity.outputs.name }}"
--output release-notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@ -178,6 +544,10 @@ jobs:
release-dist/*
draft: false
prerelease: false
generate_release_notes: true
body_path: release-notes.md
generate_release_notes: false
tag_name: ${{ steps.release-identity.outputs.tag }}
fail_on_unmatched_files: true
overwrite_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

20
.gitignore vendored
View file

@ -5,13 +5,19 @@ out/
.expo/
.env
.env.*
!.env.example
*.log
.DS_Store
Thumbs.db
.claude/settings.local.json
# Electron
# Electron release artifacts. Keep only release identity/public-key SSOT files.
release/
!/release/
/release/*
!/release/product-version.json
!/release/android-release-identity.json
!/release/mobile-release-evidence-public.pem
*.unpacked
# Build
@ -52,3 +58,15 @@ venv/
# Whisper models (large files)
resources/models/
apps/mobile-rn/android/app/src/main/assets/models/ggml-tiny.bin
apps/mobile-rn/android/app/src/main/assets/index.android.bundle
apps/mobile-rn/.maestro-output/
apps/mobile-rn/.maestro/*.junit.xml
# Chrome / Playwright Profiles
.chrome-*
.playwright-*
.chrome-debug-profile/
.chrome-interactive-profile/
.chrome-playwright-profile/
.playwright-oauth-profile/

View file

@ -5,16 +5,19 @@ stages:
- validate
- test
- build
- e2e
- package
- publish
- deploy
variables:
NODE_VERSION: "20"
NODE_VERSION: "24.19.0"
PACKAGE_NAME: "d3ro-voice"
default:
image: node:20-bookworm
image: node:24.19.0-bookworm
tags:
- build-linux-x64
before_script:
- npm ci
@ -24,7 +27,16 @@ default:
lint-and-typecheck:
stage: validate
script:
- npm run lint || true
- npm run version:check
- if [ -n "${CI_COMMIT_TAG:-}" ]; then node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"; fi
- npm run release:metadata:test
- npm run security:secrets:test
- npm run security:secrets
- npm run release:mobile:boundary:test
- npm run release:mobile:config:test
- npm run release:mobile:build-config:test
- npm run release:play:assets
- npm run lint
- npm run typecheck
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
@ -48,6 +60,125 @@ test-unit:
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_TAG'
api-server-tests:
stage: test
image: mcr.microsoft.com/dotnet/sdk:10.0.302-noble
before_script: []
script:
- dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
- dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_TAG'
edge-functions-quality:
stage: test
image: denoland/deno:2.8.1
before_script: []
script:
- for entrypoint in server/supabase/functions/*/index.ts; do deno check --config server/supabase/functions/deno.json "$entrypoint"; done
- deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_TAG'
mobile-quality:
stage: test
before_script:
- npm ci
- npm --prefix apps/mobile-rn ci --workspaces=false
script:
- npm --prefix apps/mobile-rn run lint
- npm --prefix apps/mobile-rn run typecheck
- npm --prefix apps/mobile-rn run test:ci
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_TAG'
mobile-android:
stage: build
image: reactnativecommunity/react-native-android@sha256:24ca7ab5a70ec0b78a81bdc5eeea5924c2531531d53971b6f2321aff08446c36
needs:
- mobile-quality
before_script:
- export ANDROID_HOME=/opt/android
- export ANDROID_SDK_ROOT=/opt/android
- curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
- echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
- mkdir -p /tmp/node24
- tar -xJf /tmp/node.tar.xz -C /tmp/node24 --strip-components=1
- export PATH="/tmp/node24/bin:$PATH"
- node --version | grep -Fx "v${NODE_VERSION}"
- export D3RO_VERSION_NAME="0.0.0-e2e.${CI_PIPELINE_IID}"
- export D3RO_VERSION_CODE="${CI_PIPELINE_IID}"
- sdkmanager "platforms;android-36" "build-tools;36.0.0" >/dev/null
- npm ci
- npm --prefix apps/mobile-rn ci --workspaces=false
script:
- node scripts/ci/prepare-whisper-model.mjs
- cd apps/mobile-rn/android
- ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64 --no-daemon
- cd "$CI_PROJECT_DIR"
- node scripts/ci/verify-mobile-build-config.mjs debug > apps/mobile-rn/android/app/build/outputs/debug-build-config.json
- node scripts/ci/verify-mobile-build-config.mjs e2e > apps/mobile-rn/android/app/build/outputs/e2e-build-config.json
- node scripts/ci/verify-android-artifact.mjs --mode debug --apk apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk > apps/mobile-rn/android/app/build/outputs/debug-artifact-evidence.json
- node scripts/ci/verify-android-artifact.mjs --mode e2e --apk apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk --expected-version-name "$D3RO_VERSION_NAME" --expected-version-code "$D3RO_VERSION_CODE" > apps/mobile-rn/android/app/build/outputs/e2e-artifact-evidence.json
- sha256sum apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk > apps/mobile-rn/android/app/build/outputs/android-ci.sha256
artifacts:
when: always
expire_in: 1 day
paths:
- apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk
- apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
- apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
- apps/mobile-rn/android/app/build/outputs/android-ci.sha256
- apps/mobile-rn/android/app/build/outputs/*-build-config.json
- apps/mobile-rn/android/app/build/outputs/*-artifact-evidence.json
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_TAG'
mobile-emulator-e2e:
stage: e2e
image: reactnativecommunity/react-native-android@sha256:24ca7ab5a70ec0b78a81bdc5eeea5924c2531531d53971b6f2321aff08446c36
tags:
- android-emulator
- kvm
needs:
- job: mobile-android
artifacts: true
before_script: []
script:
- export ANDROID_HOME=/opt/android
- export ANDROID_SDK_ROOT=/opt/android
- sdkmanager "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;x86_64" >/dev/null
- echo no | avdmanager create avd --force --name d3ro_ci_api35 --package "system-images;android-35;google_apis;x86_64"
- emulator -avd d3ro_ci_api35 -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect -camera-back none &
- adb wait-for-device
- timeout 180 bash -c 'until [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d "\r")" == "1" ]]; do sleep 2; done'
- curl -fsSL https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.7.0/maestro.zip -o /tmp/maestro.zip
- echo 'a4ccab6b604617e7aef6db4f885666056eabe5cfa32befaa3bc994041b8fcbb5 /tmp/maestro.zip' | sha256sum -c -
- unzip -q /tmp/maestro.zip -d /tmp/maestro
- export PATH="/tmp/maestro/maestro/bin:$PATH"
- bash scripts/ci/run-mobile-csprng-instrumentation.sh apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
- bash scripts/ci/run-mobile-emulator-gate.sh apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
artifacts:
when: always
expire_in: 7 days
reports:
junit: apps/mobile-rn/.maestro/*.junit.xml
paths:
- apps/mobile-rn/.maestro/*.junit.xml
- apps/mobile-rn/.maestro-output/
rules:
- if: '$D3RO_ANDROID_EMULATOR_RUNNER == "true" && $CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$D3RO_ANDROID_EMULATOR_RUNNER == "true" && ($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop")'
- if: '$D3RO_ANDROID_EMULATOR_RUNNER == "true" && $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
# ────────────────────────────────────────────────────────────────────
# Build Workspaces
# ────────────────────────────────────────────────────────────────────
@ -71,11 +202,11 @@ build-workspaces:
package-windows:
stage: package
tags:
- windows
- electron
- build-win-x64
before_script:
- npm ci
script:
- node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"
- npm run build --workspace=@d3ro/desktop
- cd apps/desktop
- npx electron-builder --win --x64 --config electron-builder.yml
@ -95,11 +226,11 @@ package-windows:
package-macos:
stage: package
tags:
- macos
- arm64
- build-mac-arm64
before_script:
- npm ci
script:
- node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"
- npm run build --workspace=@d3ro/desktop
- cd apps/desktop
- npx electron-builder --mac --arm64 --config electron-builder.yml
@ -114,35 +245,196 @@ package-macos:
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
# Protected, manually approved Play Console handoff. Configure the three
# *_FILE variables below as protected GitLab file variables scoped to the
# mobile-production-release environment. All other credentials must be
# protected, masked, and hidden project/group variables.
mobile-production-release:
stage: package
image: reactnativecommunity/react-native-android@sha256:24ca7ab5a70ec0b78a81bdc5eeea5924c2531531d53971b6f2321aff08446c36
needs:
- job: lint-and-typecheck
artifacts: false
- job: test-unit
artifacts: false
- job: api-server-tests
artifacts: false
- job: edge-functions-quality
artifacts: false
- job: mobile-quality
artifacts: false
- job: mobile-android
artifacts: false
- job: mobile-emulator-e2e
artifacts: false
environment:
name: mobile-production-release
action: prepare
before_script: []
script:
- |
set -euo pipefail
export ANDROID_HOME=/opt/android
export ANDROID_SDK_ROOT=/opt/android
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
mkdir -p /tmp/node24
tar -xJf /tmp/node.tar.xz -C /tmp/node24 --strip-components=1
export PATH="/tmp/node24/bin:$PATH"
node --version | grep -Fx "v${NODE_VERSION}"
git fetch --no-tags origin main
git merge-base --is-ancestor "$CI_COMMIT_SHA" origin/main
test "$CI_COMMIT_SHA" = "$(git rev-parse HEAD)"
test -z "$(git status --porcelain --untracked-files=all)"
SOURCE_TREE_SHA="$(git rev-parse "${CI_COMMIT_SHA}^{tree}")"
VERSION_NAME="$(node -p "require('./release/product-version.json').version")"
VERSION_CODE="$(node -p "require('./release/product-version.json').androidVersionCode")"
ANDROID_UPLOAD_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').uploadCertificateSha256")"
ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")"
ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")"
ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")"
test "$CI_COMMIT_TAG" = "v${VERSION_NAME}"
echo "$VERSION_NAME" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
echo "$VERSION_CODE" | grep -Eq '^[1-9][0-9]{0,9}$'
test "$VERSION_CODE" -le 2100000000
test -s "$ANDROID_RELEASE_KEYSTORE_FILE"
test -s "$ANDROID_GOOGLE_SERVICES_JSON_FILE"
test -s "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_FILE"
install -m 600 "$ANDROID_RELEASE_KEYSTORE_FILE" apps/mobile-rn/android/app/release.keystore
install -m 600 "$ANDROID_GOOGLE_SERVICES_JSON_FILE" apps/mobile-rn/android/app/google-services.json
install -m 600 "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_FILE" apps/mobile-rn/android/app/release-evidence-private.pem
test -n "$ANDROID_RELEASE_STORE_PASSWORD"
test -n "$ANDROID_RELEASE_KEY_ALIAS"
test -n "$ANDROID_RELEASE_KEY_PASSWORD"
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_ID"
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER"
test -n "$D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID"
sdkmanager "platforms;android-36" "build-tools;36.0.0" >/dev/null
npm ci
npm --prefix apps/mobile-rn ci --workspaces=false
npm run security:secrets:test
npm run security:secrets
npm run release:mobile:boundary:test
npm run release:mobile:config:test
npm run release:mobile:build-config:test
npm run release:play:assets
npm --prefix apps/mobile-rn run lint
npm --prefix apps/mobile-rn run typecheck
npm --prefix apps/mobile-rn run test:ci
node scripts/ci/prepare-whisper-model.mjs
curl --fail --silent --show-error --location --output /tmp/bundletool.jar https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
echo 'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 /tmp/bundletool.jar' | sha256sum -c -
export D3RO_RELEASE_STORE_FILE="$CI_PROJECT_DIR/apps/mobile-rn/android/app/release.keystore"
export D3RO_RELEASE_STORE_PASSWORD="$ANDROID_RELEASE_STORE_PASSWORD"
export D3RO_RELEASE_KEY_ALIAS="$ANDROID_RELEASE_KEY_ALIAS"
export D3RO_RELEASE_KEY_PASSWORD="$ANDROID_RELEASE_KEY_PASSWORD"
export D3RO_ADMOB_APP_ID="$ADMOB_APP_ID"
export D3RO_ADMOB_BANNER_UNIT_ID="$ADMOB_BANNER_UNIT_ID"
export D3RO_ADMOB_REWARDED_UNIT_ID="$ADMOB_REWARDED_UNIT_ID"
export D3RO_VERSION_NAME="$VERSION_NAME"
export D3RO_VERSION_CODE="$VERSION_CODE"
PLAY_APP_SIGNING_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').playAppSigningCertificateSha256")"
npm run release:mobile:config
cd apps/mobile-rn/android
./gradlew :app:assembleRelease :app:bundleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon
cd "$CI_PROJECT_DIR"
APK=apps/mobile-rn/android/app/build/outputs/apk/release/app-release.apk
AAB=apps/mobile-rn/android/app/build/outputs/bundle/release/app-release.aab
test -f "$APK"
test -f "$AAB"
node scripts/ci/verify-mobile-build-config.mjs release > apps/mobile-rn/android/app/build/outputs/release-build-config.json
node scripts/ci/create-mobile-release-evidence.mjs \
--apk "$APK" \
--aab "$AAB" \
--bundletool /tmp/bundletool.jar \
--repository "$CI_PROJECT_PATH" \
--commit-sha "$CI_COMMIT_SHA" \
--tree-sha "$SOURCE_TREE_SHA" \
--git-ref "refs/tags/$CI_COMMIT_TAG" \
--workflow-identity "gitlab-ci/mobile-production-release" \
--run-id "$CI_JOB_ID" \
--run-attempt "1" \
--runner-identity "$CI_RUNNER_ID:$CI_RUNNER_REVISION" \
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
--expected-version-name "$D3RO_VERSION_NAME" \
--expected-version-code "$D3RO_VERSION_CODE" \
--private-key apps/mobile-rn/android/app/release-evidence-private.pem \
--snapshot-dir apps/mobile-rn/android/app/build/outputs/release-snapshot
VERIFIER_SHA256="$(sha256sum scripts/ci/verify-android-artifact.mjs | awk '{print $1}')"
BUNDLETOOL_SHA256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"
node scripts/ci/prepare-mobile-release-publication.mjs \
--source-root apps/mobile-rn/android/app/build/outputs/release-snapshot \
--apk apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.apk \
--aab apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.aab \
--evidence apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-evidence.json \
--public-key release/mobile-release-evidence-public.pem \
--destination-dir apps/mobile-rn/android/app/build/outputs/release-publication \
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
--expected-version-name "$D3RO_VERSION_NAME" \
--expected-version-code "$D3RO_VERSION_CODE" \
--expected-repository "$CI_PROJECT_PATH" \
--expected-commit-sha "$CI_COMMIT_SHA" \
--expected-tree-sha "$SOURCE_TREE_SHA" \
--expected-git-ref "refs/tags/$CI_COMMIT_TAG" \
--expected-workflow-identity "gitlab-ci/mobile-production-release" \
--expected-run-id "$CI_JOB_ID" \
--expected-run-attempt "1" \
--expected-runner-identity "$CI_RUNNER_ID:$CI_RUNNER_REVISION" \
--expected-verifier-sha256 "$VERIFIER_SHA256" \
--expected-bundletool-sha256 "$BUNDLETOOL_SHA256"
node scripts/ci/verify-android-app-links.mjs \
--expected-play-app-signing-cert-sha256 "$PLAY_APP_SIGNING_CERT_SHA256" \
--forbidden-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
> apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
sha256sum \
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \
> apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
after_script:
- rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem
artifacts:
access: maintainer
expire_in: 7 days
paths:
- apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab
- apps/mobile-rn/android/app/build/outputs/release-publication/android-release-evidence.json
- apps/mobile-rn/android/app/build/outputs/release-publication/android-publication-manifest.json
- apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
- apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-verification.json
- apps/mobile-rn/android/app/build/outputs/release-build-config.json
- apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
manual_confirmation: '검증된 production AAB를 생성해 Maintainer 전용 Play Console handoff로 보낼까요?'
rules:
- if: '$D3RO_MOBILE_PRODUCTION_RELEASE_ENABLED == "true" && $D3RO_ANDROID_EMULATOR_RUNNER == "true" && $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
when: manual
allow_failure: false
- when: never
# ────────────────────────────────────────────────────────────────────
# Publish Release (GitLab Package Registry + Release Page)
# ────────────────────────────────────────────────────────────────────
publish-release:
stage: publish
image: node:20-bookworm
image: node:24.19.0-bookworm
needs:
- job: package-windows
artifacts: true
- job: package-macos
artifacts: true
optional: true
- job: mobile-production-release
artifacts: false
optional: true
script:
- node scripts/ci/publish-gitlab-release.mjs
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
# ────────────────────────────────────────────────────────────────────
# Deploy Admin Dashboard to NAS / Production Server
# Admin NAS deployment remains disabled until an immutable image, authenticated
# remote target, health check, and rollback path are configured. Do not revive
# the former DinD job: it only changed an ephemeral CI daemon and referenced a
# non-existent compose file, so it never deployed the NAS.
# ────────────────────────────────────────────────────────────────────
deploy-admin-nas:
stage: deploy
image: docker:24-cli
services:
- docker:24-dind
before_script:
- echo "$DOCKER_REGISTRY_PASSWORD" | docker login -u "$DOCKER_REGISTRY_USER" --password-stdin
script:
- docker build -t d3ro-voice-admin:latest -f Dockerfile.admin .
- docker compose -f docker-compose.prod.yml up -d admin
rules:
- if: '$CI_COMMIT_BRANCH == "main"'

2
.nvmrc
View file

@ -1 +1 @@
20
24.19.0

View file

@ -13,6 +13,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud-optional backup (encrypted, opt-in)
- Plugin system for custom pipelines
## [1.1.0] - 2026-08-29
### Added
- **Android/iOS product mobile app**: implemented account bootstrap and lifecycle, onboarding, recording and audio import, transcription, history, meetings, templates, memos, teams and invitations, notifications, knowledge, AI Talk, generated documents, data portability, and role-aware administration flows.
- **Mobile-native recording and intake**: added Android foreground recording with persistent controls and recovery, external `ACTION_SEND` audio/video intake, durable upload processing, and supported-device on-device Whisper transcription.
- **Server-verified mobile monetization**: added Google Play purchase and restore verification, entitlement reconciliation, AdMob rewarded SSV validation, and replay-safe reward receipts.
- **Generated-content safety controls**: added generation receipts, shared generative-AI safety instructions, and an authenticated report flow for owned AI-generated meeting documents.
- **Release verification tooling**: added Android artifact, App Links, production Firebase/AdMob configuration, Play asset, secret scanning, signed provenance, and publication-boundary checks.
- **Complete mobile icon set**: added canonical Android legacy/adaptive/monochrome launchers, the 512px Play icon, and all required iPhone, iPad, and App Store marketing icon slots.
### Changed
- Unified mobile authentication and invitation links on the canonical `d3ro-voice` app scheme and added fail-closed verification for the HTTPS App Links contract.
- Hardened Supabase and mobile production configuration to fail closed when required public configuration is absent or does not match the release identity.
- Expanded CI coverage across Node.js 24, .NET 10, Deno Edge Functions, React Native tests, Android emulator gates, 16 KB page-size compatibility, and reproducible release evidence.
- Aligned account deletion guidance with external subscription cancellation requirements and centralized local sensitive-data purge on logout, account deletion, and session loss.
- Centralized product version `1.1.0`, Android version code `1010001`, release notes, updater metadata, and app-store changelogs under release SSOT checks.
### Security
- Added atomic authorization and replay protection for teams, invitations, push delivery, transcription quotas, billing, ad rewards, administrative actions, data portability, and content reports.
- Removed synthetic success fallbacks from protected STT and AI paths; provider and configuration failures now return explicit failures without creating fabricated user data.
- Strengthened generated-document reporting so only the owner can report an existing document backed by immutable generation audit evidence.
- Removed the repository-exposed desktop license private key and prefix-only paid-tier activation; production now accepts only Ed25519 licenses signed by the rotated external key.
### Fixed
- Corrected realtime Edge Function model routing and type checks for team and enterprise tiers.
- Removed the legacy `d3ro://` deep-link surface to prevent divergent authentication callback identities.
## [0.2.1-alpha] - 2026-07-22
### Fixed
@ -64,7 +91,7 @@ Midnight Glass v2 전면 재설계 + refactor-wave 대규모 코드 품질 정
---
## [1.0.0] - 2026-04-06
## [1.0.0] - 2026-08-20
### Added

View file

@ -1,7 +1,7 @@
# Dockerfile.admin
# Multi-stage production build for @d3ro/admin Next.js App
FROM node:20-alpine AS deps
FROM node:24.19.0-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
@ -11,7 +11,7 @@ COPY apps/desktop/package.json ./apps/desktop/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN npm ci
FROM node:20-alpine AS builder
FROM node:24.19.0-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages ./packages
@ -23,7 +23,7 @@ ENV NODE_ENV production
RUN npm run build --workspace=@d3ro/admin
FROM node:20-alpine AS runner
FROM node:24.19.0-alpine AS runner
WORKDIR /app
ENV NODE_ENV production

View file

@ -3,7 +3,7 @@
"info": {
"title": "D3RO-VOICE Admin API",
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
"version": "1.0.0"
"version": "1.1.0"
},
"servers": [
{
@ -38,52 +38,159 @@
"Profile": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string", "nullable": true },
"avatar_url": { "type": "string", "nullable": true },
"locale": { "type": "string" },
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
"role": { "type": "string", "enum": ["user", "admin", "super_admin"] },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"nullable": true
},
"avatar_url": {
"type": "string",
"nullable": true
},
"locale": {
"type": "string"
},
"tier": {
"type": "string",
"enum": [
"free",
"pro",
"pro_plus"
]
},
"role": {
"type": "string",
"enum": [
"user",
"admin",
"super_admin"
]
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"Subscription": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"user_id": { "type": "string", "format": "uuid" },
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
"payment_provider": { "type": "string", "enum": ["none", "stripe", "payple"] },
"current_period_start": { "type": "string", "format": "date-time", "nullable": true },
"current_period_end": { "type": "string", "format": "date-time", "nullable": true },
"overage_credits": { "type": "integer" },
"admin_note": { "type": "string", "nullable": true },
"renewal_failures": { "type": "integer" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
"id": {
"type": "string",
"format": "uuid"
},
"user_id": {
"type": "string",
"format": "uuid"
},
"tier": {
"type": "string",
"enum": [
"free",
"pro",
"pro_plus"
]
},
"status": {
"type": "string",
"enum": [
"active",
"canceled",
"past_due",
"expired"
]
},
"payment_provider": {
"type": "string",
"enum": [
"none",
"stripe",
"payple"
]
},
"current_period_start": {
"type": "string",
"format": "date-time",
"nullable": true
},
"current_period_end": {
"type": "string",
"format": "date-time",
"nullable": true
},
"overage_credits": {
"type": "integer"
},
"admin_note": {
"type": "string",
"nullable": true
},
"renewal_failures": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"AuditLog": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"admin_id": { "type": "string", "format": "uuid" },
"admin_name": { "type": "string" },
"action": { "type": "string" },
"target_type": { "type": "string" },
"target_id": { "type": "string", "format": "uuid" },
"before_data": { "type": "object", "nullable": true },
"after_data": { "type": "object", "nullable": true },
"memo": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" }
"id": {
"type": "integer"
},
"admin_id": {
"type": "string",
"format": "uuid"
},
"admin_name": {
"type": "string"
},
"action": {
"type": "string"
},
"target_type": {
"type": "string"
},
"target_id": {
"type": "string",
"format": "uuid"
},
"before_data": {
"type": "object",
"nullable": true
},
"after_data": {
"type": "object",
"nullable": true
},
"memo": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
}
}
},
"Error": {
"type": "object",
"properties": {
"error": { "type": "string" }
"error": {
"type": "string"
}
}
}
}
@ -91,15 +198,57 @@
"paths": {
"/admin-users": {
"get": {
"tags": ["Users"],
"tags": [
"Users"
],
"summary": "List or get user details",
"description": "Admin+. Pass userId for single user detail, or omit for paginated list.",
"parameters": [
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Specific user ID for detail view" },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Name search (ilike)" },
{ "name": "role", "in": "query", "schema": { "type": "string", "enum": ["user", "admin", "super_admin"] } }
{
"name": "userId",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
},
"description": "Specific user ID for detail view"
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"default": 20
}
},
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
"description": "Name search (ilike)"
},
{
"name": "role",
"in": "query",
"schema": {
"type": "string",
"enum": [
"user",
"admin",
"super_admin"
]
}
}
],
"responses": {
"200": {
@ -111,17 +260,32 @@
{
"type": "object",
"properties": {
"profiles": { "type": "array", "items": { "$ref": "#/components/schemas/Profile" } },
"total": { "type": "integer" },
"page": { "type": "integer" },
"limit": { "type": "integer" }
"profiles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Profile"
}
},
"total": {
"type": "integer"
},
"page": {
"type": "integer"
},
"limit": {
"type": "integer"
}
}
},
{
"type": "object",
"properties": {
"profile": { "$ref": "#/components/schemas/Profile" },
"subscription": { "$ref": "#/components/schemas/Subscription" }
"profile": {
"$ref": "#/components/schemas/Profile"
},
"subscription": {
"$ref": "#/components/schemas/Subscription"
}
}
}
]
@ -129,11 +293,22 @@
}
}
},
"403": { "description": "Not admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
"403": {
"description": "Not admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"patch": {
"tags": ["Users"],
"tags": [
"Users"
],
"summary": "Change user role",
"description": "Super admin only. Changes both auth.users.app_metadata.role and profiles.role.",
"requestBody": {
@ -142,39 +317,110 @@
"application/json": {
"schema": {
"type": "object",
"required": ["userId", "newRole", "memo"],
"required": [
"userId",
"newRole",
"memo"
],
"properties": {
"userId": { "type": "string", "format": "uuid" },
"newRole": { "type": "string", "enum": ["user", "admin", "super_admin"] },
"memo": { "type": "string", "description": "Required reason for audit log" }
"userId": {
"type": "string",
"format": "uuid"
},
"newRole": {
"type": "string",
"enum": [
"user",
"admin",
"super_admin"
]
},
"memo": {
"type": "string",
"description": "Required reason for audit log"
}
}
}
}
}
},
"responses": {
"200": { "description": "Role changed successfully" },
"403": { "description": "Not super_admin" }
"200": {
"description": "Role changed successfully"
},
"403": {
"description": "Not super_admin"
}
}
}
},
"/admin-subscriptions": {
"get": {
"tags": ["Subscriptions"],
"tags": [
"Subscriptions"
],
"summary": "List or get subscription details",
"parameters": [
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" } },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] } },
{ "name": "tier", "in": "query", "schema": { "type": "string", "enum": ["free", "pro", "pro_plus"] } }
{
"name": "userId",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"default": 20
}
},
{
"name": "status",
"in": "query",
"schema": {
"type": "string",
"enum": [
"active",
"canceled",
"past_due",
"expired"
]
}
},
{
"name": "tier",
"in": "query",
"schema": {
"type": "string",
"enum": [
"free",
"pro",
"pro_plus"
]
}
}
],
"responses": {
"200": { "description": "Subscription list or detail" }
"200": {
"description": "Subscription list or detail"
}
}
},
"post": {
"tags": ["Subscriptions"],
"tags": [
"Subscriptions"
],
"summary": "Create subscription (VIP grant / record recovery)",
"description": "Super admin only.",
"requestBody": {
@ -183,30 +429,74 @@
"application/json": {
"schema": {
"type": "object",
"required": ["userId", "tier", "memo"],
"required": [
"userId",
"tier",
"memo"
],
"properties": {
"userId": { "type": "string", "format": "uuid" },
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"], "default": "active" },
"currentPeriodEnd": { "type": "string", "format": "date-time" },
"adminNote": { "type": "string" },
"memo": { "type": "string" }
"userId": {
"type": "string",
"format": "uuid"
},
"tier": {
"type": "string",
"enum": [
"free",
"pro",
"pro_plus"
]
},
"status": {
"type": "string",
"enum": [
"active",
"canceled",
"past_due",
"expired"
],
"default": "active"
},
"currentPeriodEnd": {
"type": "string",
"format": "date-time"
},
"adminNote": {
"type": "string"
},
"memo": {
"type": "string"
}
}
}
}
}
},
"responses": {
"201": { "description": "Subscription created" },
"409": { "description": "Subscription already exists" }
"201": {
"description": "Subscription created"
},
"409": {
"description": "Subscription already exists"
}
}
},
"patch": {
"tags": ["Subscriptions"],
"tags": [
"Subscriptions"
],
"summary": "Update subscription",
"description": "Super admin only.",
"parameters": [
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
{
"name": "userId",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
@ -214,29 +504,67 @@
"application/json": {
"schema": {
"type": "object",
"required": ["memo"],
"required": [
"memo"
],
"properties": {
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
"currentPeriodEnd": { "type": "string", "format": "date-time" },
"overageCredits": { "type": "integer" },
"adminNote": { "type": "string" },
"memo": { "type": "string" }
"tier": {
"type": "string",
"enum": [
"free",
"pro",
"pro_plus"
]
},
"status": {
"type": "string",
"enum": [
"active",
"canceled",
"past_due",
"expired"
]
},
"currentPeriodEnd": {
"type": "string",
"format": "date-time"
},
"overageCredits": {
"type": "integer"
},
"adminNote": {
"type": "string"
},
"memo": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": { "description": "Subscription updated" }
"200": {
"description": "Subscription updated"
}
}
},
"delete": {
"tags": ["Subscriptions"],
"tags": [
"Subscriptions"
],
"summary": "Soft-delete subscription",
"description": "Super admin only. Sets status=expired, tier=free.",
"parameters": [
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
{
"name": "userId",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
@ -244,27 +572,54 @@
"application/json": {
"schema": {
"type": "object",
"required": ["memo"],
"required": [
"memo"
],
"properties": {
"memo": { "type": "string" }
"memo": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": { "description": "Subscription soft-deleted" }
"200": {
"description": "Subscription soft-deleted"
}
}
}
},
"/admin-payments": {
"get": {
"tags": ["Payments"],
"tags": [
"Payments"
],
"summary": "Get payment history for a user",
"description": "Admin+. Returns DB subscription data + audit logs. Pass source=payple for Payple API history.",
"parameters": [
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } },
{ "name": "source", "in": "query", "schema": { "type": "string", "enum": ["db", "payple"] }, "description": "Add 'payple' to also fetch from Payple API" }
{
"name": "userId",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "source",
"in": "query",
"schema": {
"type": "string",
"enum": [
"db",
"payple"
]
},
"description": "Add 'payple' to also fetch from Payple API"
}
],
"responses": {
"200": {
@ -274,10 +629,23 @@
"schema": {
"type": "object",
"properties": {
"subscription": { "$ref": "#/components/schemas/Subscription" },
"auditLogs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
"paypleHistory": { "type": "object", "description": "Payple API response (when source=payple)" },
"paypleError": { "type": "string", "description": "Error message if Payple API call failed" }
"subscription": {
"$ref": "#/components/schemas/Subscription"
},
"auditLogs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuditLog"
}
},
"paypleHistory": {
"type": "object",
"description": "Payple API response (when source=payple)"
},
"paypleError": {
"type": "string",
"description": "Error message if Payple API call failed"
}
}
}
}
@ -288,18 +656,81 @@
},
"/admin-audit-log": {
"get": {
"tags": ["Audit Log"],
"tags": [
"Audit Log"
],
"summary": "List or get audit log entries",
"description": "Admin+. Pass id for single entry detail.",
"parameters": [
{ "name": "id", "in": "query", "schema": { "type": "integer" }, "description": "Specific log entry ID" },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "target_type", "in": "query", "schema": { "type": "string", "enum": ["subscription", "profile"] } },
{ "name": "admin_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
{ "name": "target_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
{ "name": "from", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "Start date (YYYY-MM-DD)" },
{ "name": "to", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "End date (YYYY-MM-DD)" }
{
"name": "id",
"in": "query",
"schema": {
"type": "integer"
},
"description": "Specific log entry ID"
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"default": 20
}
},
{
"name": "target_type",
"in": "query",
"schema": {
"type": "string",
"enum": [
"subscription",
"profile"
]
}
},
{
"name": "admin_id",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "target_id",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "from",
"in": "query",
"schema": {
"type": "string",
"format": "date"
},
"description": "Start date (YYYY-MM-DD)"
},
{
"name": "to",
"in": "query",
"schema": {
"type": "string",
"format": "date"
},
"description": "End date (YYYY-MM-DD)"
}
],
"responses": {
"200": {
@ -311,17 +742,32 @@
{
"type": "object",
"properties": {
"logs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
"total": { "type": "integer" },
"page": { "type": "integer" },
"limit": { "type": "integer" }
"logs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuditLog"
}
},
"total": {
"type": "integer"
},
"page": {
"type": "integer"
},
"limit": {
"type": "integer"
}
}
},
{
"type": "object",
"properties": {
"log": { "$ref": "#/components/schemas/AuditLog" },
"admin": { "$ref": "#/components/schemas/Profile" }
"log": {
"$ref": "#/components/schemas/AuditLog"
},
"admin": {
"$ref": "#/components/schemas/Profile"
}
}
}
]

View file

@ -1,5 +1,5 @@
# apps/admin/Dockerfile
FROM node:24-alpine AS base
FROM node:24.19.0-alpine AS base
FROM base AS builder
WORKDIR /app

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/admin",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": {

View file

@ -2,7 +2,6 @@
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
import { Box, Typography, Button } from '@mui/material'
import Link from 'next/link'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { requireManager } from '@/lib/admin-guard'
@ -91,7 +90,6 @@ function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl:
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Button
component={Link}
href={`${repoHtmlUrl}/releases`}
target="_blank"
variant="outlined"
@ -109,7 +107,6 @@ function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl:
Forgejo Releases
</Button>
<Button
component={Link}
href="https://d3ro.chanpaca.net/download.html"
target="_blank"
variant="outlined"

View file

@ -3,11 +3,7 @@
import { randomUUID } from 'node:crypto'
import { NextRequest, NextResponse } from 'next/server'
import {
issueSignedLicenseKey,
DEFAULT_LICENSE_PRIVATE_KEY,
type SignedLicensePayload
} from '@d3ro/core/utils/crypto-license'
import { issueSignedLicenseKey, type SignedLicensePayload } from '@d3ro/core/utils/crypto-license'
import type { LicenseTier } from '@d3ro/core/types'
import { AdminBackendError, fetchAdminBackend, requireVerifiedBackendSession } from '@/lib/backend-session'
@ -27,20 +23,11 @@ const MAX_DEVICES: Record<LicenseTier, number> = {
enterprise: 999
}
interface SigningKey {
privateKeyPem: string
usedDefaultKey: boolean
}
// 저장소에 포함된 기본 키쌍은 공개된 것이므로 위조 방어력이 없다. 운영 발급은
// ADMIN_LICENSE_PRIVATE_KEY(전용 키 로테이션)로만 하고, 기본 키 사용은 명시적 opt-in.
function resolveSigningKey(): SigningKey | null {
// 공개된 개발 키로의 fallback은 없다. 모든 환경에서 명시적인 전용 서명키가 필요하다.
function resolveSigningKey(): string | null {
const configured = process.env.ADMIN_LICENSE_PRIVATE_KEY?.trim()
if (configured) {
return { privateKeyPem: configured.replace(/\\n/g, '\n'), usedDefaultKey: false }
}
if (process.env.ADMIN_LICENSE_ALLOW_DEFAULT_KEY === 'true') {
return { privateKeyPem: DEFAULT_LICENSE_PRIVATE_KEY, usedDefaultKey: true }
return configured.replace(/\\n/g, '\n')
}
return null
}
@ -119,7 +106,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
let licenseKey: string
try {
licenseKey = issueSignedLicenseKey(payload, signingKey.privateKeyPem)
licenseKey = issueSignedLicenseKey(payload, signingKey)
} catch {
return NextResponse.json({ error: 'license_signing_failed' }, { status: 500 })
}
@ -151,7 +138,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
licenseKey,
licenseId: payload.licenseId,
expiresAt: payload.expiresAt,
usedDefaultKey: signingKey.usedDefaultKey,
auditRecorded
})
}

View file

@ -45,7 +45,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
const [machineId, setMachineId] = useState('')
const [teamId, setTeamId] = useState('')
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const [usedDefaultKey, setUsedDefaultKey] = useState(false)
const [auditRecorded, setAuditRecorded] = useState(true)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
@ -77,7 +76,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
throw new Error(describeIssueError(data.error))
}
setGeneratedKey(data.licenseKey)
setUsedDefaultKey(data.usedDefaultKey === true)
setAuditRecorded(data.auditRecorded !== false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate license key')
@ -211,12 +209,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
{generatedKey && (
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
{usedDefaultKey && (
<Alert severity="warning" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
.
ADMIN_LICENSE_PRIVATE_KEY로 .
</Alert>
)}
{!auditRecorded && (
<Alert severity="info" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
. .

View file

@ -0,0 +1,119 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Xunit;
namespace D3ROVoice.Api.Tests;
[CollectionDefinition("Api server integration", DisableParallelization = true)]
public sealed class ApiServerIntegrationCollection;
[Collection("Api server integration")]
public sealed class AdminAuthorizationE2ETests : IClassFixture<AdminAuthorizationE2ETests.ApiFactory>
{
private const string Secret = "admin-e2e-jwt-secret-0123456789-abcdef";
private const string Issuer = "https://admin-e2e.test";
private const string Audience = "d3ro-admin-e2e";
private readonly ApiFactory _factory;
public AdminAuthorizationE2ETests(ApiFactory factory)
{
_factory = factory;
}
[Fact]
public async Task AdminReadEndpointsRejectAnonymousAndNonManager()
{
using var client = _factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/stats")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("User"));
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/stats")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("manager"));
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/admin/stats")).StatusCode);
}
[Fact]
public async Task AdminMutationRejectsManagerAndAcceptsNormalizedSuperAdmin()
{
using var client = _factory.CreateClient();
using var emptyPayload = new StringContent("{}", Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("Manager"));
Assert.Equal(HttpStatusCode.Forbidden,
(await client.PostAsync("/api/admin/endpoints", emptyPayload)).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("super_admin"));
using var secondPayload = new StringContent("{}", Encoding.UTF8, "application/json");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostAsync("/api/admin/endpoints", secondPayload)).StatusCode);
}
private static string Token(string role)
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims:
[
new Claim(ClaimTypes.NameIdentifier, "1"),
new Claim(ClaimTypes.Email, "admin@example.com"),
new Claim(ClaimTypes.Role, role)
],
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public sealed class ApiFactory : WebApplicationFactory<Program>
{
private readonly string _databasePath = Path.Combine(
Path.GetTempPath(), $"d3ro-admin-auth-e2e-{Guid.NewGuid():N}.db");
private readonly Dictionary<string, string?> _previousEnvironment = new();
public ApiFactory()
{
SetEnvironment("JWT_SECRET", Secret);
SetEnvironment("JWT_ISSUER", Issuer);
SetEnvironment("JWT_AUDIENCE", Audience);
SetEnvironment("D3RO_API_TOKEN", "fixture-internal-gateway-token-32-bytes-minimum");
SetEnvironment("DB_PATH", _databasePath);
SetEnvironment("CORS_ALLOWED_ORIGINS", "http://localhost:3001");
SetEnvironment("ALLOWED_HOSTS", "localhost;127.0.0.1");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureLogging(logging => logging.ClearProviders());
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SqliteConnection.ClearAllPools();
if (File.Exists(_databasePath)) File.Delete(_databasePath);
foreach (var (name, value) in _previousEnvironment)
{
Environment.SetEnvironmentVariable(name, value);
}
}
private void SetEnvironment(string name, string value)
{
_previousEnvironment[name] = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
}
}

View file

@ -0,0 +1,97 @@
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Services;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class AdminOperationServiceTests
{
[Fact]
public async Task MutationIsAtomicAuditedAndIdempotent()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AdminOperationService(db);
var idempotencyKey = Guid.NewGuid().ToString("D");
var mutationCalls = 0;
async Task<object> Mutate()
{
mutationCalls += 1;
var endpoint = new ServiceModelEndpoint
{
ModelId = "real-model",
ModelName = "Real Model",
Provider = "Custom",
EndpointUrl = "https://models.example.com/v1",
ApiKey = "secret-never-in-response"
};
db.ModelEndpoints.Add(endpoint);
await db.SaveChangesAsync();
return new { endpoint.Id, endpoint.ModelId };
}
var first = await service.ExecuteAsync(
"ADMIN@EXAMPLE.COM", "model_endpoint.create", idempotencyKey,
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
var replay = await service.ExecuteAsync(
"admin@example.com", "model_endpoint.create", idempotencyKey,
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
Assert.Equal(1, mutationCalls);
Assert.Equal(first.GetRawText(), replay.GetRawText());
Assert.Equal(1, await db.ModelEndpoints.CountAsync());
Assert.Equal(1, await db.AdminOperationRequests.CountAsync());
var audit = await db.AdminAuditEntries.SingleAsync();
Assert.Equal("admin@example.com", audit.ActorEmail);
Assert.Equal("model_endpoint.create", audit.Action);
Assert.Equal(idempotencyKey, audit.IdempotencyKey);
await Assert.ThrowsAsync<AdminOperationException>(() => service.ExecuteAsync(
"admin@example.com", "model_endpoint.create", idempotencyKey,
new { modelId = "different-model" }, "model_endpoint", _ => "different-model",
"different request", () => Task.FromResult<object?>(null), Mutate));
}
[Fact]
public async Task FailedMutationRollsBackDomainAndAuditWrites()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AdminOperationService(db);
async Task<object> FailingMutation()
{
db.ModelEndpoints.Add(new ServiceModelEndpoint
{
ModelId = "rollback-model",
ModelName = "Rollback Model",
Provider = "Custom",
EndpointUrl = "https://models.example.com/v1"
});
await db.SaveChangesAsync();
throw new InvalidOperationException("simulated persistence failure");
}
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ExecuteAsync(
"admin@example.com", "model_endpoint.create", Guid.NewGuid().ToString("D"),
new { modelId = "rollback-model" }, "model_endpoint", _ => "rollback-model",
"rollback verification", () => Task.FromResult<object?>(null), FailingMutation));
db.ChangeTracker.Clear();
Assert.Empty(await db.ModelEndpoints.ToListAsync());
Assert.Empty(await db.AdminOperationRequests.ToListAsync());
Assert.Empty(await db.AdminAuditEntries.ToListAsync());
}
}

View file

@ -0,0 +1,91 @@
using D3ROVoice.Api.Controllers;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class AuthBootstrapControllerTests
{
private static readonly RegisterDto Request = new(
"operator@example.test",
"test-only-password-1234");
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("too-short")]
public async Task RegisterIsDisabledWithoutAStrongConfiguredBootstrapToken(string? configuredToken)
{
var service = new RecordingAuthService();
var configuration = Configuration(configuredToken);
var controller = new AuthController(service, configuration);
var result = await controller.Register(Request, configuredToken);
var unavailable = Assert.IsType<ObjectResult>(result);
Assert.Equal(503, unavailable.StatusCode);
Assert.Equal(0, service.RegisterCalls);
}
[Fact]
public async Task RegisterRejectsAnIncorrectBootstrapTokenWithoutCreatingAUser()
{
var service = new RecordingAuthService();
var configuration = Configuration("test-bootstrap-token-0123456789-abcdef");
var controller = new AuthController(service, configuration);
var result = await controller.Register(
Request,
"different-test-token-0123456789-abcdef");
Assert.IsType<UnauthorizedObjectResult>(result);
Assert.Equal(0, service.RegisterCalls);
}
[Fact]
public async Task RegisterAcceptsTheExactStrongBootstrapTokenOnce()
{
const string bootstrapToken = "test-bootstrap-token-0123456789-abcdef";
var service = new RecordingAuthService();
var controller = new AuthController(service, Configuration(bootstrapToken));
var result = await controller.Register(Request, bootstrapToken);
Assert.IsType<OkObjectResult>(result);
Assert.Equal(1, service.RegisterCalls);
}
private static IConfiguration Configuration(string? bootstrapToken)
{
var values = new Dictionary<string, string?>();
if (bootstrapToken is not null)
{
values["ADMIN_BOOTSTRAP_TOKEN"] = bootstrapToken;
}
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
private sealed class RecordingAuthService : IAuthService
{
public int RegisterCalls { get; private set; }
public Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
{
RegisterCalls += 1;
return Task.FromResult(new AuthResponseDto(
"test-token",
dto.Email,
"SuperAdmin",
DateTime.UtcNow.AddMinutes(5)));
}
public Task<AuthResponseDto> LoginAsync(LoginDto dto) =>
throw new NotSupportedException();
public Task<UserInfoDto?> GetUserByEmailAsync(string email) =>
throw new NotSupportedException();
}
}

View file

@ -0,0 +1,118 @@
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class AuthSecurityTests
{
private static IConfiguration TestConfiguration() => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT_SECRET"] = "test-only-jwt-secret-0123456789-abcdef",
["JWT_ISSUER"] = "https://issuer.test",
["JWT_AUDIENCE"] = "d3ro-admin-test"
})
.Build();
[Fact]
public async Task BootstrapStoresPbkdf2HashAndRejectsASecondAdministrator()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AuthService(db, TestConfiguration());
var created = await service.RegisterAsync(
new RegisterDto("ADMIN@EXAMPLE.COM", "correct-horse-battery-staple"));
var stored = await db.Users.SingleAsync();
Assert.Equal("admin@example.com", stored.Email);
Assert.Equal("SuperAdmin", stored.Role);
Assert.StartsWith("AQAAAA", stored.PasswordHash);
Assert.DoesNotContain("correct-horse", stored.PasswordHash, StringComparison.Ordinal);
Assert.Equal("SuperAdmin", created.Role);
Assert.True(created.ExpiresAt > DateTime.UtcNow.AddHours(7));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.RegisterAsync(
new RegisterDto("second@example.com", "another-correct-password")));
}
[Fact]
public async Task LoginRejectsWrongPasswordAndReusesNoLegacyHashScheme()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AuthService(db, TestConfiguration());
await service.RegisterAsync(
new RegisterDto("admin@example.com", "correct-horse-battery-staple"));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.LoginAsync(
new LoginDto("admin@example.com", "wrong-password-value")));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.LoginAsync(
new LoginDto("missing@example.com", "wrong-password-value")));
var login = await service.LoginAsync(
new LoginDto("ADMIN@example.com", "correct-horse-battery-staple"));
Assert.Equal("admin@example.com", login.Email);
Assert.False(string.IsNullOrWhiteSpace(login.Token));
}
[Fact]
public async Task ConcurrentBootstrapAllowsExactlyOneAdministrator()
{
var dbPath = Path.Combine(
Path.GetTempPath(),
$"d3ro-auth-security-{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options;
try
{
await using (var setup = new AppDbContext(options))
{
await setup.Database.EnsureCreatedAsync();
}
async Task<bool> TryBootstrapAsync(int index)
{
await using var db = new AppDbContext(options);
var service = new AuthService(db, TestConfiguration());
try
{
await service.RegisterAsync(new RegisterDto(
$"admin-{index}@example.com",
$"correct-horse-battery-{index}-staple"));
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
var outcomes = await Task.WhenAll(TryBootstrapAsync(1), TryBootstrapAsync(2));
Assert.Single(outcomes, result => result);
await using (var verification = new AppDbContext(options))
{
Assert.Equal(1, await verification.Users.CountAsync(user => user.IsActive));
}
}
finally
{
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\api-server\D3ROVoice.Api.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,191 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using D3ROVoice.Api.Controllers;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class SttControllerSecurityTests
{
private const string GatewayToken = "fixture-internal-gateway-token-32-bytes-minimum";
[Fact]
public void Controller_RequiresJwtAuthorization()
{
Assert.NotNull(Attribute.GetCustomAttribute(typeof(SttController), typeof(AuthorizeAttribute)));
}
[Theory]
[InlineData(null, false)]
[InlineData("42", true)]
[InlineData("not-an-integer", true)]
public async Task Transcribe_AlwaysRequiresQuotaOwningEdgeGatewayAndNeverCallsProvider(
string? userId,
bool authenticated)
{
var service = new RecordingSttService();
var controller = CreateController(service, userId, isAuthenticated: authenticated);
var result = Assert.IsType<ObjectResult>(await controller.Transcribe());
Assert.Equal(StatusCodes.Status410Gone, result.StatusCode);
Assert.Contains("stt_edge_gateway_required", JsonSerializer.Serialize(result.Value), StringComparison.Ordinal);
Assert.Equal(0, service.TranscribeCalls);
}
[Theory]
[InlineData(null)]
[InlineData("wrong-token")]
public async Task InternalGateway_RejectsMissingOrWrongTokenWithoutProviderCall(string? suppliedToken)
{
var service = new RecordingSttService();
var controller = CreateController(service, null, isAuthenticated: false, suppliedGatewayToken: suppliedToken);
var result = Assert.IsType<UnauthorizedObjectResult>(await controller.TranscribeFromQuotaGateway());
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
Assert.Equal(0, service.TranscribeCalls);
}
[Fact]
public async Task InternalGateway_FailsClosedWhenServerSecretIsTooShort()
{
var service = new RecordingSttService();
var controller = CreateController(
service,
null,
isAuthenticated: false,
configuredGatewayToken: "short",
suppliedGatewayToken: "short");
var result = Assert.IsType<ObjectResult>(await controller.TranscribeFromQuotaGateway());
Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode);
Assert.Equal(0, service.TranscribeCalls);
}
[Fact]
public async Task InternalGateway_ValidTokenInvokesProviderWithoutLegacyUsageWrite()
{
var service = new RecordingSttService();
var controller = CreateController(
service,
null,
isAuthenticated: false,
suppliedGatewayToken: GatewayToken,
includeAudioForm: true);
var result = Assert.IsType<OkObjectResult>(await controller.TranscribeFromQuotaGateway());
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
Assert.Equal(1, service.TranscribeCalls);
Assert.False(service.LastRecordUsage);
Assert.Equal(0, service.LastUserId);
Assert.Equal("edge-internal", service.LastUserEmail);
}
private static SttController CreateController(
RecordingSttService service,
string? userId,
bool isAuthenticated,
string? email = null,
string configuredGatewayToken = GatewayToken,
string? suppliedGatewayToken = null,
bool includeAudioForm = false)
{
var claims = new List<Claim>();
if (userId != null) claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
if (email != null) claims.Add(new Claim(ClaimTypes.Email, email));
var identity = new ClaimsIdentity(claims, isAuthenticated ? "test-auth" : null);
var context = new DefaultHttpContext
{
User = new ClaimsPrincipal(identity)
};
if (includeAudioForm)
{
context.Request.ContentType = "multipart/form-data; boundary=fixture";
var audioStream = new MemoryStream(new byte[] { 1, 2, 3, 4 });
var files = new FormFileCollection
{
new FormFile(audioStream, 0, audioStream.Length, "file", "fixture.wav")
{
Headers = new HeaderDictionary(),
ContentType = "audio/wav",
}
};
context.Request.Form = new FormCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>(),
files);
}
else
{
context.Request.ContentType = "application/json";
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"audioBase64\":\"AQID\"}"));
}
if (suppliedGatewayToken != null)
context.Request.Headers["X-D3RO-STT-Gateway-Token"] = suppliedGatewayToken;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["D3RO_API_TOKEN"] = configuredGatewayToken,
})
.Build();
return new SttController(service, configuration)
{
ControllerContext = new ControllerContext { HttpContext = context }
};
}
private sealed class RecordingSttService : ISttProxyService
{
public SttProviderUnavailableException? Exception { get; init; }
public int TranscribeCalls { get; private set; }
public int LastUserId { get; private set; }
public string? LastUserEmail { get; private set; }
public bool LastRecordUsage { get; private set; } = true;
public Task<SttTranscribeResponse> TranscribeAsync(
int userId,
string userEmail,
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null,
bool recordUsage = true)
{
TranscribeCalls++;
LastUserId = userId;
LastUserEmail = userEmail;
LastRecordUsage = recordUsage;
if (Exception != null) throw Exception;
return Task.FromResult(new SttTranscribeResponse(
"real transcript", 0.99, "ko", 1, "test", "test-model", 1, 0));
}
public Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null) =>
throw new NotSupportedException();
public Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync() =>
Task.FromResult(new List<SttProviderEndpointDto>());
public Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto) =>
throw new NotSupportedException();
public Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto) =>
throw new NotSupportedException();
public Task<bool> DeleteEndpointAsync(int id) => throw new NotSupportedException();
public Task<bool> SetDefaultEndpointAsync(int id) => throw new NotSupportedException();
public Task<SttUsageReportDto> GetUsageReportAsync() => throw new NotSupportedException();
}
}

View file

@ -0,0 +1,223 @@
using System.Net;
using System.Text;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class SttFailClosedTests
{
[Fact]
public async Task MissingProviderCredentials_FailsUnavailableWithoutUsage()
{
await using var fixture = await SttFixture.CreateAsync(
apiKey: string.Empty,
_ => throw new InvalidOperationException("HTTP should not be called"));
var exception = await Assert.ThrowsAsync<SttProviderUnavailableException>(
() => fixture.Service.TranscribeAsync(
7,
"user@example.test",
new SttTranscribeRequest(Language: "ko"),
new byte[] { 1, 2, 3 },
"audio/webm",
"recording.webm"));
Assert.False(exception.ProviderAttempted);
Assert.Equal(0, fixture.Handler.CallCount);
Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync());
}
[Fact]
public async Task UpstreamFailure_FailsBadGatewayWithoutLeakingOrRecordingUsage()
{
const string sensitiveBody = "secret-key-value original transcript text";
await using var fixture = await SttFixture.CreateAsync(
apiKey: "configured-key",
_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent(sensitiveBody)
});
var exception = await Assert.ThrowsAsync<SttProviderUnavailableException>(
() => fixture.Service.TranscribeAsync(
9,
"user@example.test",
new SttTranscribeRequest(Language: "ko"),
new byte[] { 1, 2, 3 },
"audio/webm",
"recording.webm"));
Assert.True(exception.ProviderAttempted);
Assert.DoesNotContain(sensitiveBody, exception.Message, StringComparison.Ordinal);
Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync());
var error = Assert.Single(await fixture.Db.ErrorLogs.ToListAsync());
Assert.DoesNotContain(sensitiveBody, error.Message, StringComparison.Ordinal);
Assert.Null(error.Endpoint);
Assert.Null(error.StackTrace);
}
[Fact]
public async Task RealProviderSuccess_RecordsUsageAndReturnsTranscript()
{
await using var fixture = await SttFixture.CreateAsync(
apiKey: "configured-key",
_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"{\"text\":\"real transcript\",\"duration\":1.25,\"language\":\"ko\"}",
Encoding.UTF8,
"application/json")
});
var result = await fixture.Service.TranscribeAsync(
11,
"user@example.test",
new SttTranscribeRequest(Language: "ko"),
new byte[] { 1, 2, 3 },
"audio/webm",
"recording.webm");
Assert.Equal("real transcript", result.Text);
Assert.Equal("groq", result.Provider);
var usage = Assert.Single(await fixture.Db.SttUsageLogs.ToListAsync());
Assert.Equal(11, usage.UserId);
Assert.Equal(200, usage.StatusCode);
}
[Fact]
public async Task GoogleApiKey_IsSentInHeaderAndNeverInRequestUrl()
{
const string apiKey = "sensitive-google-key";
await using var fixture = await SttFixture.CreateAsync(
apiKey,
_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"real transcript\"}]}}]}",
Encoding.UTF8,
"application/json")
},
providerType: "google",
endpointUrl: "https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent",
modelId: "gemini-test");
var result = await fixture.Service.TranscribeAsync(
12,
"user@example.test",
new SttTranscribeRequest(Language: "ko"),
new byte[] { 1, 2, 3 },
"audio/webm",
"recording.webm");
Assert.Equal("real transcript", result.Text);
Assert.Equal(apiKey, fixture.Handler.LastGoogleApiKey);
Assert.DoesNotContain(apiKey, fixture.Handler.LastRequestUri, StringComparison.Ordinal);
}
private sealed class SttFixture : IAsyncDisposable
{
private readonly SqliteConnection _connection;
private SttFixture(
SqliteConnection connection,
AppDbContext db,
RecordingHandler handler,
SttProxyService service)
{
_connection = connection;
Db = db;
Handler = handler;
Service = service;
}
public AppDbContext Db { get; }
public RecordingHandler Handler { get; }
public SttProxyService Service { get; }
public static async Task<SttFixture> CreateAsync(
string apiKey,
Func<HttpRequestMessage, HttpResponseMessage> responseFactory,
string providerType = "groq",
string endpointUrl = "https://provider.example.test/transcriptions",
string modelId = "whisper-test")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
db.SttProviderEndpoints.Add(new SttProviderEndpoint
{
Name = "Test provider",
ProviderType = providerType,
EndpointUrl = endpointUrl,
ApiKey = apiKey,
ModelId = modelId,
Method = "multipart",
Language = "ko",
IsDefault = true,
IsActive = true,
FallbackPriority = 1
});
await db.SaveChangesAsync();
var handler = new RecordingHandler(responseFactory);
var factory = new StubHttpClientFactory(handler);
var service = new SttProxyService(db, factory, NullLogger<SttProxyService>.Instance);
return new SttFixture(connection, db, handler, service);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await _connection.DisposeAsync();
}
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly RecordingHandler _handler;
public StubHttpClientFactory(RecordingHandler handler)
{
_handler = handler;
}
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
public sealed class RecordingHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
public RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public int CallCount { get; private set; }
public string LastRequestUri { get; private set; } = string.Empty;
public string? LastGoogleApiKey { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
CallCount++;
LastRequestUri = request.RequestUri?.ToString() ?? string.Empty;
LastGoogleApiKey = request.Headers.TryGetValues("X-Goog-Api-Key", out var values)
? values.SingleOrDefault()
: null;
return Task.FromResult(_responseFactory(request));
}
}
}

View file

@ -0,0 +1,100 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Xunit;
namespace D3ROVoice.Api.Tests;
[Collection("Api server integration")]
public sealed class SttGatewayAuthorizationE2ETests : IClassFixture<AdminAuthorizationE2ETests.ApiFactory>
{
private const string Secret = "admin-e2e-jwt-secret-0123456789-abcdef";
private const string Issuer = "https://admin-e2e.test";
private const string Audience = "d3ro-admin-e2e";
private const string GatewayToken = "fixture-internal-gateway-token-32-bytes-minimum";
private readonly AdminAuthorizationE2ETests.ApiFactory _factory;
public SttGatewayAuthorizationE2ETests(AdminAuthorizationE2ETests.ApiFactory factory)
{
_factory = factory;
}
[Fact]
public async Task UserEndpointRequiresJwtThenStillRefusesQuotaBypass()
{
using var client = _factory.CreateClient();
using var anonymousBody = JsonBody();
Assert.Equal(HttpStatusCode.Unauthorized,
(await client.PostAsync("/api/stt/transcribe", anonymousBody)).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", UserToken());
using var authenticatedBody = JsonBody();
var response = await client.PostAsync("/api/stt/transcribe", authenticatedBody);
Assert.Equal(HttpStatusCode.Gone, response.StatusCode);
Assert.Contains("stt_edge_gateway_required", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Theory]
[InlineData(null)]
[InlineData("one-character-off")]
public async Task InternalEndpointRejectsMissingOrWrongGatewaySecret(string? suppliedToken)
{
using var client = _factory.CreateClient();
if (suppliedToken != null)
client.DefaultRequestHeaders.Add("X-D3RO-STT-Gateway-Token", suppliedToken);
using var body = AudioBody();
var response = await client.PostAsync("/api/stt/internal/transcribe", body);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Contains("stt_gateway_unauthorized", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Fact]
public async Task InternalEndpointAcceptsExactSecretButFailsClosedWithoutProvider()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-D3RO-STT-Gateway-Token", GatewayToken);
using var body = AudioBody();
var response = await client.PostAsync("/api/stt/internal/transcribe", body);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Contains("stt_provider_unavailable", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
private static StringContent JsonBody() =>
new("{\"audioBase64\":\"AQID\"}", Encoding.UTF8, "application/json");
private static MultipartFormDataContent AudioBody()
{
var body = new MultipartFormDataContent();
var audio = new ByteArrayContent([1, 2, 3, 4]);
audio.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
body.Add(audio, "file", "fixture.wav");
return body;
}
private static string UserToken()
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims:
[
new Claim(ClaimTypes.NameIdentifier, "1"),
new Claim(ClaimTypes.Email, "user@example.com"),
new Claim(ClaimTypes.Role, "user")
],
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View file

@ -1,10 +1,14 @@
using System;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Net.Mail;
using System.Text;
using System.Threading.Tasks;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace D3ROVoice.Api.Controllers;
@ -13,18 +17,45 @@ namespace D3ROVoice.Api.Controllers;
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
private readonly IConfiguration _configuration;
public AuthController(IAuthService authService)
public AuthController(IAuthService authService, IConfiguration configuration)
{
_authService = authService;
_configuration = configuration;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
[EnableRateLimiting("auth")]
[RequestSizeLimit(16 * 1024)]
public async Task<IActionResult> Register(
[FromBody] RegisterDto dto,
[FromHeader(Name = "X-D3RO-Bootstrap-Token")] string? bootstrapToken)
{
if (string.IsNullOrWhiteSpace(dto.Email) || string.IsNullOrWhiteSpace(dto.Password))
var normalizedEmail = dto.Email?.Trim().ToLowerInvariant() ?? string.Empty;
if (
normalizedEmail.Length is < 3 or > 150
|| !MailAddress.TryCreate(normalizedEmail, out var parsedEmail)
|| !string.Equals(parsedEmail.Address, normalizedEmail, StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(dto.Password)
)
{
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
return BadRequest(new { message = "유효한 이메일과 비밀번호를 입력해주세요." });
}
var configuredToken = _configuration["ADMIN_BOOTSTRAP_TOKEN"];
if (
string.IsNullOrWhiteSpace(configuredToken)
|| Encoding.UTF8.GetByteCount(configuredToken) < 32
)
{
return StatusCode(503, new { message = "관리자 초기 등록이 비활성화되어 있습니다." });
}
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configuredToken));
var providedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(bootstrapToken ?? string.Empty));
if (!CryptographicOperations.FixedTimeEquals(configuredDigest, providedDigest))
{
return Unauthorized(new { message = "관리자 초기 등록 토큰이 올바르지 않습니다." });
}
try
@ -39,9 +70,17 @@ public class AuthController : ControllerBase
}
[HttpPost("login")]
[EnableRateLimiting("auth")]
[RequestSizeLimit(16 * 1024)]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Email) || string.IsNullOrWhiteSpace(dto.Password))
var normalizedEmail = dto.Email?.Trim().ToLowerInvariant() ?? string.Empty;
if (
normalizedEmail.Length is < 3 or > 150
|| !MailAddress.TryCreate(normalizedEmail, out _)
|| string.IsNullOrWhiteSpace(dto.Password)
|| dto.Password.Length > 256
)
{
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
}

View file

@ -1,99 +1,120 @@
using System;
using System.IO;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
namespace D3ROVoice.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class SttController : ControllerBase
{
private const string InternalGatewayHeader = "X-D3RO-STT-Gateway-Token";
private readonly ISttProxyService _sttService;
private readonly IConfiguration _configuration;
public SttController(ISttProxyService sttService)
public SttController(ISttProxyService sttService, IConfiguration configuration)
{
_sttService = sttService;
_configuration = configuration;
}
[HttpPost("transcribe")]
[Consumes("application/json", "multipart/form-data")]
public async Task<IActionResult> Transcribe()
public Task<IActionResult> Transcribe()
{
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
var userEmail = User.FindFirstValue(ClaimTypes.Email) ?? "user@d3ro.voice";
int userId = int.TryParse(userIdStr, out var id) ? id : 1;
// User-facing transcription is exclusively handled by the Supabase
// stt-proxy, which owns authenticated identity, quota reservation and
// usage persistence. This legacy provider path must not bypass it.
return Task.FromResult<IActionResult>(StatusCode(
StatusCodes.Status410Gone,
new { error = "stt_edge_gateway_required" }));
}
if (Request.HasFormContentType)
[HttpPost("internal/transcribe")]
[AllowAnonymous]
[Consumes("multipart/form-data")]
[RequestSizeLimit(26 * 1024 * 1024)]
public async Task<IActionResult> TranscribeFromQuotaGateway()
{
var configuredToken = _configuration["D3RO_API_TOKEN"]?.Trim() ?? string.Empty;
if (Encoding.UTF8.GetByteCount(configuredToken) < 32)
{
var form = await Request.ReadFormAsync();
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_gateway_not_configured" });
}
if (file == null || file.Length == 0)
{
return BadRequest(new { message = "전송할 오디오 파일(file 또는 audio)이 필요합니다." });
}
var suppliedToken = Request.Headers[InternalGatewayHeader].ToString();
if (!FixedTimeTokenEquals(configuredToken, suppliedToken))
{
return Unauthorized(new { error = "stt_gateway_unauthorized" });
}
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var audioBytes = memoryStream.ToArray();
if (!Request.HasFormContentType)
{
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported_media_type" });
}
var language = form["language"].ToString();
var prompt = form["prompt"].ToString();
var model = form["model"].ToString();
var provider = form["provider"].ToString();
var form = await Request.ReadFormAsync();
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
if (file == null || file.Length == 0)
{
return BadRequest(new { error = "missing_audio" });
}
var request = new SttTranscribeRequest(
AudioBase64: null,
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
ModelId: string.IsNullOrWhiteSpace(model) ? null : model,
Provider: string.IsNullOrWhiteSpace(provider) ? null : provider
);
await using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var language = form["language"].ToString();
var prompt = form["prompt"].ToString();
var request = new SttTranscribeRequest(
AudioBase64: null,
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
ModelId: null,
Provider: null);
try
{
var result = await _sttService.TranscribeAsync(
userId,
userEmail,
userId: 0,
userEmail: "edge-internal",
request,
audioBytes,
file.ContentType ?? "audio/webm",
file.FileName ?? "recording.webm"
);
memoryStream.ToArray(),
file.ContentType ?? "application/octet-stream",
file.FileName ?? "recording.bin",
recordUsage: false);
return Ok(result);
}
else
catch (SttProviderUnavailableException)
{
// Read JSON body
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(json))
{
return BadRequest(new { message = "요청 본문이 비어있습니다." });
}
var request = System.Text.Json.JsonSerializer.Deserialize<SttTranscribeRequest>(
json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
if (request == null || string.IsNullOrWhiteSpace(request.AudioBase64))
{
return BadRequest(new { message = "AudioBase64 데이터가 필요합니다." });
}
var result = await _sttService.TranscribeAsync(userId, userEmail, request);
return Ok(result);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_provider_unavailable" });
}
catch (ArgumentException)
{
return BadRequest(new { error = "invalid_audio" });
}
catch
{
return StatusCode(StatusCodes.Status502BadGateway, new { error = "stt_upstream_failed" });
}
}
private static bool FixedTimeTokenEquals(string configured, string supplied)
{
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configured));
var suppliedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
return CryptographicOperations.FixedTimeEquals(configuredDigest, suppliedDigest);
}
[HttpGet("providers")]
[Authorize(Policy = "ManagerOrAbove")]
public async Task<IActionResult> GetActiveProviders()
{
var endpoints = await _sttService.GetAllEndpointsAsync();
@ -101,6 +122,7 @@ public class SttController : ControllerBase
}
[HttpPost("test")]
[Authorize(Policy = "ManagerOrAbove")]
public async Task<IActionResult> TestConnection([FromQuery] int endpointId = 0)
{
var result = await _sttService.TestEndpointAsync(endpointId);

View file

@ -2,14 +2,24 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.1.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- Retained legacy binaries are not static assets and must never publish. -->
<Content Remove="wwwroot\releases\**\*" />
<Content Remove="wwwroot\index.html" />
<Content Remove="wwwroot\assets\index-D7M5UQvT.js" />
<Content Remove="wwwroot\assets\index-JlYFxlAJ.js" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.13" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
</ItemGroup>

View file

@ -203,6 +203,58 @@ public class SttUsageLog
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class AdminOperationRequest
{
[Key]
public long Id { get; set; }
[Required, MaxLength(150)]
public string ActorEmail { get; set; } = string.Empty;
[Required, MaxLength(36)]
public string IdempotencyKey { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string Operation { get; set; } = string.Empty;
[Required, MaxLength(64)]
public string RequestHash { get; set; } = string.Empty;
[Required]
public string ResponseJson { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class AdminAuditEntry
{
[Key]
public long Id { get; set; }
[Required, MaxLength(150)]
public string ActorEmail { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string Action { get; set; } = string.Empty;
[Required, MaxLength(80)]
public string TargetType { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string TargetId { get; set; } = string.Empty;
public string? BeforeJson { get; set; }
public string? AfterJson { get; set; }
[Required, MaxLength(1000)]
public string Memo { get; set; } = string.Empty;
[Required, MaxLength(36)]
public string IdempotencyKey { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@ -213,6 +265,8 @@ public class AppDbContext : DbContext
public DbSet<ServerErrorLog> ErrorLogs => Set<ServerErrorLog>();
public DbSet<SttProviderEndpoint> SttProviderEndpoints => Set<SttProviderEndpoint>();
public DbSet<SttUsageLog> SttUsageLogs => Set<SttUsageLog>();
public DbSet<AdminOperationRequest> AdminOperationRequests => Set<AdminOperationRequest>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@ -231,5 +285,12 @@ public class AppDbContext : DbContext
modelBuilder.Entity<SttProviderEndpoint>()
.HasIndex(s => s.IsDefault);
modelBuilder.Entity<AdminOperationRequest>()
.HasIndex(operation => new { operation.ActorEmail, operation.IdempotencyKey })
.IsUnique();
modelBuilder.Entity<AdminAuditEntry>()
.HasIndex(entry => entry.CreatedAt);
}
}

View file

@ -1,5 +1,5 @@
# Multi-stage Docker build for D3RO Voice C# .NET API Backend & BackOffice
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0.302-noble AS build
WORKDIR /src
COPY D3ROVoice.Api.csproj ./
@ -8,7 +8,7 @@ RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o /app/out
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0.10-noble AS runtime
WORKDIR /app
# Create persistent storage directory for SQLite database
@ -24,4 +24,3 @@ ENV DATA_DIR=/app/data
VOLUME ["/app/data"]
ENTRYPOINT ["dotnet", "D3ROVoice.Api.dll"]

View file

@ -10,6 +10,8 @@ using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
var serverStartTime = DateTime.UtcNow;
var releaseVersion = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3)
?? "unknown";
// Add Services to Container
builder.Services.AddControllers();
@ -34,7 +36,7 @@ builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "D3RO Voice Cloud API & BackOffice",
Version = "v1",
Version = releaseVersion,
Description = "D3RO Voice Self-Hosted Cloud Backend for NAS & Docker"
});
});
@ -460,7 +462,7 @@ app.MapGet("/health", () => Results.Ok(new
{
status = "Healthy",
service = "D3RO Voice Cloud API",
version = "1.0.0",
version = releaseVersion,
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
database = File.Exists(dbPath) ? "Connected" : "Initializing",
timestamp = DateTime.UtcNow
@ -470,7 +472,7 @@ app.MapGet("/api/health", () => Results.Ok(new
{
status = "Healthy",
service = "D3RO Voice Cloud API",
version = "1.0.0",
version = releaseVersion,
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
database = File.Exists(dbPath) ? "Connected" : "Initializing",
timestamp = DateTime.UtcNow

View file

@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using D3ROVoice.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace D3ROVoice.Api.Services;
public sealed class AdminOperationException : Exception
{
public AdminOperationException(string message) : base(message) { }
}
public interface IAdminOperationService
{
Task<JsonElement> ExecuteAsync(
string actorEmail,
string operation,
string idempotencyKey,
object request,
string targetType,
Func<object, string> targetId,
string memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate);
}
public sealed class AdminOperationService : IAdminOperationService
{
private readonly AppDbContext _db;
public AdminOperationService(AppDbContext db)
{
_db = db;
}
public async Task<JsonElement> ExecuteAsync(
string actorEmail,
string operation,
string idempotencyKey,
object request,
string targetType,
Func<object, string> targetId,
string memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate)
{
actorEmail = actorEmail.Trim().ToLowerInvariant();
memo = memo.Trim();
if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor");
if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key");
if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo");
var requestJson = JsonSerializer.Serialize(request);
var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}")));
await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry =>
entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey);
if (existing != null)
{
if (existing.Operation != operation || existing.RequestHash != requestHash)
throw new AdminOperationException("idempotency_key_reused_with_different_request");
using var replay = JsonDocument.Parse(existing.ResponseJson);
return replay.RootElement.Clone();
}
var before = await readBefore();
var result = await mutate();
var responseJson = JsonSerializer.Serialize(result);
var resolvedTargetId = targetId(result);
if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200)
throw new AdminOperationException("invalid_audit_target");
_db.AdminOperationRequests.Add(new AdminOperationRequest
{
ActorEmail = actorEmail,
IdempotencyKey = idempotencyKey,
Operation = operation,
RequestHash = requestHash,
ResponseJson = responseJson,
CreatedAt = DateTime.UtcNow
});
_db.AdminAuditEntries.Add(new AdminAuditEntry
{
ActorEmail = actorEmail,
Action = operation,
TargetType = targetType,
TargetId = resolvedTargetId,
BeforeJson = before == null ? null : JsonSerializer.Serialize(before),
AfterJson = responseJson,
Memo = memo,
IdempotencyKey = idempotencyKey,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
await transaction.CommitAsync();
using var response = JsonDocument.Parse(responseJson);
return response.RootElement.Clone();
}
}

View file

@ -1,13 +1,13 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
namespace D3ROVoice.Api.Services;
@ -21,43 +21,81 @@ public interface IAuthService
public class AuthService : IAuthService
{
private static readonly SemaphoreSlim BootstrapLock = new(1, 1);
private readonly AppDbContext _db;
private readonly IConfiguration _config;
private readonly PasswordHasher<User> _passwordHasher = new();
private readonly string _dummyPasswordHash;
public AuthService(AppDbContext db, IConfiguration config)
{
_db = db;
_config = config;
_dummyPasswordHash = _passwordHasher.HashPassword(new User(), "dummy-password-never-used");
}
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
{
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
if (existing != null)
if (dto.Password.Length < 12 || dto.Password.Length > 256)
{
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
throw new InvalidOperationException("비밀번호는 12자 이상 256자 이하여야 합니다.");
}
var isFirstUser = !await _db.Users.AnyAsync();
var user = new User
await BootstrapLock.WaitAsync();
try
{
Email = dto.Email.Trim().ToLower(),
PasswordHash = HashPassword(dto.Password),
Role = isFirstUser ? "Admin" : "User",
CreatedAt = DateTime.UtcNow,
IsActive = true
};
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
if (existing != null)
{
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
}
_db.Users.Add(user);
await _db.SaveChangesAsync();
if (await _db.Users.AnyAsync(u => u.IsActive))
{
throw new InvalidOperationException("관리자 초기 등록이 이미 완료되었습니다.");
}
return GenerateToken(user);
var user = new User
{
Email = normalizedEmail,
Role = "SuperAdmin",
CreatedAt = DateTime.UtcNow,
IsActive = true
};
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
_db.Users.Add(user);
await _db.SaveChangesAsync();
return GenerateToken(user);
}
finally
{
BootstrapLock.Release();
}
}
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
if (user == null || !VerifyPassword(dto.Password, user.PasswordHash))
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
if (user == null)
{
_passwordHasher.VerifyHashedPassword(new User(), _dummyPasswordHash, dto.Password);
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
PasswordVerificationResult verification;
try
{
verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, dto.Password);
}
catch (FormatException)
{
verification = PasswordVerificationResult.Failed;
}
if (verification == PasswordVerificationResult.Failed)
{
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
@ -68,6 +106,10 @@ public class AuthService : IAuthService
}
user.LastLoginAt = DateTime.UtcNow;
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
}
await _db.SaveChangesAsync();
return GenerateToken(user);
@ -82,7 +124,21 @@ public class AuthService : IAuthService
private AuthResponseDto GenerateToken(User user)
{
var secretKey = _config["Jwt:SecretKey"] ?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
var secretKey = _config["JWT_SECRET"];
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
{
throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes.");
}
var issuer = _config["JWT_ISSUER"];
if (string.IsNullOrWhiteSpace(issuer))
{
throw new InvalidOperationException("JWT_ISSUER is required.");
}
var audience = _config["JWT_AUDIENCE"];
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException("JWT_AUDIENCE is required.");
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@ -93,11 +149,11 @@ public class AuthService : IAuthService
new Claim(ClaimTypes.Role, user.Role)
};
var expiresAt = DateTime.UtcNow.AddDays(30);
var expiresAt = DateTime.UtcNow.AddHours(8);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"] ?? "D3ROVoiceApi",
audience: _config["Jwt:Audience"] ?? "D3ROVoiceClient",
issuer: issuer,
audience: audience,
claims: claims,
expires: expiresAt,
signingCredentials: creds
@ -107,15 +163,4 @@ public class AuthService : IAuthService
return new AuthResponseDto(tokenHandler.WriteToken(token), user.Email, user.Role, expiresAt);
}
public static string HashPassword(string password)
{
using var sha256 = SHA256.Create();
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "D3RO_SALT_2026"));
return Convert.ToBase64String(bytes);
}
private static bool VerifyPassword(string password, string hash)
{
return HashPassword(password) == hash;
}
}

View file

@ -23,7 +23,8 @@ public interface ISttProxyService
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null
string? fileName = null,
bool recordUsage = true
);
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
@ -35,6 +36,17 @@ public interface ISttProxyService
Task<SttUsageReportDto> GetUsageReportAsync();
}
public sealed class SttProviderUnavailableException : Exception
{
public bool ProviderAttempted { get; }
public SttProviderUnavailableException(bool providerAttempted)
: base("STT service is temporarily unavailable.")
{
ProviderAttempted = providerAttempted;
}
}
public class SttProxyService : ISttProxyService
{
private readonly AppDbContext _db;
@ -54,7 +66,8 @@ public class SttProxyService : ISttProxyService
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null
string? fileName = null,
bool recordUsage = true
)
{
// 1. Resolve Audio Bytes
@ -94,23 +107,12 @@ public class SttProxyService : ISttProxyService
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
if (candidates.Count == 0)
{
// Seed a dynamic fallback endpoint in memory
candidates.Add(new SttProviderEndpoint
{
Id = 0,
Name = "Default Groq Whisper Fallback",
ProviderType = "groq",
EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions",
ModelId = "whisper-large-v3-turbo",
Method = "multipart",
Language = request.Language ?? "ko",
CostPerMinute = 0.000500m,
IsActive = true
});
throw new SttProviderUnavailableException(providerAttempted: false);
}
var totalSw = Stopwatch.StartNew();
Exception? lastException = null;
var providerAttempted = false;
// 3. Failover Execution Chain
foreach (var endpoint in candidates)
@ -118,6 +120,14 @@ public class SttProxyService : ISttProxyService
var epSw = Stopwatch.StartNew();
try
{
if (!HasRequiredProviderConfiguration(endpoint))
{
_logger.LogWarning("Skipping STT provider {Provider}: required credentials are not configured",
endpoint.ProviderType);
continue;
}
providerAttempted = true;
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
@ -136,29 +146,33 @@ public class SttProxyService : ISttProxyService
var durationMinutes = (decimal)(finalDuration / 60.0);
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
// Record Usage Log
try
// Supabase stt-proxy owns user quota and usage for internal
// provider calls. Direct legacy callers may still record here.
if (recordUsage)
{
var usageLog = new SttUsageLog
try
{
UserId = userId,
UserEmail = userEmail,
EndpointId = endpoint.Id,
Provider = endpoint.ProviderType,
ModelId = endpoint.ModelId,
AudioDurationSeconds = Math.Round(finalDuration, 2),
CalculatedCost = cost,
LatencyMs = (int)epSw.ElapsedMilliseconds,
StatusCode = 200,
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
CreatedAt = DateTime.UtcNow
};
_db.SttUsageLogs.Add(usageLog);
await _db.SaveChangesAsync();
}
catch (Exception dbEx)
{
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
var usageLog = new SttUsageLog
{
UserId = userId,
UserEmail = userEmail,
EndpointId = endpoint.Id,
Provider = endpoint.ProviderType,
ModelId = endpoint.ModelId,
AudioDurationSeconds = Math.Round(finalDuration, 2),
CalculatedCost = cost,
LatencyMs = (int)epSw.ElapsedMilliseconds,
StatusCode = 200,
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
CreatedAt = DateTime.UtcNow
};
_db.SttUsageLogs.Add(usageLog);
await _db.SaveChangesAsync();
}
catch (Exception dbEx)
{
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
}
}
return new SttTranscribeResponse(
@ -176,18 +190,19 @@ public class SttProxyService : ISttProxyService
{
epSw.Stop();
lastException = ex;
_logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...",
endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds);
var failureType = GetSafeFailureType(ex);
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
// Record error to ErrorLogs
try
{
_db.ErrorLogs.Add(new ServerErrorLog
{
ErrorType = $"SttProviderError:{endpoint.ProviderType}",
Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}",
StackTrace = ex.StackTrace,
Endpoint = endpoint.EndpointUrl,
ErrorType = "SttProviderError",
Message = $"STT provider request failed ({failureType}).",
StackTrace = null,
Endpoint = null,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
@ -200,19 +215,9 @@ public class SttProxyService : ISttProxyService
}
totalSw.Stop();
_logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds);
// Standalone graceful mock echo response if no cloud API keys configured or network is isolated
return new SttTranscribeResponse(
Text: $"[D3RO Cloud STT — Voice Transcribed] 음성 전사 완료 ({effectiveFileName}, {Math.Round(durationSeconds, 1)}초)",
Confidence: 0.95,
Language: request.Language ?? "ko",
DurationSeconds: Math.Round(durationSeconds, 2),
Provider: "fallback-local",
ModelId: "whisper-local",
LatencyMs: totalSw.ElapsedMilliseconds,
Cost: 0m
);
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
throw new SttProviderUnavailableException(providerAttempted);
}
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
@ -229,15 +234,9 @@ public class SttProxyService : ISttProxyService
var providerType = endpoint.ProviderType.ToLowerInvariant();
var apiKey = endpoint.ApiKey?.Trim() ?? "";
// If no API key configured or local mock
if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom")
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
{
return (
$"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.",
0.99,
request.Language ?? endpoint.Language ?? "ko",
EstimateAudioDuration(audioBytes, contentType)
);
throw new InvalidOperationException("STT provider credentials are not configured.");
}
switch (providerType)
@ -325,17 +324,17 @@ public class SttProxyService : ISttProxyService
if (!httpResponse.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT Upstream {endpoint.ProviderType} returned HTTP {httpResponse.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {httpResponse.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
if (root.TryGetProperty("text", out var textProp))
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
{
text = textProp.GetString() ?? "";
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProp.GetString() ?? "";
var duration = 0.0;
if (root.TryGetProperty("duration", out var durProp))
@ -385,28 +384,29 @@ public class SttProxyService : ISttProxyService
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Deepgram returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var transcript = "";
string transcript;
var confidence = 0.95;
var duration = 0.0;
if (root.TryGetProperty("results", out var results) &&
results.TryGetProperty("channels", out var channels) &&
channels.GetArrayLength() > 0)
if (!root.TryGetProperty("results", out var results) ||
!results.TryGetProperty("channels", out var channels) ||
channels.GetArrayLength() == 0 ||
!channels[0].TryGetProperty("alternatives", out var alternatives) ||
alternatives.GetArrayLength() == 0 ||
!alternatives[0].TryGetProperty("transcript", out var transcriptProperty) ||
transcriptProperty.ValueKind != JsonValueKind.String)
{
var ch0 = channels[0];
if (ch0.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
{
var alt0 = alts[0];
if (alt0.TryGetProperty("transcript", out var tProp)) transcript = tProp.GetString() ?? "";
if (alt0.TryGetProperty("confidence", out var cProp)) confidence = cProp.GetDouble();
}
throw new JsonException("STT upstream response did not contain a transcript.");
}
transcript = transcriptProperty.GetString() ?? "";
if (alternatives[0].TryGetProperty("confidence", out var confidenceProperty))
confidence = confidenceProperty.GetDouble();
if (root.TryGetProperty("metadata", out var meta) && meta.TryGetProperty("duration", out var dProp))
{
@ -431,9 +431,6 @@ public class SttProxyService : ISttProxyService
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
{
var apiKey = endpoint.ApiKey;
var url = endpoint.EndpointUrl.Contains("?")
? $"{endpoint.EndpointUrl}&key={apiKey}"
: $"{endpoint.EndpointUrl}?key={apiKey}";
var geminiPayload = new
{
@ -460,32 +457,33 @@ public class SttProxyService : ISttProxyService
}
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Google Gemini STT returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0)
if (!root.TryGetProperty("candidates", out var cands) ||
cands.GetArrayLength() == 0 ||
!cands[0].TryGetProperty("content", out var content) ||
!content.TryGetProperty("parts", out var parts) ||
parts.GetArrayLength() == 0 ||
!parts[0].TryGetProperty("text", out var textProperty) ||
textProperty.ValueKind != JsonValueKind.String)
{
var cand0 = cands[0];
if (cand0.TryGetProperty("content", out var content) &&
content.TryGetProperty("parts", out var parts) &&
parts.GetArrayLength() > 0)
{
text = parts[0].GetProperty("text").GetString() ?? "";
}
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProperty.GetString() ?? "";
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
}
@ -493,9 +491,6 @@ public class SttProxyService : ISttProxyService
{
// Google Cloud Speech-to-Text v1
var apiKey = endpoint.ApiKey;
var url = endpoint.EndpointUrl.Contains("?")
? $"{endpoint.EndpointUrl}&key={apiKey}"
: $"{endpoint.EndpointUrl}?key={apiKey}";
var gcpPayload = new
{
@ -512,17 +507,18 @@ public class SttProxyService : ISttProxyService
}
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Google Cloud STT returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
@ -530,7 +526,12 @@ public class SttProxyService : ISttProxyService
var transcript = "";
var confidence = 0.95;
if (root.TryGetProperty("results", out var results) && results.GetArrayLength() > 0)
if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
{
throw new JsonException("STT upstream response did not contain transcription results.");
}
if (results.GetArrayLength() > 0)
{
var sb = new StringBuilder();
foreach (var res in results.EnumerateArray())
@ -568,7 +569,7 @@ public class SttProxyService : ISttProxyService
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
if (!uploadResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"AssemblyAI upload failed: {uploadBody}");
throw new HttpRequestException($"STT upstream upload returned HTTP {uploadResp.StatusCode}.");
}
using var uploadDoc = JsonDocument.Parse(uploadBody);
@ -594,7 +595,7 @@ public class SttProxyService : ISttProxyService
var transBody = await transResp.Content.ReadAsStringAsync();
if (!transResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"AssemblyAI transcript job failed: {transBody}");
throw new HttpRequestException($"STT upstream job returned HTTP {transResp.StatusCode}.");
}
using var transDoc = JsonDocument.Parse(transBody);
@ -610,12 +611,22 @@ public class SttProxyService : ISttProxyService
var pollResp = await client.SendAsync(pollReq);
var pollBody = await pollResp.Content.ReadAsStringAsync();
if (!pollResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream poll returned HTTP {pollResp.StatusCode}.");
}
using var pollDoc = JsonDocument.Parse(pollBody);
var status = pollDoc.RootElement.GetProperty("status").GetString();
if (status == "completed")
{
var text = pollDoc.RootElement.GetProperty("text").GetString() ?? "";
if (!pollDoc.RootElement.TryGetProperty("text", out var textProperty) ||
textProperty.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProperty.GetString() ?? "";
var confidence = 0.95;
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
var duration = 0.0;
@ -624,8 +635,7 @@ public class SttProxyService : ISttProxyService
}
if (status == "error")
{
var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error";
throw new HttpRequestException($"AssemblyAI processing error: {err}");
throw new HttpRequestException("STT upstream processing failed.");
}
}
@ -663,24 +673,32 @@ public class SttProxyService : ISttProxyService
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Azure Speech STT returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
string text;
var confidence = 0.95;
if (root.TryGetProperty("DisplayText", out var dt))
if (root.TryGetProperty("DisplayText", out var dt) && dt.ValueKind == JsonValueKind.String)
{
text = dt.GetString() ?? "";
}
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
{
var best = nbest[0];
if (best.TryGetProperty("Display", out var d)) text = d.GetString() ?? "";
if (!best.TryGetProperty("Display", out var display) || display.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
text = display.GetString() ?? "";
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
}
else
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
}
@ -717,14 +735,17 @@ public class SttProxyService : ISttProxyService
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Local STT Sidecar returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
if (root.TryGetProperty("text", out var tProp)) text = tProp.GetString() ?? "";
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = tProp.GetString() ?? "";
var duration = EstimateAudioDuration(audioBytes, contentType);
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
@ -789,7 +810,7 @@ public class SttProxyService : ISttProxyService
sw.Stop();
return new SttTestResultDto(
Success: false,
Message: $"연결 실패: {ex.Message}",
Message: $"연결 실패: {GetSafeFailureType(ex)}",
LatencyMs: sw.ElapsedMilliseconds,
TranscriptPreview: null,
Provider: endpoint.ProviderType,
@ -980,6 +1001,32 @@ public class SttProxyService : ISttProxyService
.ToListAsync();
}
private static bool HasRequiredProviderConfiguration(SttProviderEndpoint endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint.EndpointUrl)) return false;
var providerType = endpoint.ProviderType.Trim().ToLowerInvariant();
return !RequiresApiKey(providerType) || !string.IsNullOrWhiteSpace(endpoint.ApiKey);
}
private static bool RequiresApiKey(string providerType)
{
return providerType is "groq" or "openai" or "deepgram" or "google" or "assemblyai" or "azure";
}
private static string GetSafeFailureType(Exception? exception)
{
return exception switch
{
TimeoutException or TaskCanceledException => "timeout",
HttpRequestException => "upstream_http_error",
JsonException => "invalid_upstream_response",
InvalidOperationException => "provider_not_configured",
null => "not_configured",
_ => "provider_error"
};
}
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
{
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;

View file

@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.d3ro.voice",
"sha256_cert_fingerprints": [
"01:00:19:21:DB:4F:33:40:85:CE:21:E4:B8:DE:CC:BD:71:DA:87:67:C5:6E:3B:59:83:2A:A1:C8:29:EA:0D:AB"
]
}
}
]

View file

@ -0,0 +1,345 @@
:root {
--ink: #08090c;
--panel: #11141c;
--panel-raised: #171b25;
--line: #303647;
--line-soft: rgba(255, 255, 255, 0.07);
--text: #f4f4f5;
--muted: #a1a1aa;
--faint: #71717a;
--accent: #f25b29;
--accent-hot: #ff7342;
--success: #4ade80;
--danger: #fb7185;
font-family: "Pretendard Variable", Pretendard, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--ink);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
background:
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
radial-gradient(circle at 72% 18%, rgba(242, 91, 41, 0.13), transparent 34rem),
var(--ink);
background-size: 40px 40px, 40px 40px, auto, auto;
}
button,
a {
font: inherit;
}
a {
color: inherit;
}
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 18px;
}
.invite-panel {
width: min(100%, 720px);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(145deg, rgba(23, 27, 37, 0.96), rgba(12, 14, 20, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48), inset 0 1px rgba(255, 255, 255, 0.06);
}
.brand-row,
.copy-block,
.token-card,
.actions,
.support-row,
.error {
margin-inline: clamp(22px, 7vw, 64px);
}
.brand-row {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid var(--line-soft);
}
.brand,
.protocol,
.eyebrow,
.token-card dt,
.token-card dd {
font-family: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
}
.brand {
color: var(--text);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.16em;
text-decoration: none;
}
.protocol {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.12em;
}
.signal {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 12px rgba(74, 222, 128, 0.72);
}
.route-line {
height: 54px;
display: grid;
grid-template-columns: auto 1fr auto 1fr auto;
align-items: center;
padding-inline: clamp(22px, 7vw, 64px);
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--line-soft);
}
.route-node {
width: 9px;
height: 9px;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--panel);
}
.route-node--active {
border-color: var(--accent-hot);
background: var(--accent);
box-shadow: 0 0 18px rgba(242, 91, 41, 0.72);
}
.route-track {
height: 1px;
background: linear-gradient(90deg, var(--accent), var(--line));
}
.copy-block {
padding-top: clamp(42px, 8vw, 72px);
}
.eyebrow {
margin: 0 0 16px;
color: var(--accent-hot);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
}
h1 {
max-width: 600px;
margin: 0;
font-size: clamp(34px, 7.2vw, 60px);
font-weight: 790;
letter-spacing: -0.045em;
line-height: 1.08;
text-wrap: balance;
}
.description {
max-width: 590px;
margin: 26px 0 0;
color: #d4d4d8;
font-size: 16px;
line-height: 1.72;
word-break: keep-all;
}
.description--en {
margin-top: 8px;
color: var(--faint);
font-size: 13px;
}
.token-card {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-top: 38px;
padding: 1px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--line);
overflow: hidden;
}
.token-card div {
min-width: 0;
padding: 18px;
background: var(--panel-raised);
}
.token-card dt {
color: var(--faint);
font-size: 9px;
letter-spacing: 0.13em;
}
.token-card dd {
margin: 8px 0 0;
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.token-card dd[data-state="valid"] {
color: var(--success);
}
.token-card dd[data-state="invalid"] {
color: var(--danger);
}
.error {
margin-top: 18px;
padding: 13px 14px;
border: 1px solid rgba(251, 113, 133, 0.35);
border-radius: 8px;
color: #fecdd3;
background: rgba(251, 113, 133, 0.08);
font-size: 14px;
line-height: 1.5;
}
.actions {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 12px;
margin-top: 24px;
}
.button {
min-height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
padding: 0 18px;
font-weight: 750;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
}
.button--primary {
color: #fff;
background: var(--accent);
box-shadow: 0 10px 30px rgba(242, 91, 41, 0.2);
}
.button--primary:hover {
background: var(--accent-hot);
transform: translateY(-1px);
}
.button--secondary {
color: var(--text);
border-color: var(--line);
background: rgba(255, 255, 255, 0.035);
}
.button--secondary:hover {
border-color: #535f7f;
background: rgba(255, 255, 255, 0.06);
}
.button[aria-disabled="true"],
.button:disabled {
opacity: 0.42;
cursor: not-allowed;
pointer-events: none;
transform: none;
}
.button:focus-visible,
.brand:focus-visible,
.support-row a:focus-visible {
outline: 3px solid rgba(255, 115, 66, 0.78);
outline-offset: 3px;
}
.support-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 38px;
padding-block: 22px 28px;
border-top: 1px solid var(--line-soft);
color: var(--faint);
font-size: 13px;
}
.support-row a {
color: #d4d4d8;
font-weight: 650;
text-underline-offset: 4px;
}
@media (max-width: 560px) {
.shell {
align-items: start;
padding: 12px;
}
.invite-panel {
border-radius: 12px;
}
.brand-row {
min-height: 66px;
}
.protocol {
font-size: 8px;
}
.token-card,
.actions {
grid-template-columns: 1fr;
}
.support-row {
align-items: flex-start;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -0,0 +1,76 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#08090c" />
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
/>
<title>D3RO Voice — 팀 초대 열기</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/accept-invite.css" />
</head>
<body>
<main class="shell">
<section class="invite-panel" aria-labelledby="invite-title">
<header class="brand-row">
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
</header>
<div class="route-line" aria-hidden="true">
<span class="route-node route-node--active"></span>
<span class="route-track"></span>
<span class="route-node"></span>
<span class="route-track"></span>
<span class="route-node"></span>
</div>
<div class="copy-block">
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
<p class="description">
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
저장하거나 수락 결과를 만들지 않아.
</p>
<p class="description description--en" lang="en">
Acceptance is completed only after the app verifies your signed-in account and server permissions.
</p>
</div>
<dl class="token-card" aria-label="초대 링크 상태">
<div>
<dt>LINK STATUS</dt>
<dd id="invite-status" aria-live="polite">검증 중</dd>
</div>
<div>
<dt>TOKEN FINGERPRINT</dt>
<dd id="token-fingerprint"></dd>
</div>
</dl>
<p id="invite-error" class="error" role="alert" hidden></p>
<div class="actions">
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
D3RO Voice 앱 열기
</a>
<button id="copy-link" class="button button--secondary" type="button" disabled>
초대 링크 복사
</button>
</div>
<footer class="support-row">
<span>앱이 설치되지 않았어?</span>
<a href="/download.html">안전한 설치 파일 받기</a>
</footer>
</section>
</main>
<script src="/accept-invite.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,37 @@
(() => {
'use strict'
const tokenPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const status = document.getElementById('invite-status')
const fingerprint = document.getElementById('token-fingerprint')
const error = document.getElementById('invite-error')
const openApp = document.getElementById('open-app')
const copyLink = document.getElementById('copy-link')
const token = new URLSearchParams(window.location.search).get('token')?.trim() ?? ''
if (!tokenPattern.test(token)) {
status.textContent = '사용할 수 없는 링크'
status.dataset.state = 'invalid'
error.textContent = '초대 링크가 없거나 형식이 올바르지 않아. 새 초대 링크를 요청해.'
error.hidden = false
return
}
status.textContent = '형식 검증 완료 · 서버 확인 대기'
status.dataset.state = 'valid'
fingerprint.textContent = `${token.slice(0, 8)}${token.slice(-4)}`
openApp.href = `d3ro-voice://accept-invite?token=${encodeURIComponent(token)}`
openApp.removeAttribute('aria-disabled')
copyLink.disabled = false
copyLink.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(window.location.href)
copyLink.textContent = '복사했어'
window.setTimeout(() => { copyLink.textContent = '초대 링크 복사' }, 1800)
} catch {
error.textContent = '브라우저가 복사를 허용하지 않았어. 주소 표시줄에서 링크를 직접 복사해.'
error.hidden = false
}
})
})()

View file

@ -0,0 +1,76 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#08090c" />
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
/>
<title>D3RO Voice — 팀 초대 열기</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/accept-invite.css" />
</head>
<body>
<main class="shell">
<section class="invite-panel" aria-labelledby="invite-title">
<header class="brand-row">
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
</header>
<div class="route-line" aria-hidden="true">
<span class="route-node route-node--active"></span>
<span class="route-track"></span>
<span class="route-node"></span>
<span class="route-track"></span>
<span class="route-node"></span>
</div>
<div class="copy-block">
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
<p class="description">
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
저장하거나 수락 결과를 만들지 않아.
</p>
<p class="description description--en" lang="en">
Acceptance is completed only after the app verifies your signed-in account and server permissions.
</p>
</div>
<dl class="token-card" aria-label="초대 링크 상태">
<div>
<dt>LINK STATUS</dt>
<dd id="invite-status" aria-live="polite">검증 중</dd>
</div>
<div>
<dt>TOKEN FINGERPRINT</dt>
<dd id="token-fingerprint"></dd>
</div>
</dl>
<p id="invite-error" class="error" role="alert" hidden></p>
<div class="actions">
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
D3RO Voice 앱 열기
</a>
<button id="copy-link" class="button button--secondary" type="button" disabled>
초대 링크 복사
</button>
</div>
<footer class="support-row">
<span>앱이 설치되지 않았어?</span>
<a href="/download.html">안전한 설치 파일 받기</a>
</footer>
</section>
</main>
<script src="/accept-invite.js" defer></script>
</body>
</html>

View file

@ -1,489 +1,17 @@
<!DOCTYPE html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>D3RO Voice — Official Download Center & Release History</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700;800;900&family=Pretendard:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
d3ro: {
void: '#05070d',
base: '#090d19',
card: '#0f172a',
cardHover: '#141e36',
border: '#1e293b',
hairline: 'rgba(255, 255, 255, 0.08)',
accent: '#38bdf8',
accentHover: '#0ea5e9',
accentMuted: 'rgba(56, 189, 248, 0.12)',
green: '#22c55e',
amber: '#f59e0b',
text: {
bright: '#ffffff',
primary: '#f1f5f9',
secondary: '#94a3b8',
dim: '#64748b'
}
}
},
fontFamily: {
sans: ['Geist', 'Pretendard', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['Fira Code', 'monospace']
}
}
}
}
</script>
<style>
body {
background-color: #05070d;
color: #f1f5f9;
font-family: 'Geist', 'Pretendard', sans-serif;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 189, 248, 0.12), transparent 70%),
radial-gradient(circle at 100% 100%, rgba(15, 23, 42, 0.8), transparent 40%);
background-attachment: fixed;
}
.glass-surface {
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.07);
}
.glass-surface-interactive {
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.07);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.glass-surface-interactive:hover {
border-color: rgba(56, 189, 248, 0.35);
background: rgba(20, 30, 54, 0.85);
transform: translateY(-2px);
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.7), 0 0 30px -10px rgba(56, 189, 248, 0.2);
}
.pill-glow {
box-shadow: 0 0 20px -3px rgba(56, 189, 248, 0.4);
}
</style>
</head>
<body class="min-h-screen flex flex-col antialiased selection:bg-sky-500/30 selection:text-sky-200">
<!-- Header -->
<header class="sticky top-0 z-50 glass-surface border-b border-white/5 px-6 py-3.5 flex items-center justify-between">
<div class="flex items-center gap-3.5">
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-sky-500 to-blue-600 flex items-center justify-center font-black text-white text-xs shadow-md shadow-sky-500/30">
D3
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-extrabold tracking-tight text-sm text-white">D3RO VOICE</span>
<span class="px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-sky-500/10 text-sky-400 border border-sky-500/25">v1.0.0 STABLE</span>
</div>
</div>
</div>
<nav class="hidden md:flex items-center gap-6 text-xs font-medium text-slate-400">
<a href="/" class="hover:text-white transition-colors">Overview</a>
<a href="#changelog" class="hover:text-white transition-colors">Changelog</a>
<a href="#integrity" class="hover:text-white transition-colors">SHA-256 Verifier</a>
<a href="https://git.chanpaca.net/yunchan/d3ro-voice" target="_blank" class="flex items-center gap-1 text-sky-400 hover:text-sky-300 font-mono">
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i> Forgejo Git
</a>
</nav>
<div class="flex items-center gap-3">
<a href="http://admin.chanpaca.net:3001" target="_blank" class="px-3.5 py-1.5 rounded-lg glass-surface hover:border-sky-500/40 text-sky-300 text-xs font-mono font-semibold flex items-center gap-1.5 transition-all">
<i data-lucide="layout-dashboard" class="w-3.5 h-3.5"></i> Admin CRM
</a>
</div>
</header>
<!-- Hero & Primary OS Download Section -->
<main class="flex-1 max-w-6xl mx-auto w-full px-6 pt-12 pb-24">
<!-- Hero Header (Strict 2-line headline, <20 words subtext, no clutter) -->
<div class="text-center max-w-2xl mx-auto mb-10">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-sky-500/10 border border-sky-500/25 text-sky-400 text-xs font-mono font-semibold mb-4">
<span class="w-1.5 h-1.5 rounded-full bg-sky-400 animate-pulse"></span>
OFFICIAL RELEASE • ZERO-LATENCY LOCAL WHISPER
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl font-black tracking-tight text-white mb-3">
Download <span class="bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">D3RO Voice</span> for Desktop
</h1>
<p class="text-sm sm:text-base text-slate-400 leading-relaxed">
100% on-device local transcription, intelligent meeting minutes, and multi-network ad rewards.
</p>
</div>
<!-- Primary Hero Download Box (Auto-detected OS card) -->
<div class="max-w-xl mx-auto glass-surface p-6 sm:p-8 rounded-2xl border border-sky-500/30 relative overflow-hidden mb-16 shadow-2xl shadow-sky-950/40">
<div class="absolute -right-16 -top-16 w-48 h-48 bg-sky-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="flex items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-3.5">
<div id="osIcon" class="w-12 h-12 rounded-xl bg-sky-500/15 border border-sky-500/30 flex items-center justify-center text-sky-400">
<i data-lucide="monitor" class="w-6 h-6"></i>
</div>
<div>
<div id="detectedOsTitle" class="font-bold text-base sm:text-lg text-white">Windows 64-bit Installer</div>
<div id="detectedOsMeta" class="text-xs text-slate-400 font-mono">D3RO-Voice-Setup-1.0.0-x64.exe • 102 MB • NSIS</div>
</div>
</div>
<span class="px-2.5 py-1 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 uppercase tracking-wider">
Recommended
</span>
</div>
<!-- Single Primary Action Button (No Wrap, High Contrast WCAG AA) -->
<a id="primaryDownloadBtn" href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" download class="w-full py-3.5 px-6 rounded-xl bg-sky-400 hover:bg-sky-300 text-slate-950 font-bold text-sm flex items-center justify-center gap-2 transition-all transform active:scale-[0.98] shadow-lg shadow-sky-500/20">
<i data-lucide="download" class="w-4 h-4"></i>
<span id="downloadBtnText">Download for Windows (v1.0.0)</span>
</a>
<!-- Quick Verify Strip -->
<div class="mt-5 pt-4 border-t border-white/5 flex items-center justify-between text-xs text-slate-400">
<div class="flex items-center gap-1.5 font-mono truncate max-w-[320px]">
<span>SHA256:</span>
<span class="text-slate-300 truncate">b0ac051443151a2e34e8...</span>
<button onclick="copyHash('b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2')" class="text-sky-400 hover:text-sky-300 underline font-sans text-[11px] ml-1">Copy</button>
</div>
<span class="text-emerald-400 text-[11px] font-mono flex items-center gap-1">
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i> Signed & Verified
</span>
</div>
</div>
<!-- Platform Packages Grid (Bento Structure with Asymmetry) -->
<div class="mb-20">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-white">All Platform Releases</h2>
<p class="text-xs text-slate-400">Optimized standalone packages for desktop operating systems.</p>
</div>
<div class="flex items-center gap-2 text-xs font-mono text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> Channel: latest.yml Active
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<!-- Windows Card -->
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
<div>
<div class="flex items-center justify-between mb-4">
<div class="w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400">
<i data-lucide="layout-grid" class="w-5 h-5"></i>
</div>
<span class="text-[11px] font-mono text-slate-400">Windows 10 / 11 x64</span>
</div>
<h3 class="font-bold text-base text-white mb-1">Windows</h3>
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
NSIS one-click installer with background delta updates and DirectML GPU acceleration.
</p>
<div class="space-y-2 mb-6">
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/30 transition-all text-xs text-white">
<div class="flex items-center gap-2 font-medium">
<i data-lucide="download" class="w-3.5 h-3.5 text-sky-400"></i>
<span>Setup Installer (.exe)</span>
</div>
<span class="font-mono text-slate-400 text-[11px]">102 MB</span>
</a>
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe.blockmap" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
<span class="font-mono">Blockmap (.blockmap)</span>
<span class="text-[10px]">105 KB</span>
</a>
</div>
</div>
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
<span>Min: 4GB RAM / 1GB Disk</span>
</div>
</div>
<!-- macOS Card -->
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
<div>
<div class="flex items-center justify-between mb-4">
<div class="w-9 h-9 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<i data-lucide="apple" class="w-5 h-5"></i>
</div>
<span class="text-[11px] font-mono text-slate-400">macOS 12.0+</span>
</div>
<h3 class="font-bold text-base text-white mb-1">macOS (Apple Silicon & Intel)</h3>
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
Metal accelerated build for Apple Silicon M1/M2/M3/M4 and universal Intel DMG.
</p>
<div class="space-y-2 mb-6">
<a href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-purple-500/10 border border-white/5 hover:border-purple-500/30 transition-all text-xs text-white">
<div class="flex items-center gap-2 font-medium">
<i data-lucide="download" class="w-3.5 h-3.5 text-purple-400"></i>
<span>Apple Silicon DMG (.dmg)</span>
</div>
<span class="font-mono text-slate-400 text-[11px]">98 MB</span>
</a>
<a href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64-mac.zip" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
<span class="font-mono">Portable Zip (.zip)</span>
<span class="text-[10px]">96 MB</span>
</a>
</div>
</div>
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
<span>Metal Acceleration Ready</span>
</div>
</div>
<!-- NAS & Server Card -->
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
<div>
<div class="flex items-center justify-between mb-4">
<div class="w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
<i data-lucide="server" class="w-5 h-5"></i>
</div>
<span class="text-[11px] font-mono text-slate-400">Docker / Synology NAS</span>
</div>
<h3 class="font-bold text-base text-white mb-1">Synology NAS & Docker</h3>
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
Self-hosted private deployment package for NAS Container Manager and CRM services.
</p>
<div class="space-y-2 mb-6">
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docker-compose.nas.yml" target="_blank" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-emerald-500/10 border border-white/5 hover:border-emerald-500/30 transition-all text-xs text-white">
<div class="flex items-center gap-2 font-medium">
<i data-lucide="file-code" class="w-3.5 h-3.5 text-emerald-400"></i>
<span>docker-compose.nas.yml</span>
</div>
<span class="font-mono text-emerald-400 text-[11px]">Source →</span>
</a>
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docs/deployment/nas-deployment-guide.md" target="_blank" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
<span>NAS Deployment Manual</span>
<span class="text-[10px] text-slate-400">Guide →</span>
</a>
</div>
</div>
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
<span>DSM 7.2+ Container Manager</span>
</div>
</div>
</div>
</div>
<!-- Client-Side SHA-256 Verifier (Security & Integrity) -->
<div id="integrity" class="glass-surface p-6 sm:p-8 rounded-2xl border border-sky-500/20 mb-20">
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
<div>
<div class="flex items-center gap-2 text-sky-400 text-xs font-mono font-bold mb-1">
<i data-lucide="hash" class="w-4 h-4"></i>
CLIENT-SIDE CRYPTOGRAPHIC VERIFICATION
</div>
<h3 class="text-lg font-bold text-white">SHA-256 Binary Integrity Verifier</h3>
<p class="text-xs text-slate-400">Drag & drop your downloaded installer file to compute and compare hash client-side.</p>
</div>
<button onclick="document.getElementById('fileVerifierInput').click()" class="px-3.5 py-2 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 text-xs font-semibold flex items-center gap-2 transition-all">
<i data-lucide="file-check" class="w-4 h-4"></i> Select File to Verify
</button>
<input type="file" id="fileVerifierInput" class="hidden" onchange="handleFileVerify(event)">
</div>
<div id="dropZone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleFileDrop(event)" class="border-2 border-dashed border-slate-700/80 rounded-xl p-6 sm:p-8 text-center transition-all bg-black/20">
<div id="verifyIdleState">
<i data-lucide="upload-cloud" class="w-8 h-8 text-slate-500 mx-auto mb-2"></i>
<p class="text-xs font-medium text-slate-300 mb-1">Drop installer (.exe / .dmg / .zip) here</p>
<p class="text-[11px] text-slate-500 font-mono">Calculated locally in browser via WebCrypto API (no upload)</p>
</div>
<div id="verifyResultState" class="hidden text-left space-y-3 font-mono text-xs">
<div class="flex items-center justify-between">
<span class="text-slate-300 font-bold" id="verifyFileName">D3RO-Voice-Setup-1.0.0-x64.exe</span>
<span id="verifyBadge" class="px-2.5 py-0.5 rounded text-[11px] font-bold"></span>
</div>
<div class="p-3 rounded-lg bg-black/40 border border-white/5 space-y-1.5">
<div class="text-slate-400">Computed Hash: <span class="text-sky-300" id="calculatedHash">-</span></div>
<div class="text-slate-400">Official Hash: <span class="text-emerald-400" id="officialHash">b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span></div>
</div>
</div>
</div>
</div>
<!-- Release Changelog Timeline -->
<div id="changelog" class="mb-16">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-white">Release Changelog & History</h2>
<p class="text-xs text-slate-400">Complete version archives and feature milestones.</p>
</div>
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/releases" target="_blank" class="text-xs text-sky-400 hover:text-sky-300 font-mono underline flex items-center gap-1">
Full Git Tags Archive →
</a>
</div>
<div class="space-y-4">
<!-- v1.0.0 Item -->
<div class="glass-surface p-6 rounded-2xl border-l-4 border-l-sky-400">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-3">
<span class="text-lg font-bold text-white font-mono">v1.0.0</span>
<span class="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30">
LATEST STABLE
</span>
<span class="text-xs text-slate-500 font-mono">2026-08-20</span>
</div>
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" class="px-3 py-1.5 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 text-xs font-semibold flex items-center gap-1.5 transition-all">
<i data-lucide="download" class="w-3.5 h-3.5"></i> Installer (.exe)
</a>
</div>
<div class="space-y-1.5 text-xs text-slate-300 leading-relaxed mb-4">
<p class="font-semibold text-white">Highlights & Features:</p>
<ul class="list-disc list-inside space-y-1 ml-2 text-slate-400">
<li><strong class="text-sky-300">10+ Ad Mediation System</strong>: Parallel header bidding auction (EthicalAds, Carbon, GAM, Playwire, AppLovin, Unity).</li>
<li><strong class="text-sky-300">Free Tier Rewarded Token Refill</strong>: 15s commercial playback grants +50 Cloud AI tokens.</li>
<li><strong class="text-sky-300">Forgejo CI/CD & Synology NAS Packaging</strong>: Multi-platform automated packaging and Docker CRM.</li>
<li><strong class="text-sky-300">100% Local Whisper Engine</strong>: Zero-latency offline speech transcription with hardware acceleration.</li>
</ul>
</div>
<div class="p-2.5 rounded-lg bg-black/30 font-mono text-[11px] text-slate-400 flex items-center justify-between">
<span class="truncate">SHA-256: b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span>
<span class="text-sky-400">102 MB</span>
</div>
</div>
<!-- v0.2.1-alpha Item -->
<div class="glass-surface p-5 rounded-xl border-l-4 border-l-slate-700 opacity-80">
<div class="flex items-center gap-3 mb-2">
<span class="text-base font-bold text-white font-mono">v0.2.1-alpha</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono bg-white/5 text-slate-400 border border-white/10">PRE-RELEASE</span>
<span class="text-xs text-slate-500 font-mono">2026-08-15</span>
</div>
<p class="text-xs text-slate-400">
Multi-provider cloud STT drivers dispatch (Deepgram, AssemblyAI, Groq) and cryptographic license verification engine.
</p>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="glass-surface border-t border-white/5 py-6 px-6 text-xs text-slate-500">
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3">
<div>
<span class="font-bold text-white">D3RO Voice</span> • Copyright © 2026 D3RO. All rights reserved.
</div>
<div class="flex items-center gap-4">
<a href="/privacy" class="hover:text-white transition-colors">Privacy Policy</a>
<a href="/terms" class="hover:text-white transition-colors">Terms</a>
<a href="https://git.chanpaca.net" target="_blank" class="text-sky-400 hover:text-sky-300 font-mono">git.chanpaca.net</a>
</div>
</div>
</footer>
<script>
lucide.createIcons();
const OFFICIAL_HASH = 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2';
// Auto OS Detection
(function detectOS() {
const userAgent = window.navigator.userAgent.toLowerCase();
const titleEl = document.getElementById('detectedOsTitle');
const metaEl = document.getElementById('detectedOsMeta');
const btnEl = document.getElementById('primaryDownloadBtn');
const btnTextEl = document.getElementById('downloadBtnText');
const iconEl = document.getElementById('osIcon');
if (userAgent.includes('mac') || userAgent.includes('darwin')) {
titleEl.textContent = 'macOS Apple Silicon Installer';
metaEl.textContent = 'D3RO-Voice-1.0.0-arm64.dmg • 98 MB • Apple Silicon (M1/M2/M3/M4)';
btnEl.href = '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg';
btnTextEl.textContent = 'Download for macOS (v1.0.0)';
iconEl.innerHTML = '<i data-lucide="apple" class="w-6 h-6"></i>';
} else if (userAgent.includes('linux')) {
titleEl.textContent = 'Linux Universal Package';
metaEl.textContent = 'D3RO-Voice-1.0.0.AppImage • 105 MB • AppImage';
btnEl.href = '/releases/1.0.0/D3RO-Voice-1.0.0.AppImage';
btnTextEl.textContent = 'Download for Linux (v1.0.0)';
}
lucide.createIcons();
})();
function copyHash(hash) {
navigator.clipboard.writeText(hash).then(() => {
alert('SHA-256 Hash copied to clipboard:\n' + hash);
});
}
function handleDragOver(e) {
e.preventDefault();
document.getElementById('dropZone').classList.add('border-sky-400', 'bg-sky-500/10');
}
function handleDragLeave(e) {
e.preventDefault();
document.getElementById('dropZone').classList.remove('border-sky-400', 'bg-sky-500/10');
}
function handleFileDrop(e) {
e.preventDefault();
document.getElementById('dropZone').classList.remove('border-sky-400', 'bg-sky-500/10');
const files = e.dataTransfer.files;
if (files.length > 0) calculateFileHash(files[0]);
}
function handleFileVerify(e) {
const files = e.target.files;
if (files.length > 0) calculateFileHash(files[0]);
}
async function calculateFileHash(file) {
document.getElementById('verifyIdleState').classList.add('hidden');
document.getElementById('verifyResultState').classList.remove('hidden');
document.getElementById('verifyFileName').textContent = file.name + ' (' + (file.size / (1024*1024)).toFixed(1) + ' MB)';
document.getElementById('calculatedHash').textContent = 'Computing SHA-256 hash locally...';
const badge = document.getElementById('verifyBadge');
badge.textContent = 'Hashing...';
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById('calculatedHash').textContent = hashHex;
if (hashHex === OFFICIAL_HASH) {
badge.textContent = '✓ OFFICIAL MATCH (GENUINE)';
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
} else {
badge.textContent = '✓ LOCAL HASH COMPUTED';
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30';
}
}
</script>
</body>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
<title>D3RO Voice 릴리스 준비</title>
</head>
<body>
<main>
<h1>D3RO Voice 릴리스 준비</h1>
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
</main>
</body>
</html>

View file

@ -1,19 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="D3RO Voice - Fully local AI voice assistant. Whisper + Ollama powered, zero cloud, 100% private." />
<meta name="theme-color" content="#f25b29" />
<title>D3RO Voice — Local AI Voice Assistant</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="./assets/index-D7M5UQvT.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BLH9FjGS.css">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
<title>D3RO Voice 릴리스 준비</title>
</head>
<body class="bg-surface-950 text-white antialiased">
<div id="root"></div>
<body>
<main>
<h1>D3RO Voice 릴리스 준비</h1>
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
</main>
</body>
</html>

View file

@ -3,7 +3,7 @@ productName: D3RO Voice
copyright: Copyright © 2026 D3RO
# monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어
# 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정.
electronVersion: "33.3.0"
electronVersion: "33.4.11"
directories:
# buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회.
@ -25,7 +25,7 @@ files:
# ────────────────────────────────────────────────────────────────────
publish:
provider: generic
url: "https://d3ro.chanpaca.net/releases/1.0.0"
url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest"
# prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 —
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.

View file

@ -1,3 +1,5 @@
import { createPublicKey } from 'crypto'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
@ -9,9 +11,19 @@ const sharedAlias = {
}
const workspaceExclude = ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n']
const licensePublicKeyPath = resolve(__dirname, 'resources/license/production-public.pem')
const licensePublicKey = readFileSync(licensePublicKeyPath, 'utf8').trim()
const parsedLicensePublicKey = createPublicKey(licensePublicKey)
if (parsedLicensePublicKey.asymmetricKeyType !== 'ed25519') {
throw new Error(`Desktop license public key must be Ed25519: ${licensePublicKeyPath}`)
}
export default defineConfig({
main: {
// 공개키만 main bundle에 주입한다. 대응 ADMIN_LICENSE_PRIVATE_KEY는 admin 서버 전용이다.
define: {
'process.env.D3RO_LICENSE_PUBLIC_KEY': JSON.stringify(licensePublicKey)
},
plugins: [
externalizeDepsPlugin({
exclude: ['electron-store', ...workspaceExclude]

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/desktop",
"version": "1.0.0",
"version": "1.1.0",
"productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js",
@ -31,7 +31,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"electron": "^33.3.0",
"electron": "33.4.11",
"electron-builder": "^26.8.1",
"electron-vite": "^2.3.0",
"vite": "^5.4.0",

View file

@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEApTW7quHfh/UgtUGUgSykuUuERFtfIpQqfenZd1kuDcA=
-----END PUBLIC KEY-----

View file

@ -1,12 +1,10 @@
// src/main/bootstrap.ts — 초기화 시퀀스
import { join } from 'path'
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService, configGet, configSet } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
import { getVoiceModeService } from './services/VoiceModeService'
import { getPremiumLLMService } from './services/PremiumLLMService'
import { startLocalLLMAvailability } from './services/LocalLLMService'
import { getHistoryService } from './services/HistoryService'
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'

View file

@ -1,6 +1,7 @@
// src/main/index.ts — 앱 진입점
import { app } from 'electron'
import electronLog from 'electron-log'
import path from 'path'
// 앱 식별자 명시 (기본값 'Electron'과 충돌 방지)
@ -24,7 +25,7 @@ process.stderr?.on?.('error', () => { /* ignore EPIPE */ })
process.on('uncaughtException', (err) => {
if (err.message?.includes('EPIPE')) return // EPIPE는 무시
// 기타 예외는 로그만
try { require('electron-log').default?.error?.('Uncaught:', err) } catch { /* noop */ }
try { electronLog.error('Uncaught:', err) } catch { /* noop */ }
})
// V2-4: deep link 프로토콜 등록 — d3ro-voice://auth-callback?code=...
@ -131,7 +132,7 @@ async function handleDeepLink(url: string): Promise<void> {
const gotTheLock = process.env.NODE_ENV === 'test' ? true : app.requestSingleInstanceLock()
if (!gotTheLock) {
console.log('[D3RO Voice] Another instance is already running. Existing window focused.')
deepLinkLogger.info('Another instance is already running. Existing window focused.')
app.exit(0)
} else {
app.on('second-instance', (_event, argv) => {

View file

@ -6,11 +6,8 @@ import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getAudioCaptureService, calculateRMS } from '../services/AudioCaptureService'
import { getMainWindow } from '../windows/WindowManager'
import { configGet, configSet } from '../services/ConfigService'
import { getLogger } from '../services/LoggerService'
import type { SetDeviceParams } from '@d3ro/core/types'
const logger = getLogger('audio-handlers')
let testTimer: ReturnType<typeof setTimeout> | null = null
let testAudioHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
let testLevelInterval: ReturnType<typeof setInterval> | null = null

View file

@ -1,48 +1,246 @@
// apps/desktop/src/main/ipc/payment-handlers.ts
// IPC handlers for Multi-PG Payment & Billing
// Authenticated, server-authoritative checkout and subscription IPC handlers.
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ok } from '@d3ro/core/errors'
import { ErrorCode, ipcError, ok, type IPCResult } from '@d3ro/core/errors'
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
import { getLicenseService } from '../services/LicenseService'
import { getCloudSyncService } from '../services/CloudSyncService'
const CHECKOUT_FUNCTION = 'stripe-checkout'
const SUBSCRIPTION_FUNCTION = 'payple-manage'
const CHECKOUT_SUCCESS_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=success'
const CHECKOUT_CANCEL_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled'
const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com'
const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const SERVER_ENTITLED_STATUSES = new Set([
'active',
'trialing',
'past_due',
'canceled',
'cancelled'
])
export const PAYMENT_REQUEST_TIMEOUT_MS = 15_000
const CHECKOUT_IDEMPOTENCY_TTL_MS = 5 * 60_000
type PaidTier = 'pro' | 'pro_plus'
type CloudSyncService = ReturnType<typeof getCloudSyncService>
interface CheckoutAttempt {
idempotencyKey: string
expiresAt: number
inFlight?: Promise<IPCResult<CheckoutSessionResult>>
result?: CheckoutSessionResult
}
interface SubscriptionStatus {
tier: 'free' | PaidTier
valid: boolean
expiresAt: number | null
}
class PaymentTimeoutError extends Error {}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseCheckoutParams(value: unknown): { tier: PaidTier } | null {
if (!isRecord(value)) return null
if (value.provider !== 'stripe') return null
if (value.tier !== 'pro' && value.tier !== 'pro_plus') return null
return { tier: value.tier }
}
function getAuthenticatedCloud(): { cloud: CloudSyncService; userId: string } | null {
const cloud = getCloudSyncService()
const user = cloud.getUser()
if (!cloud.isAuthenticated() || !user || !UUID_PATTERN.test(user.id)) return null
return { cloud, userId: user.id }
}
async function invokeWithDeadline(
cloud: CloudSyncService,
name: string,
body: Record<string, unknown>
): Promise<{ data: unknown; error: { message: string } | null }> {
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
controller.abort()
reject(new PaymentTimeoutError('Payment server request timed out'))
}, PAYMENT_REQUEST_TIMEOUT_MS)
})
try {
return await Promise.race([
cloud.invokeFunction(name, body, {
signal: controller.signal,
timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS
}),
deadline
])
} finally {
if (timeout !== undefined) clearTimeout(timeout)
}
}
function parseTrustedCheckoutUrl(value: unknown): string | null {
if (!isRecord(value) || typeof value.url !== 'string') return null
try {
const url = new URL(value.url)
if (
url.protocol !== 'https:' ||
url.origin !== STRIPE_CHECKOUT_ORIGIN ||
url.username !== '' ||
url.password !== '' ||
!url.pathname.startsWith(STRIPE_CHECKOUT_PATH_PREFIX) ||
url.pathname.length <= STRIPE_CHECKOUT_PATH_PREFIX.length
)
return null
return url.toString()
} catch {
return null
}
}
function parseServerSubscription(value: unknown, now = Date.now()): SubscriptionStatus | null {
if (!isRecord(value)) return null
if (value.tier !== 'free' && value.tier !== 'pro' && value.tier !== 'pro_plus') return null
if (typeof value.status !== 'string' || value.status.length === 0) return null
let expiresAt: number | null = null
if (value.current_period_end !== null && value.current_period_end !== undefined) {
if (typeof value.current_period_end !== 'string') return null
const parsed = Date.parse(value.current_period_end)
if (!Number.isFinite(parsed)) return null
expiresAt = parsed
}
const paidTier = value.tier === 'pro' || value.tier === 'pro_plus'
const valid =
paidTier &&
SERVER_ENTITLED_STATUSES.has(value.status) &&
(expiresAt === null || expiresAt > now)
return {
tier: valid ? value.tier : 'free',
valid,
expiresAt
}
}
async function readServerSubscription(cloud: CloudSyncService): Promise<SubscriptionStatus> {
const response = await invokeWithDeadline(cloud, SUBSCRIPTION_FUNCTION, { action: 'info' })
if (response.error) throw new Error('subscription_provider_error')
const status = parseServerSubscription(response.data)
if (!status) throw new Error('subscription_response_invalid')
return status
}
function paymentFailure<T>(error: unknown, fallback: string): IPCResult<T> {
if (error instanceof PaymentTimeoutError) {
return ipcError(ErrorCode.UnknownError, 'Payment server request timed out')
}
return ipcError(ErrorCode.UnknownError, fallback)
}
export function registerPaymentHandlers(): void {
// 1. Create Checkout Session
ipcMain.handle(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, async (_event, params: CheckoutSessionParams) => {
const isKrw = params.currency === 'KRW'
const session: CheckoutSessionResult = {
sessionId: `cs_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
checkoutUrl: isKrw
? `https://pay.tosspayments.com/v1/billing/${params.planId}`
: `https://checkout.stripe.com/c/pay/${params.planId}`,
provider: params.provider || (isKrw ? 'toss_payments' : 'stripe'),
orderId: `ORD-${Date.now()}`,
amount: params.amount,
currency: params.currency,
}
return ok(session)
})
const attempts = new Map<string, CheckoutAttempt>()
// 2. Verify Payment & Activate Tier
ipcMain.handle(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, async (_event, { tier }: { tier: string }) => {
ipcMain.handle(
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
async (_event, rawParams: CheckoutSessionParams) => {
const params = parseCheckoutParams(rawParams)
if (!params) {
return ipcError(ErrorCode.UnknownError, 'Invalid checkout request')
}
const authenticated = getAuthenticatedCloud()
if (!authenticated) {
return ipcError(ErrorCode.UnknownError, 'Sign in is required for checkout')
}
const now = Date.now()
for (const [key, attempt] of attempts) {
if (!attempt.inFlight && attempt.expiresAt <= now) attempts.delete(key)
}
const fingerprint = `${authenticated.userId}:stripe:${params.tier}`
let attempt = attempts.get(fingerprint)
if (!attempt || attempt.expiresAt <= now) {
attempt = {
idempotencyKey: `desktop:stripe-checkout:${randomUUID()}`,
expiresAt: now + CHECKOUT_IDEMPOTENCY_TTL_MS
}
attempts.set(fingerprint, attempt)
}
if (attempt.result) return ok(attempt.result)
if (attempt.inFlight) return attempt.inFlight
const currentAttempt = attempt
const operation = (async (): Promise<IPCResult<CheckoutSessionResult>> => {
try {
const response = await invokeWithDeadline(authenticated.cloud, CHECKOUT_FUNCTION, {
tier: params.tier,
success_url: CHECKOUT_SUCCESS_URL,
cancel_url: CHECKOUT_CANCEL_URL,
idempotency_key: currentAttempt.idempotencyKey
})
if (response.error) {
return ipcError(ErrorCode.UnknownError, 'Checkout service rejected the request')
}
const checkoutUrl = parseTrustedCheckoutUrl(response.data)
if (!checkoutUrl) {
return ipcError(ErrorCode.UnknownError, 'Checkout service returned an invalid URL')
}
const result: CheckoutSessionResult = {
checkoutUrl,
provider: 'stripe',
status: 'pending'
}
currentAttempt.result = result
return ok(result)
} catch (error) {
return paymentFailure(error, 'Checkout service is unavailable')
} finally {
currentAttempt.inFlight = undefined
}
})()
currentAttempt.inFlight = operation
return operation
}
)
ipcMain.handle(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, async () => {
const authenticated = getAuthenticatedCloud()
if (!authenticated) {
return ipcError(ErrorCode.UnknownError, 'Sign in is required to verify a subscription')
}
try {
const license = getLicenseService()
await license.activate(`D3RO-${tier.toUpperCase()}-${Date.now().toString(36).toUpperCase()}`)
} catch {
// ignore
const status = await readServerSubscription(authenticated.cloud)
return ok({ success: status.valid, activeTier: status.tier })
} catch (error) {
return paymentFailure(error, 'Subscription status is unavailable')
}
return ok({ success: true, activeTier: tier })
})
// 3. Get Subscription Status
ipcMain.handle(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS, async () => {
const license = getLicenseService()
const info = license.getLicenseInfo()
return ok({
tier: info.tier,
valid: info.valid,
expiresAt: info.expiresAt,
})
const authenticated = getAuthenticatedCloud()
if (!authenticated) {
return ipcError(ErrorCode.UnknownError, 'Sign in is required to read a subscription')
}
try {
return ok(await readServerSubscription(authenticated.cloud))
} catch (error) {
return paymentFailure(error, 'Subscription status is unavailable')
}
})
}

View file

@ -30,7 +30,7 @@ export function registerSTTHandlers(): void {
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
})
getSTTManager().on('provider-changed', (payload) => {
getSTTManager().on('provider-changed', (_payload) => {
safeSendToRenderer(IPC_CHANNELS.STT.STATUS_CHANGED, { status: getSTTManager().getStatus() })
})

View file

@ -1,7 +1,7 @@
// apps/desktop/src/main/ipc/support-handlers.ts
// IPC handlers for Customer Assistance, AI Helpdesk & Diagnostics
import { ipcMain } from 'electron'
import { app, ipcMain } from 'electron'
import os from 'os'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ok } from '@d3ro/core/errors'
@ -30,7 +30,7 @@ export function registerSupportHandlers(): void {
const payload: SystemDiagnosticsPayload = {
machineId: `d3ro-${os.hostname().toLowerCase().slice(0, 12)}`,
appVersion: '1.0.0-release',
appVersion: app.getVersion(),
platform: `${os.platform()} (${os.arch()})`,
osRelease: `${os.type()} ${os.release()}`,
activeAudioDevice: (configGet('audio.selectedDevice') as string) || 'Default System Microphone',

View file

@ -64,7 +64,7 @@ export function registerVoiceHandlers(): void {
return ipcSuccess(getVoiceModeService().getState())
})
ipcMain.handle(IPC_CHANNELS.VOICE.SET_MODE, async (_event, params: SetVoiceModeParams) => {
ipcMain.handle(IPC_CHANNELS.VOICE.SET_MODE, async (_event, _params: SetVoiceModeParams) => {
// Phase 2: 모드만 설정에 저장 (실제 모드 전환은 핫키에서 처리)
return ipcSuccess(undefined)
})

View file

@ -3,7 +3,6 @@
// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
import { EventEmitter } from 'events'
import path from 'path'
import { existsSync } from 'fs'
import { spawn, type ChildProcess } from 'child_process'
import type { Readable } from 'stream'

View file

@ -3,6 +3,7 @@
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
import { EventEmitter } from 'events'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
import { getSoundEffectService } from './SoundEffectService'
@ -485,7 +486,7 @@ class CaptionService extends EventEmitter {
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
appVersion: '1.0.0',
appVersion: app.getVersion(),
})
logger.info(
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}`,

View file

@ -1,7 +1,7 @@
import { EventEmitter } from 'events';
import { getLogger } from './LoggerService';
import { getCloudSyncService } from './CloudSyncService';
import { D3ROError, ErrorCode } from '@d3ro/core/errors';
import { D3ROCloudDriver } from './stt/drivers/D3ROCloudDriver';
export interface TranscriptionSegment {
readonly text: string;
@ -28,18 +28,10 @@ const logger = getLogger('CloudSTTService');
class CloudSTTService extends EventEmitter {
private _disposed = false;
private readonly _driver = new D3ROCloudDriver();
async initialize(): Promise<void> {
if (this._disposed) return;
const cloud = getCloudSyncService();
if (!cloud.isAuthenticated()) {
// Auto-authenticate anonymously if zero-configuration is required
try {
await cloud.signInAnonymously();
} catch (e) {
logger.warn('Failed to sign in anonymously', e);
}
}
logger.info('CloudSTTService initialized');
}
@ -47,36 +39,7 @@ class CloudSTTService extends EventEmitter {
if (this._disposed) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'CloudSTTService disposed');
}
const cloud = getCloudSyncService();
// Instead of using formData, we can send base64 or binary depending on edge function support.
// Assuming 'stt-proxy' edge function accepts base64 audio in JSON for simplicity, or multipart.
const base64Audio = audioBuffer.toString('base64');
const { data, error } = await cloud.invokeFunction('stt-proxy', {
audio: base64Audio,
language: options?.language,
initial_prompt: options?.initialPrompt
});
if (error) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `STT failed: ${error.message}`);
}
const result = data as { text?: unknown; segments?: TranscriptionSegment[]; language?: string; duration?: number; processingTime?: number } | null;
if (!result || typeof result.text !== 'string') {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'STT returned malformed payload');
}
if (!result.text.trim()) {
throw new D3ROError(ErrorCode.STTNoAudioData, 'STT returned empty transcript');
}
return {
text: result.text,
segments: result.segments || [],
language: result.language || 'ko',
duration: result.duration || 0,
processingTime: result.processingTime || 0,
};
return this._driver.transcribe(audioBuffer, options);
}
async dispose(): Promise<void> {

View file

@ -380,7 +380,11 @@ class CloudSyncService extends EventEmitter {
* Phase 3.2: Supabase Edge Function auth .
* raw fetch gateway 401 .
*/
async invokeFunction(name: string, body: Record<string, unknown>): Promise<{ data: unknown; error: { message: string } | null }> {
async invokeFunction(
name: string,
body: Record<string, unknown> | FormData,
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<{ data: unknown; error: { message: string } | null }> {
if (!this._client) {
return { data: null, error: { message: 'Supabase client not initialized' } }
}
@ -395,6 +399,8 @@ class CloudSyncService extends EventEmitter {
const { data, error } = await this._client.functions.invoke(name, {
body,
headers: { Authorization: `Bearer ${token}` },
signal: options?.signal,
timeout: options?.timeoutMs
})
if (error) {

View file

@ -10,8 +10,6 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
DictationTemplate,
TemplateField,
TemplateSessionState,
TemplateSessionInfo,
TemplateFieldCompletedEvent,
TemplateSessionCompletedEvent,

View file

@ -7,7 +7,6 @@ import path from 'path'
import fs from 'fs'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { getLocalSTTService } from './LocalSTTService'
import { getSTTManager } from './stt/STTManager'
import { getHistoryService } from './HistoryService'
import { configGet } from './ConfigService'

View file

@ -23,6 +23,23 @@ import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/ut
const logger = getLogger('license')
function resolveLicenseVerificationConfig(): {
publicKeyPem: string | undefined
allowDevelopmentKeys: boolean
environment: string | undefined
} {
const environment = process.env.NODE_ENV
const allowDevelopmentKeys =
environment === 'test' ||
(environment === 'development' && process.env.D3RO_ALLOW_LEGACY_DEV_LICENSES === 'true')
const configuredPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY?.trim()
return {
publicKeyPem: configuredPublicKey?.replace(/\\n/g, '\n'),
allowDevelopmentKeys,
environment,
}
}
// ── 머신 ID 생성 ─────────────────────────────────────────
function generateMachineId(): string {
const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}`
@ -108,9 +125,6 @@ const TIER_ORDER: Record<LicenseTier, number> = {
/** 오프라인 유예 기간: 30일 */
const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
/** 온라인 재검증 주기: 30일 */
const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000
function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean {
return TIER_ORDER[current] >= TIER_ORDER[required]
}
@ -174,8 +188,6 @@ class LicenseService extends EventEmitter {
const storedTrialExpiresAt = this._readStoredField<number>('licenseTrialExpiresAt')
const storedExpiresAt = this._readStoredField<number>('licenseExpiresAt')
const storedCustomerEmail = this._readStoredField<string>('licenseCustomerEmail')
const trialEverStarted = this._readStoredField<boolean>('licenseTrialEverStarted')
const now = Date.now()
if (storedTier && storedTier !== 'free') {
@ -410,9 +422,9 @@ class LicenseService extends EventEmitter {
}
/**
* (Ed25519 + ).
* D3RO-LIC-xxx (Ed25519 ) D3RO-PRO-xxx ( )
* Payple/Stripe CloudSync
* (Ed25519 ).
* D3RO_LICENSE_PUBLIC_KEY가 , fixture는 test
* opt-in한 development .
*/
async activate(key: string): Promise<ActivateLicenseResult> {
const trimmedKey = key.trim()
@ -420,7 +432,13 @@ class LicenseService extends EventEmitter {
return { success: false, tier: 'free', message: 'License key is empty' }
}
const verification = verifySignedLicenseKey(trimmedKey, this._info.machineId)
const verificationConfig = resolveLicenseVerificationConfig()
const verification = verifySignedLicenseKey(
trimmedKey,
this._info.machineId,
verificationConfig.publicKeyPem,
verificationConfig,
)
if (!verification.valid) {
return { success: false, tier: 'free', message: verification.message }
}

View file

@ -15,7 +15,6 @@ import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import {
parseMinutes,
buildExportMarkdown,
markdownToSimpleHtml,
formatTime,
@ -45,15 +44,6 @@ const logger = getLogger('MeetingModeService')
let isShowingMeetingSaveDialog = false
interface MeetingModeServiceEvents {
'state-changed': (state: MeetingModeState) => void
'segment': (segment: CaptionSegment) => void
'memo-added': (memo: MeetingMemo) => void
'processing-progress': (progress: MeetingProcessingProgress) => void
'session-completed': (detail: MeetingSessionDetail) => void
'error': (error: D3ROError) => void
}
class MeetingModeService extends EventEmitter {
private _state: MeetingModeState = 'idle'
private _sessionId: string | null = null
@ -1232,7 +1222,7 @@ ${speakerHint}
<meta charset="utf-8">
<style>
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
h2 { font-size: 16px; color: #444; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }

View file

@ -193,7 +193,7 @@ class MemoService {
const db = getDatabase()
// 태그별 히스토리 조회
let tagFilter = params.tag
const tagFilter = params.tag
? eq(memoTags.tag, params.tag.trim().toLowerCase())
: undefined

View file

@ -3,21 +3,11 @@
// 온라인 모드 사용 시 필수 인증(JWT Bearer)을 통해 서버로 AI 요청 전달.
import { EventEmitter } from 'events'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LLMAction } from '@d3ro/core/types'
import { resolveSystemPrompt } from './llm-prompts'
const logger = getLogger('OnlineLLMService')
interface OnlineGenerateOptions {
model?: string
temperature?: number
maxTokens?: number
systemPrompt?: string
}
interface OnlineGenerateResponse {
text: string
model: string

View file

@ -23,13 +23,6 @@ const logger = getLogger('PremiumLLMService')
// 내부 타입
// ============================================================
interface PremiumGenerateOptions {
model?: string
temperature?: number
maxTokens?: number
systemPrompt?: string
}
/** Ollama-style 메시지 → Claude Messages 변환용 */
interface ChatMessage {
role: 'user' | 'assistant'
@ -55,17 +48,6 @@ interface ClaudeMessageResponse {
usage: { input_tokens: number; output_tokens: number }
}
/** llm-proxy 에러 응답 */
interface LlmProxyErrorResponse {
error: string
current?: number
limit?: number
tier?: 'free' | 'pro' | 'pro_plus'
overage_credits?: number
allowed?: string[]
requested?: string
}
export interface QuotaUsageSnapshot {
tier: 'free' | 'pro' | 'pro_plus'
current: number

View file

@ -4,7 +4,6 @@
import { EventEmitter } from 'events'
import path from 'path'
import fs from 'fs'
import { eq } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService'

View file

@ -1,7 +1,7 @@
// src/main/services/UpdateService.ts
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
//
// feed: D3RO Official Release Feed `https://d3ro.chanpaca.net/releases/1.0.0`
// feed: public GitLab Generic Registry `d3ro-voice/latest`
// 기능:
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
@ -172,7 +172,7 @@ class UpdateService extends EventEmitter {
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
private async _promptUserConsent(
version: string,
releaseNotes?: string | any[]
releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null
): Promise<void> {
if (this._promptShown || this._downloading) return
this._promptShown = true
@ -262,4 +262,3 @@ export function getUpdateService(): UpdateService {
}
export { UpdateService }

View file

@ -7,7 +7,6 @@ import { exec } from 'child_process'
import { shell } from 'electron'
import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService'
import { configGet } from './ConfigService'
import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'

View file

@ -27,7 +27,6 @@ import {
hideRecordingTip,
updateRecordingTipState,
sendAudioLevelToTip,
sendPartialTranscriptToTip,
showResultPopup,
} from '../windows/WindowManager'
import type { ScreenContext } from '@d3ro/core/types'

View file

@ -9,7 +9,6 @@ import type {
AdImpressionEvent,
AdRewardResult,
AdRevenueStats,
AdNetworkId,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter } from './BaseAdAdapter'
import { EthicalAdsAdapter } from './EthicalAdsAdapter'
@ -29,10 +28,13 @@ export class AdMediationEngine {
private adapters: Map<string, IAdNetworkAdapter> = new Map()
private config: AdMediationConfig
private impressionHistory: AdImpressionEvent[] = []
private lastRewardTimestamp = 0
private deliveredCreatives = new Map<string, AdCreativePayload>()
private clickedCreatives = new Set<string>()
private constructor() {
// Register all 10+ Production Ad Adapters
// Provider shells stay registered for explicit configuration diagnostics,
// but are disabled until an official SDK or authenticated decision API is
// integrated. No adapter may fabricate a bid or creative.
const adapterList: IAdNetworkAdapter[] = [
new DirectHouseSponsorAdapter(),
new PlaywireAdapter(),
@ -54,14 +56,14 @@ export class AdMediationEngine {
networks: adapterList.map((a, idx) => ({
id: a.networkId,
name: a.networkName,
enabled: true,
enabled: false,
priority: idx + 1,
floorEcpm: a.defaultFloorEcpm,
adapterType: 'rest_json',
})),
rewardTokensAmount: 50,
rewardCooldownSeconds: 60,
houseAdFallback: true,
houseAdFallback: false,
headerBiddingTimeoutMs: 800,
defaultFloorEcpm: 2.0,
}
@ -75,14 +77,55 @@ export class AdMediationEngine {
}
public getConfig(): AdMediationConfig {
return { ...this.config }
return {
...this.config,
networks: this.config.networks.map((network) => ({ ...network })),
}
}
public setConfig(newConfig: Partial<AdMediationConfig>): AdMediationConfig {
this.config = { ...this.config, ...newConfig }
if (newConfig.rewardTokensAmount !== undefined && (!Number.isFinite(newConfig.rewardTokensAmount) || newConfig.rewardTokensAmount < 0)) {
throw new Error('rewardTokensAmount must be a non-negative number')
}
if (newConfig.rewardCooldownSeconds !== undefined && (!Number.isFinite(newConfig.rewardCooldownSeconds) || newConfig.rewardCooldownSeconds < 0)) {
throw new Error('rewardCooldownSeconds must be a non-negative number')
}
if (newConfig.defaultFloorEcpm !== undefined && (!Number.isFinite(newConfig.defaultFloorEcpm) || newConfig.defaultFloorEcpm < 0)) {
throw new Error('defaultFloorEcpm must be a non-negative number')
}
if (newConfig.headerBiddingTimeoutMs !== undefined && (!Number.isFinite(newConfig.headerBiddingTimeoutMs) || newConfig.headerBiddingTimeoutMs <= 0)) {
throw new Error('headerBiddingTimeoutMs must be a positive number')
}
this.config = {
...this.config,
...newConfig,
networks: newConfig.networks?.map((network) => ({ ...network }))
?? this.config.networks.map((network) => ({ ...network })),
}
return this.getConfig()
}
private isSafeCreative(
adapter: IAdNetworkAdapter,
request: AdMediationAuctionRequest,
bidEcpm: number,
creative: AdCreativePayload,
): boolean {
if (
!Number.isFinite(bidEcpm)
|| bidEcpm <= 0
|| !creative.id.trim()
|| creative.networkId !== adapter.networkId
|| creative.format !== request.format
) return false
try {
return new URL(creative.clickUrl).protocol === 'https:'
} catch {
return false
}
}
/**
* Execute real-time Header Bidding Auction across all enabled ad networks
*/
@ -93,7 +136,7 @@ export class AdMediationEngine {
const enabledAdapters = Array.from(this.adapters.values()).filter((adapter) => {
const netConfig = this.config.networks.find((n) => n.id === adapter.networkId)
return (netConfig ? netConfig.enabled : true) && adapter.supportedFormats.includes(request.format)
return netConfig?.enabled === true && adapter.supportedFormats.includes(request.format)
})
// Query all participating demand sources in parallel with timeout
@ -107,11 +150,36 @@ export class AdMediationEngine {
),
])
if (
bidResult.hasBid
&& (
!bidResult.creative
|| !this.isSafeCreative(adapter, request, bidResult.bidEcpm, bidResult.creative)
)
) {
return {
networkId: adapter.networkId,
networkName: adapter.networkName,
bidEcpm: 0,
creative: undefined,
latencyMs: Date.now() - adapterStart,
status: 'error' as const,
}
}
const creative = bidResult.hasBid && bidResult.creative
? {
...bidResult.creative,
networkName: adapter.networkName,
bidEcpm: bidResult.bidEcpm,
}
: undefined
return {
networkId: adapter.networkId,
networkName: adapter.networkName,
bidEcpm: bidResult.hasBid ? bidResult.bidEcpm : 0,
creative: bidResult.creative,
creative,
latencyMs: Date.now() - adapterStart,
status: (bidResult.hasBid ? 'bid' : 'no_bid') as 'bid' | 'no_bid',
}
@ -134,22 +202,23 @@ export class AdMediationEngine {
.filter((b) => b.status === 'bid' && b.creative && b.bidEcpm >= floorEcpm)
.sort((a, b) => b.bidEcpm - a.bidEcpm)
let winningCreative: AdCreativePayload
if (validBids.length > 0 && validBids[0].creative) {
winningCreative = validBids[0].creative
} else {
// Fallback to Direct House Sponsor
const houseAdapter = this.adapters.get('direct_sponsor') || new DirectHouseSponsorAdapter()
const fallbackBid = await houseAdapter.requestBid(request)
winningCreative = fallbackBid.creative!
const winningCreative = validBids[0]?.creative ?? null
if (winningCreative !== null) {
this.deliveredCreatives.set(winningCreative.id, winningCreative)
if (this.deliveredCreatives.size > 256) {
const oldest = this.deliveredCreatives.keys().next().value as string | undefined
if (oldest !== undefined) {
this.deliveredCreatives.delete(oldest)
this.clickedCreatives.delete(oldest)
}
}
}
const totalLatency = Date.now() - auctionStart
return {
winner: winningCreative,
winningBidEcpm: winningCreative.bidEcpm,
winningBidEcpm: winningCreative?.bidEcpm ?? 0,
participatingBids: bidResults.map((b) => ({
networkId: b.networkId,
networkName: b.networkName,
@ -163,8 +232,18 @@ export class AdMediationEngine {
}
public recordImpression(event: Omit<AdImpressionEvent, 'timestamp'>): void {
const delivered = this.deliveredCreatives.get(event.adId)
if (
delivered === undefined
|| delivered.networkId !== event.network
|| delivered.format !== event.format
|| this.impressionHistory.some((candidate) => candidate.adId === event.adId)
) return
const fullEvent: AdImpressionEvent = {
...event,
networkName: delivered.networkName,
earnedEcpm: delivered.bidEcpm,
timestamp: Date.now(),
}
this.impressionHistory.push(fullEvent)
@ -175,10 +254,19 @@ export class AdMediationEngine {
}
// Register into Settlement Ledger
getAdSettlementService().recordImpression(event.network, event.earnedEcpm || 3.5)
getAdSettlementService().recordImpression(event.network, delivered.bidEcpm)
}
public recordClick(adId: string, networkId: string): void {
const delivered = this.deliveredCreatives.get(adId)
const impressed = this.impressionHistory.some((candidate) => candidate.adId === adId)
if (
delivered === undefined
|| delivered.networkId !== networkId
|| !impressed
|| this.clickedCreatives.has(adId)
) return
this.clickedCreatives.add(adId)
const adapter = this.adapters.get(networkId)
if (adapter) {
adapter.reportClick(adId).catch(() => {})
@ -187,39 +275,19 @@ export class AdMediationEngine {
}
public async claimReward(adId: string, networkId: string): Promise<AdRewardResult> {
const now = Date.now()
const cooldownMs = this.config.rewardCooldownSeconds * 1000
if (now - this.lastRewardTimestamp < cooldownMs) {
const waitSeconds = Math.ceil((cooldownMs - (now - this.lastRewardTimestamp)) / 1000)
return {
success: false,
tokensAdded: 0,
newTotalQuota: 0,
nextAvailableAt: now + waitSeconds * 1000,
}
}
const adapter = this.adapters.get(networkId)
let tokenAmount = this.config.rewardTokensAmount
if (adapter && adapter.reportRewardCompletion) {
const res = await adapter.reportRewardCompletion(adId)
if (res.success && res.tokenReward) tokenAmount = res.tokenReward
}
this.lastRewardTimestamp = now
getAdSettlementService().recordCompletion(networkId)
// Desktop mediation has no server-verified completion/nonce ledger. A
// timer, renderer-supplied ID, or adapter callback is not entitlement
// proof, so rewards remain unavailable until that boundary exists.
void adId
void networkId
return {
success: true,
tokensAdded: tokenAmount,
newTotalQuota: 100 + tokenAmount, // Demo / actual license service quota boost
rewardId: `rew_${Date.now()}`,
success: false,
tokensAdded: 0,
newTotalQuota: 0,
}
}
public getRevenueStats(period = '2026-08'): AdRevenueStats {
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
return getAdSettlementService().getRevenueStats(period)
}
}

View file

@ -2,23 +2,22 @@
// Ad Revenue Settlement, Tax Withholding & Payout Ledger Service
import type {
AdSettlementRecord,
AdRevenueStats,
AdSettlementRecord,
PublisherAccountConfig,
AdNetworkId,
} from '@d3ro/core/types'
export class AdSettlementService {
private static instance: AdSettlementService | null = null
private publisherAccount: PublisherAccountConfig = {
accountEmail: 'yunchanpaca@gmail.com',
beneficiaryName: 'D3RO Voice AI',
payoutBank: 'KB국민은행 (Kookmin Bank)',
payoutAccountNumber: '928702-00-184920',
taxRegistrationNumber: '120-88-01923',
paypalEmail: 'yunchanpaca@gmail.com',
networksConfigured: 10,
accountEmail: '',
beneficiaryName: '',
payoutBank: '',
payoutAccountNumber: '',
taxRegistrationNumber: '',
paypalEmail: '',
networksConfigured: 0,
}
// Network counters for current cycle
@ -27,11 +26,7 @@ export class AdSettlementService {
{ impressions: number; clicks: number; completions: number; grossUsd: number }
> = new Map()
private settlements: AdSettlementRecord[] = []
private constructor() {
this.seedInitialSettlementHistory()
}
private constructor() {}
public static getInstance(): AdSettlementService {
if (!AdSettlementService.instance) {
@ -40,51 +35,8 @@ export class AdSettlementService {
return AdSettlementService.instance
}
private seedInitialSettlementHistory(): void {
const networks: Array<{ id: AdNetworkId; name: string; imp: number; ecpm: number }> = [
{ id: 'direct_sponsor', name: 'Direct House Sponsor (Cursor/Notion)', imp: 84000, ecpm: 15.2 },
{ id: 'playwire', name: 'Playwire RAMP Desktop Header Bidding', imp: 62000, ecpm: 8.4 },
{ id: 'applovin_max', name: 'AppLovin MAX In-App Bidding', imp: 48000, ecpm: 7.8 },
{ id: 'unity_ads', name: 'Unity LevelPlay Rewarded Video', imp: 45000, ecpm: 9.1 },
{ id: 'ethical_ads', name: 'EthicalAds Privacy-First Dev Network', imp: 38000, ecpm: 3.8 },
{ id: 'carbon_ads', name: 'Carbon Ads (BuySellAds)', imp: 31000, ecpm: 4.2 },
{ id: 'google_ad_manager', name: 'Google Ad Manager 360', imp: 29000, ecpm: 3.5 },
{ id: 'mintegral', name: 'Mintegral Global Video Network', imp: 22000, ecpm: 6.2 },
{ id: 'inmobi', name: 'InMobi Exchange', imp: 19000, ecpm: 3.4 },
{ id: 'pubmatic', name: 'PubMatic OpenWrap SSP', imp: 15000, ecpm: 3.6 },
]
for (const net of networks) {
const grossUsd = (net.imp / 1000) * net.ecpm
const withholdingRate = 0.033 // 3.3% Korean Business Tax Withholding
const netUsd = parseFloat((grossUsd * (1 - withholdingRate)).toFixed(2))
const exchangeRate = 1350
const netKrw = Math.round(netUsd * exchangeRate)
this.settlements.push({
id: `stl_202607_${net.id}`,
cycleMonth: '2026-07',
networkId: net.id,
networkName: net.name,
impressions: net.imp,
clicks: Math.round(net.imp * 0.032),
completions: Math.round(net.imp * 0.15),
avgEcpm: net.ecpm,
grossRevenueUsd: parseFloat(grossUsd.toFixed(2)),
withholdingTaxRate: withholdingRate,
netRevenueUsd: netUsd,
exchangeRateKrw: exchangeRate,
netPayoutKrw: netKrw,
payoutStatus: 'settled',
paymentMethod: 'bank_wire_krw',
beneficiaryAccount: this.publisherAccount.payoutAccountNumber,
settledAt: Date.now() - 1000 * 60 * 60 * 24 * 10,
invoiceNumber: `INV-202607-${net.id.toUpperCase().slice(0, 4)}`,
})
}
}
public recordImpression(networkId: string, earnedEcpm: number): void {
if (!networkId || !Number.isFinite(earnedEcpm) || earnedEcpm <= 0) return
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
cur.impressions += 1
cur.grossUsd += earnedEcpm / 1000
@ -92,13 +44,15 @@ export class AdSettlementService {
}
public recordClick(networkId: string): void {
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
const cur = this.networkCounters.get(networkId)
if (!cur || cur.clicks >= cur.impressions) return
cur.clicks += 1
this.networkCounters.set(networkId, cur)
}
public recordCompletion(networkId: string): void {
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
const cur = this.networkCounters.get(networkId)
if (!cur || cur.completions >= cur.impressions) return
cur.completions += 1
this.networkCounters.set(networkId, cur)
}
@ -112,28 +66,32 @@ export class AdSettlementService {
return this.getPublisherAccount()
}
public getRevenueStats(period = '2026-08'): AdRevenueStats {
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
let totalImp = 0
let totalClicks = 0
let totalCompletions = 0
let totalGrossUsd = 0
for (const record of this.settlements) {
totalImp += record.impressions
totalClicks += record.clicks
totalCompletions += record.completions
totalGrossUsd += record.grossRevenueUsd
for (const counters of this.networkCounters.values()) {
totalImp += counters.impressions
totalClicks += counters.clicks
totalCompletions += counters.completions
totalGrossUsd += counters.grossUsd
}
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 5.84
const networkBreakdown = this.settlements.map((s) => ({
network: s.networkName,
impressions: s.impressions,
revenueUsd: s.grossRevenueUsd,
ecpm: s.avgEcpm,
fillRate: 98.4,
}))
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 0
const networkBreakdown = Array.from(this.networkCounters.entries()).map(
([network, counters]) => ({
network,
impressions: counters.impressions,
revenueUsd: parseFloat(counters.grossUsd.toFixed(6)),
ecpm: counters.impressions > 0
? parseFloat(((counters.grossUsd / counters.impressions) * 1000).toFixed(2))
: 0,
// The desktop shell has no authoritative request/no-fill ledger yet.
fillRate: 0,
}),
)
return {
period,
@ -142,24 +100,15 @@ export class AdSettlementService {
totalCompletions: totalCompletions,
totalRevenueUsd: parseFloat(totalGrossUsd.toFixed(2)),
avgEcpm: parseFloat(avgEcpm.toFixed(2)),
fillRatePercent: 98.6,
fillRatePercent: 0,
networkBreakdown,
settlements: [...this.settlements],
settlements: [],
}
}
public requestPayout(settlementId: string): { success: boolean; message: string; settlement?: AdSettlementRecord } {
const found = this.settlements.find((s) => s.id === settlementId)
if (!found) {
return { success: false, message: 'Settlement record not found.' }
}
found.payoutStatus = 'paid'
found.settledAt = Date.now()
return {
success: true,
message: `정산금 ₩${found.netPayoutKrw.toLocaleString()}${this.publisherAccount.payoutBank} (${this.publisherAccount.payoutAccountNumber})으로 성공적으로 입금 신청되었습니다. (원천징수 영수증 발급 완료)`,
settlement: found,
}
void settlementId
return { success: false, message: 'external_settlement_not_configured' }
}
}

View file

@ -1,58 +1,12 @@
// apps/desktop/src/main/services/ads/AppLovinAdapter.ts
// AppLovin MAX Programmatic Bidding Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class AppLovinAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'applovin_max'
readonly networkName = 'AppLovin MAX (Real-Time In-App Bidding)'
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock', 'export_sponsor']
readonly defaultFloorEcpm = 5.5
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
const baseEcpm = request.format === 'rewarded_video' ? 11.5 : 4.5
const ecpm = baseEcpm + Math.random() * 5.0 // Competitive bid
const creative: AdCreativePayload = {
id: `max_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'Grammarly AI — Write with Confidence Across All Apps',
description: 'Real-time AI suggestions, tone adjustments, and grammar correction.',
ctaText: 'Get Grammarly Free',
clickUrl: 'https://grammarly.com?utm_source=applovin',
sponsorTag: 'AppLovin MAX',
advertiserName: 'Grammarly',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
durationSeconds: 15,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 60,
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class AppLovinAdapter extends UnavailableAdAdapter {
constructor() {
super(
'applovin_max',
'AppLovin MAX',
['rewarded_video', 'banner_dock', 'export_sponsor'],
5.5,
)
}
}

View file

@ -1,61 +1,7 @@
// apps/desktop/src/main/services/ads/CarbonAdsAdapter.ts
// BuySellAds / Carbon Ads Curated Tech Single-Unit Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class CarbonAdsAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'carbon_ads'
readonly networkName = 'Carbon Ads (BuySellAds Tech Network)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'sidebar_sponsor_card' as any]
readonly defaultFloorEcpm = 3.5
private placement = 'd3rovoice'
async init(config?: { placement?: string }): Promise<void> {
if (config?.placement) this.placement = config.placement
}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
const ecpm = 3.8 + Math.random() * 2.2 // $3.80 - $6.00 eCPM
const creative: AdCreativePayload = {
id: `carbon_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'Linear — The issue tracking tool you will actually love',
description: 'Streamline software projects, sprints, tasks, and bug tracking at high speed.',
ctaText: 'Try Linear',
iconUrl: 'https://cdn.carbonads.com/carbon_linear_logo.png',
clickUrl: 'https://linear.app?ref=carbon',
sponsorTag: 'Carbon Ads',
advertiserName: 'Linear',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 52,
}
}
async reportImpression(adId: string): Promise<void> {
// Carbon impression beacon
}
async reportClick(adId: string): Promise<void> {
// Carbon click beacon
export class CarbonAdsAdapter extends UnavailableAdAdapter {
constructor() {
super('carbon_ads', 'Carbon Ads', ['banner_dock'], 3.5)
}
}

View file

@ -1,113 +1,12 @@
// apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts
// Direct House Sponsor Engine (Highest margin, premium AI/developer partnerships)
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
interface HouseSponsorCreative {
title: string
description: string
ctaText: string
clickUrl: string
sponsorTag: string
advertiserName: string
bidEcpm: number
format: AdFormat
iconUrl?: string
}
export class DirectHouseSponsorAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'direct_sponsor'
readonly networkName = 'Direct House Sponsor Engine (100% Margin)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
readonly defaultFloorEcpm = 12.0
private sponsors: HouseSponsorCreative[] = [
{
title: 'Cursor AI — Next-Gen AI Code Editor',
description: 'Build software with intelligent voice agents & lightning-speed code search.',
ctaText: 'Learn More',
clickUrl: 'https://cursor.com',
sponsorTag: 'Direct Partner',
advertiserName: 'Cursor AI',
bidEcpm: 15.5,
format: 'banner_dock',
},
{
title: 'ElevenLabs — Human-like Voice AI & Speech Synthesis',
description: 'Industry-leading emotional AI voices for creators, developers, and games.',
ctaText: 'Try Voice AI',
clickUrl: 'https://elevenlabs.io',
sponsorTag: 'Direct Partner',
advertiserName: 'ElevenLabs',
bidEcpm: 18.0,
format: 'rewarded_video',
},
{
title: 'Perplexity Pro — Where Knowledge Begins',
description: 'Instant answers with citations, source tracking, and multi-model research.',
ctaText: 'Try Perplexity',
clickUrl: 'https://perplexity.ai',
sponsorTag: 'Direct Partner',
advertiserName: 'Perplexity AI',
bidEcpm: 14.2,
format: 'banner_dock',
},
{
title: 'Notion AI — Connected Workspace for Documents & Notes',
description: 'Summarize meeting audio, manage tasks, and organize thoughts in one canvas.',
ctaText: 'Get Notion Free',
clickUrl: 'https://notion.so',
sponsorTag: 'Direct Partner',
advertiserName: 'Notion Labs',
bidEcpm: 13.5,
format: 'export_sponsor',
},
]
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
const matching = this.sponsors.filter((s) => s.format === request.format)
if (matching.length === 0) {
return { hasBid: false, bidEcpm: 0, latencyMs: 2 }
}
// Pick rotating sponsor
const picked = matching[Math.floor(Math.random() * matching.length)]
const creative: AdCreativePayload = {
id: `house_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: picked.title,
description: picked.description,
ctaText: picked.ctaText,
clickUrl: picked.clickUrl,
sponsorTag: picked.sponsorTag,
advertiserName: picked.advertiserName,
bidEcpm: picked.bidEcpm,
format: picked.format,
rewardTokens: picked.format === 'rewarded_video' ? 50 : undefined,
durationSeconds: picked.format === 'rewarded_video' ? 15 : undefined,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 8, // Near zero latency
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class DirectHouseSponsorAdapter extends UnavailableAdAdapter {
constructor() {
super(
'direct_sponsor',
'Direct House Sponsor',
['banner_dock', 'rewarded_video', 'export_sponsor'],
12,
)
}
}

View file

@ -1,71 +1,7 @@
// apps/desktop/src/main/services/ads/EthicalAdsAdapter.ts
// Privacy-First Developer Native Ad Network Adapter (REST Decision API /api/v1/decision/)
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class EthicalAdsAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'ethical_ads'
readonly networkName = 'EthicalAds (Privacy-First Dev Network)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'export_sponsor']
readonly defaultFloorEcpm = 3.2
private publisherId = 'd3ro-voice'
async init(config?: { publisherId?: string }): Promise<void> {
if (config?.publisherId) this.publisherId = config.publisherId
}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
try {
// EthicalAds developer ads simulation & real JSON endpoint fallback
const ecpm = 3.2 + Math.random() * 1.5 // $3.20 - $4.70 eCPM
const creative: AdCreativePayload = {
id: `ea_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'MongoDB Atlas — The Multi-Cloud Developer Data Platform',
description: 'Build fast with automated scaling, vector search, and global clusters.',
ctaText: 'Deploy Free',
iconUrl: 'https://media.ethicalads.io/media/images/2024/02/mongodb_icon.png',
clickUrl: 'https://www.mongodb.com/cloud/atlas/register?utm_source=ethicalads',
sponsorTag: 'EthicalAd • Privacy Verified',
advertiserName: 'MongoDB',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 45,
}
} catch (err) {
return {
hasBid: false,
bidEcpm: 0,
latencyMs: Date.now() - startTime,
error: err instanceof Error ? err.message : String(err),
}
}
}
async reportImpression(adId: string): Promise<void> {
// console.log(`[EthicalAds] Impression recorded for ${adId}`)
}
async reportClick(adId: string): Promise<void> {
// console.log(`[EthicalAds] Click recorded for ${adId}`)
export class EthicalAdsAdapter extends UnavailableAdAdapter {
constructor() {
super('ethical_ads', 'EthicalAds', ['banner_dock', 'export_sponsor'], 3.2)
}
}

View file

@ -1,59 +1,12 @@
// apps/desktop/src/main/services/ads/GoogleAdManagerAdapter.ts
// Google Ad Manager 360 / AdMob Universal Global Demand Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class GoogleAdManagerAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'google_ad_manager'
readonly networkName = 'Google Ad Manager 360 (Global Demand)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
readonly defaultFloorEcpm = 2.0
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
// High 99%+ fill rate, stable eCPM
const baseEcpm = request.format === 'rewarded_video' ? 7.2 : 3.0
const ecpm = baseEcpm + Math.random() * 2.0
const creative: AdCreativePayload = {
id: `gam_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'Google Cloud Vertex AI — Build & Scale Generative AI Apps',
description: 'Access Gemini 1.5 Pro, customized embeddings, and enterprise search.',
ctaText: 'Explore Cloud',
clickUrl: 'https://cloud.google.com/vertex-ai',
sponsorTag: 'Google Ad Manager',
advertiserName: 'Google Cloud',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
durationSeconds: 15,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 40,
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class GoogleAdManagerAdapter extends UnavailableAdAdapter {
constructor() {
super(
'google_ad_manager',
'Google Ad Manager',
['banner_dock', 'rewarded_video', 'export_sponsor'],
2,
)
}
}

View file

@ -1,58 +1,7 @@
// apps/desktop/src/main/services/ads/InMobiAdapter.ts
// InMobi Programmatic Demand & Mobile/Hybrid Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class InMobiAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'inmobi'
readonly networkName = 'InMobi (Programmatic Exchange)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video']
readonly defaultFloorEcpm = 2.8
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
const baseEcpm = request.format === 'rewarded_video' ? 6.8 : 3.4
const ecpm = baseEcpm + Math.random() * 2.2
const creative: AdCreativePayload = {
id: `inmobi_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'NordVPN — Secure Your Data with Next-Gen Encryption',
description: 'Ultra-fast VPN protection across all your desktop and mobile devices.',
ctaText: 'Get 70% Off',
clickUrl: 'https://nordvpn.com?utm_source=inmobi',
sponsorTag: 'InMobi Exchange',
advertiserName: 'Nord Security',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
rewardTokens: 50,
durationSeconds: 15,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 50,
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class InMobiAdapter extends UnavailableAdAdapter {
constructor() {
super('inmobi', 'InMobi', ['banner_dock', 'rewarded_video'], 2.8)
}
}

View file

@ -1,58 +1,7 @@
// apps/desktop/src/main/services/ads/MintegralAdapter.ts
// Mintegral Global / APAC Rewarded Video & Interstitial Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class MintegralAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'mintegral'
readonly networkName = 'Mintegral (APAC & Global Video Network)'
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock']
readonly defaultFloorEcpm = 4.0
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
const baseEcpm = request.format === 'rewarded_video' ? 8.8 : 3.8
const ecpm = baseEcpm + Math.random() * 3.2
const creative: AdCreativePayload = {
id: `mintegral_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'Canva Pro — Design Anything with Team Collaboration',
description: 'Create presentations, graphics, and video with easy AI magic tools.',
ctaText: 'Try Canva Free',
clickUrl: 'https://canva.com?ref=mintegral',
sponsorTag: 'Mintegral Video',
advertiserName: 'Canva',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
rewardTokens: 50,
durationSeconds: 15,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 52,
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class MintegralAdapter extends UnavailableAdAdapter {
constructor() {
super('mintegral', 'Mintegral', ['rewarded_video', 'banner_dock'], 4)
}
}

View file

@ -1,61 +1,12 @@
// apps/desktop/src/main/services/ads/PlaywireAdapter.ts
// Playwire Desktop Application Programmatic Header Bidding Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class PlaywireAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'playwire'
readonly networkName = 'Playwire RAMP (Desktop Header Bidding)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
readonly defaultFloorEcpm = 4.5
async init(): Promise<void> {
// Initialize Playwire RAMP desktop runtime
}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
// Playwire high-tier programmatic bidding: $4.50 - $11.00 eCPM
const baseEcpm = request.format === 'rewarded_video' ? 8.5 : 4.8
const ecpm = baseEcpm + Math.random() * 3.5
const creative: AdCreativePayload = {
id: `playwire_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'AWS Cloud — Scalable AI & Machine Learning Infrastructure',
description: 'Train models and deploy high-performance applications on AWS Bedrock.',
ctaText: 'Start Free Trial',
clickUrl: 'https://aws.amazon.com/free/?utm_source=playwire',
sponsorTag: 'Playwire Programmatic',
advertiserName: 'Amazon Web Services',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
durationSeconds: request.format === 'rewarded_video' ? 15 : undefined,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 68,
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class PlaywireAdapter extends UnavailableAdAdapter {
constructor() {
super(
'playwire',
'Playwire RAMP',
['banner_dock', 'rewarded_video', 'export_sponsor'],
4.5,
)
}
}

View file

@ -1,52 +1,7 @@
// apps/desktop/src/main/services/ads/PubMaticAdapter.ts
// PubMatic OpenWrap Header Bidding SSP Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class PubMaticAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'pubmatic'
readonly networkName = 'PubMatic OpenWrap (Enterprise SSP)'
readonly supportedFormats: AdFormat[] = ['banner_dock', 'export_sponsor']
readonly defaultFloorEcpm = 3.0
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
const ecpm = 3.6 + Math.random() * 2.5
const creative: AdCreativePayload = {
id: `pubmatic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'Datadog — Cloud Monitoring, APM & Security in One Platform',
description: 'See metrics, traces, and logs from your entire technology stack.',
ctaText: 'Start Monitoring',
clickUrl: 'https://datadoghq.com?utm_source=pubmatic',
sponsorTag: 'PubMatic OpenWrap',
advertiserName: 'Datadog',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 58,
}
export class PubMaticAdapter extends UnavailableAdAdapter {
constructor() {
super('pubmatic', 'PubMatic OpenWrap', ['banner_dock', 'export_sponsor'], 3)
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
}

View file

@ -0,0 +1,37 @@
import type { AdFormat, AdMediationAuctionRequest, AdNetworkId } from '@d3ro/core/types'
import type { AdBidResponse, IAdNetworkAdapter } from './BaseAdAdapter'
/**
* Fail-closed boundary for providers whose official desktop SDK or
* authenticated decision endpoint is not integrated. Demo creatives are not
* ads, so this adapter deliberately returns no bid and never grants rewards.
*/
export class UnavailableAdAdapter implements IAdNetworkAdapter {
constructor(
readonly networkId: AdNetworkId,
readonly networkName: string,
readonly supportedFormats: AdFormat[],
readonly defaultFloorEcpm: number,
) {}
async init(): Promise<void> {}
async requestBid(_request: AdMediationAuctionRequest): Promise<AdBidResponse> {
return {
hasBid: false,
bidEcpm: 0,
latencyMs: 0,
error: 'provider_not_integrated',
}
}
async reportImpression(_adId: string): Promise<void> {}
async reportClick(_adId: string): Promise<void> {}
async reportRewardCompletion(
_adId: string,
): Promise<{ success: boolean; tokenReward: number }> {
return { success: false, tokenReward: 0 }
}
}

View file

@ -1,58 +1,7 @@
// apps/desktop/src/main/services/ads/UnityAdsAdapter.ts
// Unity Ads / Unity LevelPlay Rewarded Video Adapter
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
import type {
AdNetworkId,
AdFormat,
AdMediationAuctionRequest,
AdCreativePayload,
} from '@d3ro/core/types'
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
export class UnityAdsAdapter implements IAdNetworkAdapter {
readonly networkId: AdNetworkId = 'unity_ads'
readonly networkName = 'Unity LevelPlay (Rewarded Video & Bidding)'
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock']
readonly defaultFloorEcpm = 6.0
async init(): Promise<void> {}
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
const startTime = Date.now()
if (!this.supportedFormats.includes(request.format)) {
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
}
const baseEcpm = request.format === 'rewarded_video' ? 10.2 : 4.0
const ecpm = baseEcpm + Math.random() * 4.0 // High yield rewarded video
const creative: AdCreativePayload = {
id: `unity_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
networkId: this.networkId,
networkName: this.networkName,
title: 'Unity Engine — Create & Grow Real-Time 3D Experiences',
description: 'The industry-standard game engine for multi-platform interactive applications.',
ctaText: 'Download Unity',
clickUrl: 'https://unity.com/download',
sponsorTag: 'Unity Ads',
advertiserName: 'Unity Technologies',
bidEcpm: parseFloat(ecpm.toFixed(2)),
format: request.format,
rewardTokens: 50,
durationSeconds: 15,
}
return {
hasBid: true,
bidEcpm: creative.bidEcpm,
creative,
latencyMs: Date.now() - startTime + 55,
}
}
async reportImpression(adId: string): Promise<void> {}
async reportClick(adId: string): Promise<void> {}
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
return { success: true, tokenReward: 50 }
export class UnityAdsAdapter extends UnavailableAdAdapter {
constructor() {
super('unity_ads', 'Unity LevelPlay', ['rewarded_video', 'banner_dock'], 6)
}
}

View file

@ -14,7 +14,6 @@ import type {
TestSTTConnectionResult,
STTStatus,
} from '@d3ro/core/types'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from './types'
import { OpenAIDriver } from './drivers/OpenAIDriver'
import { GroqDriver } from './drivers/GroqDriver'

View file

@ -1,147 +1,173 @@
// apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts
// D3RO Voice Cloud STT Gateway 드라이버
// 사용자는 별도 API 키 설정 없이 D3RO 클라우드 서비스를 통해 관리자 설정 프로바이더로 전사 처리
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import { getCloudSyncService } from '../../CloudSyncService'
import { getLogger } from '../../LoggerService'
import { configGet } from '../../ConfigService'
import { pcmToWav } from '../audio-utils'
import type { ISTTDriver } from '../types'
const logger = getLogger('D3ROCloudDriver')
export interface CloudSttGateway {
getAccessToken(): Promise<string | null>
getSupabaseUrl(): string | null
getAnonKey(): string | null
}
interface CloudSttPayload {
transcript?: unknown
confidence?: unknown
language_code?: unknown
duration_seconds?: unknown
provider?: unknown
}
function parseCloudSttPayload(value: unknown): {
transcript: string
confidence: number
language: string
duration: number
} {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
}
const payload = value as CloudSttPayload
if (typeof payload.transcript === 'string' && !payload.transcript.trim()) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
if (
typeof payload.transcript !== 'string'
|| !payload.transcript.trim()
|| payload.transcript.length > 1_000_000
|| typeof payload.confidence !== 'number'
|| !Number.isFinite(payload.confidence)
|| payload.confidence < 0
|| payload.confidence > 1
|| typeof payload.language_code !== 'string'
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|| typeof payload.duration_seconds !== 'number'
|| !Number.isFinite(payload.duration_seconds)
|| payload.duration_seconds < 0
|| payload.duration_seconds > 24 * 60 * 60
|| typeof payload.provider !== 'string'
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
}
return {
transcript: payload.transcript.trim(),
confidence: payload.confidence,
language: payload.language_code,
duration: payload.duration_seconds,
}
}
function edgeEndpoint(supabaseUrl: string): string {
try {
const endpoint = new URL('/functions/v1/stt-proxy', supabaseUrl)
const localHttp = endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname)
if ((endpoint.protocol !== 'https:' && !localHttp) || endpoint.username || endpoint.password) {
throw new Error('invalid endpoint')
}
return endpoint.toString()
} catch {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 서버 설정이 올바르지 않습니다.')
}
}
export class D3ROCloudDriver implements ISTTDriver {
readonly id = 'd3ro-cloud' as const
readonly name = 'D3RO Cloud STT (Managed)'
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
config?: STTProviderConfig,
): Promise<TranscriptionResult> {
const apiBase = (config?.baseUrl || configGet('cloudApiUrl') || process.env.D3RO_API_URL || 'http://localhost:5000').replace(/\/+$/, '')
const endpoint = `${apiBase}/api/stt/transcribe`
const startTime = Date.now()
void config
if (!Buffer.isBuffer(audioBuffer) || audioBuffer.length < 1) {
throw new D3ROError(ErrorCode.STTNoAudioData, '전사할 오디오가 없습니다.')
}
const token = await this.cloud.getAccessToken()
const supabaseUrl = this.cloud.getSupabaseUrl()
const anonKey = this.cloud.getAnonKey()
if (!token || !supabaseUrl || !anonKey) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT는 로그인과 Supabase 설정이 필요합니다.')
}
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
const arrayBuf = wavBuffer.buffer.slice(
const arrayBuffer = wavBuffer.buffer.slice(
wavBuffer.byteOffset,
wavBuffer.byteOffset + wavBuffer.byteLength
wavBuffer.byteOffset + wavBuffer.byteLength,
) as ArrayBuffer
const formData = new FormData()
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav')
formData.append('audio', new Blob([arrayBuffer], { type: 'audio/wav' }), 'recording.wav')
if (options?.language && options.language !== 'auto') {
formData.append('language', options.language)
}
if (options?.initialPrompt) {
formData.append('prompt', options.initialPrompt)
}
if (config?.modelId && config.modelId !== 'default') {
formData.append('model', config.modelId)
}
const headers: Record<string, string> = {}
const token = config?.apiKey || (configGet('cloudAuthToken') as string | undefined)
if (token) {
headers.Authorization = `Bearer ${token}`
formData.append('language_code', options.language)
}
const startedAt = Date.now()
try {
logger.info(`Sending audio to D3RO Cloud STT Gateway: ${endpoint}`)
const response = await fetch(endpoint, {
logger.info('Sending audio to authenticated D3RO Cloud STT Edge gateway')
const response = await fetch(edgeEndpoint(supabaseUrl), {
method: 'POST',
headers,
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
},
body: formData,
signal: AbortSignal.timeout(30000),
signal: AbortSignal.timeout(120_000),
})
if (!response.ok) {
const errorBody = await response.text()
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 오류 (${response.status}): ${errorBody}`)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 요청 실패 (${response.status})`)
}
const data = (await response.json()) as {
text?: string
language?: string
durationSeconds?: number
provider?: string
latencyMs?: number
}
const rawText = data.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
const duration = data.durationSeconds || Math.round(audioBuffer.length / 2 / 16000)
const result = parseCloudSttPayload(await response.json().catch(() => null))
return {
text: rawText,
language: data.language || options?.language || 'ko',
duration,
processingTime,
segments: [
{
text: rawText,
start: 0,
end: duration,
confidence: 0.98,
},
],
text: result.transcript,
language: result.language,
duration: result.duration,
processingTime: Date.now() - startedAt,
segments: [{
text: result.transcript,
start: 0,
end: result.duration,
confidence: result.confidence,
}],
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('D3RO Cloud driver transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 전사 실패: ${msg}`)
} catch (error) {
if (error instanceof D3ROError) throw error
logger.error('D3RO Cloud driver transcribe error')
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 전사에 실패했습니다.')
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiBase = (config.baseUrl || configGet('cloudApiUrl') || 'http://localhost:5000').replace(/\/+$/, '')
const endpoint = `${apiBase}/api/stt/test`
const startTime = Date.now()
async testConnection(_config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const startedAt = Date.now()
try {
const headers: Record<string, string> = {}
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`
}
const response = await fetch(endpoint, {
const token = await this.cloud.getAccessToken()
const supabaseUrl = this.cloud.getSupabaseUrl()
const anonKey = this.cloud.getAnonKey()
if (!token || !supabaseUrl || !anonKey) throw new Error('로그인 또는 Supabase 설정이 없습니다.')
const response = await fetch(edgeEndpoint(supabaseUrl), {
method: 'POST',
headers,
signal: AbortSignal.timeout(10000),
headers: { Authorization: `Bearer ${token}`, apikey: anonKey },
body: new FormData(),
signal: AbortSignal.timeout(10_000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
const data = await response.json()
return {
success: true,
latencyMs: data.latencyMs || latencyMs,
message: data.message || `D3RO Cloud STT 연결 성공 (${latencyMs}ms)`,
}
const latencyMs = Date.now() - startedAt
// The empty authenticated probe must be rejected before provider work,
// proving reachability without consuming quota or incurring provider cost.
if ([400, 413, 415].includes(response.status)) {
return { success: true, latencyMs, message: `D3RO Cloud STT 인증 경로 준비됨 (${latencyMs}ms)` }
}
const text = await response.text()
return { success: false, latencyMs, message: `D3RO Cloud 연결 실패 (HTTP ${response.status})` }
} catch {
return {
success: false,
latencyMs,
message: `D3RO Cloud 연결 실패 (HTTP ${response.status}): ${text.slice(0, 100)}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
return {
success: false,
latencyMs,
message: `D3RO Cloud 연결 실패: ${err instanceof Error ? err.message : String(err)}`,
latencyMs: Date.now() - startedAt,
message: 'D3RO Cloud 연결 또는 인증에 실패했습니다.',
}
}
}

View file

@ -10,5 +10,6 @@
//
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
// 버전 없는 `latest` 패키지를 가리켜야 기존 설치본이 새 릴리스를 계속 찾을 수 있다.
export const UPDATE_FEED_URL =
'https://d3ro.chanpaca.net/releases/1.0.0'
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'

View file

@ -94,7 +94,13 @@ export function createMainWindow(): BrowserWindow {
minHeight: 600,
show: true,
autoHideMenuBar: true,
frame: true,
// 보더리스: 렌더러의 커스텀 TitleBar(AppLayout 최상단)가 크롬을 대체한다.
// macOS는 hiddenInset으로 네이티브 교통신호(traffic lights)를 콘텐츠 위에
// 인셋 유지 — 이 플랫폼에서는 커스텀 최대/최소/닫기 버튼을 렌더러에서 숨긴다.
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 13 } }
: { frame: false }),
roundedCorners: true,
backgroundColor: '#0a0e1c',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),

View file

@ -84,7 +84,6 @@ import type {
SetVoiceCommandKeywordsParams,
SetVoiceCommandEnabledParams,
CaptureContextResult,
ScreenContext,
LLMChain,
CreateChainParams,
UpdateChainParams,
@ -95,7 +94,6 @@ import type {
CaptionState,
CaptionSegment,
CaptionConfig,
CaptionSessionSummary,
// Phase 11
LicenseInfo,
ActivateLicenseParams,
@ -162,7 +160,6 @@ import type {
MeetingDocument,
MeetingDocTemplate,
MeetingGenerateDocParams,
MeetingGenerateDocResult,
MeetingUpdateDocParams,
MeetingDeleteDocParams,
MeetingGetDocsParams,
@ -845,10 +842,10 @@ const electronAPI = {
payment: {
createCheckoutSession: (params: import('@d3ro/core/types').CheckoutSessionParams) =>
invoke<import('@d3ro/core/types').CheckoutSessionResult>(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, params),
verifyPayment: (params: { tier: string }) =>
invoke<{ success: boolean; activeTier: string }>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, params),
verifyPayment: () =>
invoke<import('@d3ro/core/types').VerifyPaymentResult>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT),
getSubscriptionStatus: () =>
invoke<{ tier: string; valid: boolean; expiresAt: number | null }>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
invoke<import('@d3ro/core/types').SubscriptionStatusResult>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
},
} as const

View file

@ -12,8 +12,6 @@ import {
Library,
Users,
Settings,
Sparkles,
Zap,
Headphones,
} from 'lucide-react'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
@ -182,7 +180,7 @@ export function AppLayout(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '20px',
fontWeight: 800,
fontWeight: 600,
letterSpacing: '-0.02em',
backgroundImage: d3roPalette.gradient.logo,
backgroundClip: 'text',
@ -198,7 +196,7 @@ export function AppLayout(): React.ReactElement {
sx={{
fontFamily: d3roFontMono,
fontSize: '10px',
fontWeight: 700,
fontWeight: 500,
letterSpacing: '0.08em',
color: d3roPalette.text.dimLabel,
textTransform: 'uppercase',
@ -401,7 +399,6 @@ export function AppLayout(): React.ReactElement {
onClose={() => setCheckoutModalOpen(false)}
onSuccess={(newTier) => {
setCurrentTier(newTier)
setCheckoutModalOpen(false)
}}
/>
<RewardedQuotaModal

View file

@ -6,7 +6,6 @@ import { useEffect, useState } from 'react'
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
import { Cloud, CloudCheck, Globe, GitBranch } from 'lucide-react'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
interface CloudSyncState {
authenticated: boolean
@ -22,7 +21,6 @@ interface SyncProgress {
}
export function CloudSyncSection(): React.ReactElement {
const { t: _t } = useI18n()
const [state, setState] = useState<CloudSyncState>({
authenticated: false,
userEmail: null,

View file

@ -63,7 +63,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.body.size,
letterSpacing: d3roTypo.engrave.spacing,
textTransform: 'uppercase',
@ -91,7 +91,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
fontWeight: 500,
letterSpacing: '0.5px',
color: d3roPalette.text.inactive,
minHeight: 44,
@ -119,7 +119,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
'& .MuiAlert-icon': { color: d3roPalette.accent.main },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, mb: 0.5 }}>
(30 )
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
@ -128,7 +128,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
</Alert>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
[STEP-BY-STEP ]
</Typography>
@ -143,7 +143,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
1단계: OpenAI Platform
</Typography>
<Button
@ -154,7 +154,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -177,7 +177,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
2단계: (Secret Key)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, display: 'block', mb: 1 }}>
@ -204,7 +204,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
3단계: D3RO-Voice STT
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
@ -229,7 +229,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
'& .MuiAlert-icon': { color: d3roPalette.tag.orange },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, mb: 0.5 }}>
ChatGPT Plus / Pro / Team
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
@ -238,7 +238,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
</Alert>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
[Codex Device Flow ]
</Typography>
@ -252,7 +252,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
mb: 2,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
1. Device Auth
</Typography>
<Box
@ -292,7 +292,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
2. (Troubleshooting)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
@ -328,8 +328,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Groq STT</Typography>
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Groq STT</Typography>
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
LPU Whisper Large V3 0.2 .
@ -360,8 +360,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Deepgram Nova-3</Typography>
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Deepgram Nova-3</Typography>
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
. .
@ -392,8 +392,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Google Gemini 2.0 Flash Audio</Typography>
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Google Gemini 2.0 Flash Audio</Typography>
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
Google AI Studio에서 API .
@ -424,8 +424,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AssemblyAI Universal-2</Typography>
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>AssemblyAI Universal-2</Typography>
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
.
@ -453,7 +453,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
variant="contained"
sx={{
fontFamily: d3roFontMono,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.label.size,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,

View file

@ -20,32 +20,6 @@ import { useI18n } from '@d3ro/i18n'
import type { HotkeyBinding } from '@d3ro/core/types'
import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey'
// ── 키 이름 매핑 (Windows) ──────────────────────────────
const KEY_DISPLAY_MAP: Record<number, string> = {
// Modifier keys
16: 'Shift', 17: 'Ctrl', 18: 'Alt', 91: 'Win', 92: 'Win',
160: 'Left Shift', 161: 'Right Shift',
162: 'Left Ctrl', 163: 'Right Ctrl',
164: 'Left Alt', 165: 'Right Alt',
// Common keys
8: 'Backspace', 9: 'Tab', 13: 'Enter', 19: 'Pause', 20: 'CapsLock',
27: 'Esc', 32: 'Space',
33: 'PgUp', 34: 'PgDn', 35: 'End', 36: 'Home',
37: '←', 38: '↑', 39: '→', 40: '↓',
45: 'Insert', 46: 'Delete',
// F-keys
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
// Numpad
96: 'Num0', 97: 'Num1', 98: 'Num2', 99: 'Num3', 100: 'Num4',
101: 'Num5', 102: 'Num6', 103: 'Num7', 104: 'Num8', 105: 'Num9',
106: 'Num*', 107: 'Num+', 109: 'Num-', 110: 'Num.', 111: 'Num/',
// Special
186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`',
219: '[', 220: '\\', 221: ']', 222: "'",
}
// 시스템 예약 조합
const RESERVED_COMBOS = [
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
@ -235,7 +209,7 @@ export function HotkeyRecordModal({
disableAutoFocus
disableRestoreFocus
>
<DialogTitle sx={{ fontWeight: 700, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogTitle sx={{ fontWeight: 500, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogContent>
{/* 녹화 영역 */}
<Box
@ -266,7 +240,7 @@ export function HotkeyRecordModal({
key={`${key.keyCode}-${i}`}
label={key.name}
sx={{
fontWeight: 700,
fontWeight: 500,
fontSize: '14px',
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,

View file

@ -50,7 +50,7 @@ export function LicenseTab(): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Sparkles size={18} color={d3roPalette.accent.main} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 'bold' }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 500 }}>
14-Day Reverse Trial (Pro+)
</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
@ -77,7 +77,7 @@ export function LicenseTab(): React.ReactElement {
{/* ── 오프라인 라이선스 키 등록 ── */}
<MetalCard>
<LicenseKeySection t={t} currentTier={currentTier} />
<LicenseKeySection currentTier={currentTier} />
</MetalCard>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
@ -113,7 +113,7 @@ function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): Rea
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}
function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; currentTier: string }): React.ReactElement {
function LicenseKeySection({ currentTier }: { currentTier: string }): React.ReactElement {
const [licenseKeyInput, setLicenseKeyInput] = useState('')
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
const [loading, setLoading] = useState(false)
@ -157,7 +157,7 @@ function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; curre
</Box>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
(D3RO-LIC-... D3RO-PRO-...) .
Ed25519 (D3RO-LIC-...) .
</PhosphorText>
<Box sx={{ display: 'flex', gap: 1 }}>

View file

@ -29,8 +29,6 @@ import {
Play,
Download,
CheckCircle2,
Cpu,
Sparkles,
Zap,
} from 'lucide-react'
import {
@ -315,7 +313,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Typography
sx={{
fontFamily: d3roFontSans,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.heading.size,
color: d3roPalette.text.primary,
}}
@ -393,7 +391,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
fontSize: '11px',
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
fontWeight: 700,
fontWeight: 500,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
@ -412,7 +410,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
{/* ── Step 1: Ollama 설치 및 기동 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 1" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Chip label="STEP 1" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
Ollama
</Typography>
@ -464,7 +462,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 2" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Chip label="STEP 2" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
1-
</Typography>
@ -503,11 +501,11 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: d3roPalette.text.primary }}>
{model.name}
</Typography>
{model.recommended && (
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 700, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 500, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
)}
</Box>
<Chip label={model.size} size="small" sx={{ height: 18, fontSize: '10px', fontFamily: d3roFontMono }} />
@ -562,7 +560,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
py: 0.25,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
@ -593,7 +591,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 3" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Chip label="STEP 3" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
(AI )
</Typography>
@ -622,7 +620,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
minWidth: 100,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
@ -644,7 +642,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
borderRadius: d3roRadius.small,
}}
>
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 700, mb: 0.5 }}>
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 500, mb: 0.5 }}>
AI ({activeModel}):
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
@ -683,4 +681,3 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
</Dialog>
)
}

View file

@ -22,8 +22,6 @@ import {
CheckCircle2,
AlertCircle,
Cloud,
HardDrive,
Lock,
Download,
Play,
RefreshCw,
@ -35,7 +33,7 @@ import {
} from 'lucide-react'
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import type { LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
@ -68,7 +66,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
const [authLoading, setAuthLoading] = useState(false)
// Error State
const [errorMsg, setErrorMsg] = useState('')
const errorMsg = ''
const isVisible = open || internalOpen
@ -277,11 +275,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={22} color={d3roPalette.accent.main} />
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
AI
</Typography>
</Box>
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
</Box>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
<b>Ollama</b> <b>Whisper STT</b> PC .
@ -310,7 +308,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
sx={{
mt: 2.5,
fontFamily: d3roFontSans,
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -344,7 +342,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={22} color={d3roPalette.tag.blue} />
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
</Typography>
</Box>
@ -407,7 +405,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{isOllamaConnected
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 로컬 엔진 확인 필요'}
@ -438,7 +436,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
startIcon={<Play size={13} />}
onClick={handleStartOllama}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
>
Ollama
</Button>
@ -455,7 +453,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
{/* Ollama 미설치 시 가이드 */}
{!isOllamaConnected && (
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
Ollama가 :
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
@ -476,7 +474,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
{/* 추천 모델 설치 섹션 */}
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
AI (Gemma 2 2B)
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
@ -503,7 +501,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
onClick={() => handlePullModel('gemma2:2b')}
disabled={!isOllamaConnected}
sx={{
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -529,7 +527,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
variant="contained"
onClick={handleCompleteLocalSetup}
disabled={!isOllamaConnected}
sx={{ fontWeight: 700 }}
sx={{ fontWeight: 500 }}
>
</Button>
@ -616,7 +614,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
{phase === 'success' && (
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 700 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 500 }}>
D3RO Voice
</Button>
</DialogActions>
@ -624,4 +622,3 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
</Dialog>
)
}

View file

@ -233,7 +233,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
)}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '12px', mb: 0.2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontSize: '12px', mb: 0.2 }}>
{p.name}
</Typography>
</Box>
@ -244,7 +244,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
mt: 1,
height: 18,
fontSize: '9px',
fontWeight: 700,
fontWeight: 500,
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
@ -268,7 +268,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
{currentProviderInfo?.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
@ -281,7 +281,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
height: 20,
fontSize: '10px',
fontWeight: 700,
fontWeight: 500,
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
}}
@ -335,7 +335,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{selected.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
@ -351,7 +351,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -403,7 +403,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
minWidth: 105,
height: 40,
whiteSpace: 'nowrap',
@ -457,7 +457,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.text.secondary,
letterSpacing: '0.5px',
}}

View file

@ -24,7 +24,7 @@ import {
Stack,
Paper,
} from '@mui/material'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, Sparkles, CheckCircle2 } from 'lucide-react'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
@ -35,7 +35,6 @@ import { STTTab } from './STTTab'
import { useI18n, LOCALE_META } from '@d3ro/i18n'
import type { Locale } from '@d3ro/i18n'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types'
interface SettingsModalProps {
open: boolean
@ -79,7 +78,7 @@ function HotkeyDisplay({
label={key}
size="small"
sx={{
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.label.size,
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
@ -138,7 +137,7 @@ function VoiceModeCard({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
sx={{ fontWeight: 500, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
>
{title}
</Typography>
@ -163,7 +162,7 @@ function VoiceModeCard({
size="small"
sx={{
fontSize: '10px', // P4: 토큰에 없는 10px 보존
fontWeight: 700,
fontWeight: 500,
height: 20,
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
@ -189,6 +188,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
const [activeTab, setActiveTab] = useState(initialTab)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
const [appVersion, setAppVersion] = useState<string | null>(null)
useEffect(() => {
if (open && initialTab !== undefined) {
@ -196,13 +196,30 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
}
}, [open, initialTab])
useEffect(() => {
if (!open) return
let cancelled = false
void window.electronAPI.system.getVersion()
.then((result) => {
if (!cancelled && result.success) setAppVersion(result.data)
})
.catch(() => {
if (!cancelled) setAppVersion(null)
})
return () => {
cancelled = true
}
}, [open])
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
const [captionBinding, setCaptionBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
const [, setHotkeyGlobalEnabled] = useState(true)
// 핫키 녹화 모달
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
@ -350,7 +367,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.body.size,
letterSpacing: d3roTypo.engrave.spacing,
textTransform: 'uppercase',
@ -377,7 +394,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
fontWeight: 500,
letterSpacing: '0.5px',
textTransform: 'uppercase',
color: d3roPalette.text.inactive,
@ -700,7 +717,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaStatus?.connectionState === 'connected'
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
@ -746,7 +763,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
}
}}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
>
{startingOllama ? '실행 중' : 'Ollama 실행'}
</Button>
@ -923,7 +940,9 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.version')}</Typography>
<Typography variant="body2" color="text.secondary">v1.0.0</Typography>
<Typography variant="body2" color="text.secondary">
{appVersion === null ? '—' : `v${appVersion}`}
</Typography>
</Box>
<Box>
@ -973,4 +992,3 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</>
)
}

View file

@ -6,7 +6,7 @@ import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActi
import { Plus, Trash2, Pencil, Play } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from './shared'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'

View file

@ -1,7 +1,8 @@
// src/renderer/components/TitleBar.tsx
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
// v3 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
// macOS(hiddenInset)에서는 네이티브 교통신호가 좌측에 표시되므로 커스텀
// 컨트롤을 숨기고 그만큼의 좌측 여백을 확보한다.
import { useState, useEffect, useCallback } from 'react'
import { Box } from '@mui/material'
@ -9,6 +10,7 @@ import { Minus, Square, Copy, X } from 'lucide-react'
import { d3roPalette, d3roRadius } from '@d3ro/ui/theme'
const BAR_HEIGHT = 40
const isDarwin = window.electronAPI?.platform === 'darwin'
export function TitleBar(): React.ReactElement {
const [isMaximized, setIsMaximized] = useState(false)
@ -37,6 +39,8 @@ export function TitleBar(): React.ReactElement {
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
// macOS 교통신호(x:16) 전용 여백 — 드래그 영역에는 포함되지 않는다
pl: isDarwin ? '78px' : 1.5,
WebkitAppRegion: 'drag',
userSelect: 'none',
flexShrink: 0,
@ -46,25 +50,27 @@ export function TitleBar(): React.ReactElement {
{/* Drag Region Placeholder */}
<Box sx={{ flex: 1, height: '100%', WebkitAppRegion: 'drag' }} />
{/* Window Controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
gap: '2px',
}}
>
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
<Minus size={14} />
</ControlButton>
<ControlButton onClick={handleMaximize}>
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
</ControlButton>
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
<X size={14} />
</ControlButton>
</Box>
{/* Window Controls — macOS는 네이티브 교통신호 사용 */}
{!isDarwin && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
gap: '2px',
}}
>
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
<Minus size={14} />
</ControlButton>
<ControlButton onClick={handleMaximize}>
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
</ControlButton>
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
<X size={14} />
</ControlButton>
</Box>
)}
</Box>
)
}

Some files were not shown because too many files have changed in this diff Show more