diff --git a/.dockerignore b/.dockerignore index 589bedb..181db27 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,9 +8,15 @@ node_modules/ dist/ **/dist/ build/ +**/build/ out/ +**/out/ test-results/ +**/test-results/ +**/playwright-report/ coverage/ +**/coverage/ +**/*.tsbuildinfo *.log *.tar bin/ diff --git a/.env.example b/.env.example index 53c17a4..9c310cc 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.eslintrc.cjs b/.eslintrc.cjs index bc2269b..fef7baf 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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' + } + } + ] } diff --git a/.forgejo/workflows/deploy-site-windows.yml b/.forgejo/workflows/deploy-site-windows.yml index 5d8171c..4d56a91 100644 --- a/.forgejo/workflows/deploy-site-windows.yml +++ b/.forgejo/workflows/deploy-site-windows.yml @@ -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: diff --git a/.forgejo/workflows/deploy-site.yml b/.forgejo/workflows/deploy-site.yml index 7c58a14..c6b0767 100644 --- a/.forgejo/workflows/deploy-site.yml +++ b/.forgejo/workflows/deploy-site.yml @@ -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 diff --git a/.github/workflows/build-mac.yml b/.github/workflows/build-mac.yml index e8beb46..3c9ea50 100644 --- a/.github/workflows/build-mac.yml +++ b/.github/workflows/build-mac.yml @@ -1,9 +1,6 @@ name: Build macOS on: - push: - tags: - - 'v*' workflow_dispatch: inputs: notarize: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da8f376..65d8b16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 252deb5..74afdcb 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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 diff --git a/.github/workflows/release-signing-ca.yml b/.github/workflows/release-signing-ca.yml index 1af45ef..2eb432c 100644 --- a/.github/workflows/release-signing-ca.yml +++ b/.github/workflows/release-signing-ca.yml @@ -1,9 +1,6 @@ name: Release & Code Signing CA Pipeline on: - push: - tags: - - 'v*' workflow_dispatch: jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93b0dc4..6e670a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 09c4f78..4e261c5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9994da5..78a090e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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"' diff --git a/.nvmrc b/.nvmrc index 209e3ef..60ade1a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +24.19.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index adc95d2..8cae6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile.admin b/Dockerfile.admin index 772ea2a..35bd117 100644 --- a/Dockerfile.admin +++ b/Dockerfile.admin @@ -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 diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index 2dc4ce6..385711a 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -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" + } } } ] diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index 743fe40..5eeff6a 100644 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -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 diff --git a/apps/admin/package.json b/apps/admin/package.json index 2f675b2..c1e58ae 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/admin/src/app/(admin)/releases/page.tsx b/apps/admin/src/app/(admin)/releases/page.tsx index 258619c..b915a94 100644 --- a/apps/admin/src/app/(admin)/releases/page.tsx +++ b/apps/admin/src/app/(admin)/releases/page.tsx @@ -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: