diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5638ffc4..73fa3511 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -3,52 +3,55 @@ name: Build Electron App
on:
workflow_dispatch:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
jobs:
- build-windows:
- runs-on: windows-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
-
- - name: Setup Node.js
- uses: actions/setup-node@v3
- with:
- node-version: '22'
-
- - name: Install dependencies
- run: npm ci
-
- - name: Install app dependencies
- run: npx electron-builder install-app-deps
-
- - name: Build Windows app
- run: npm run build:win
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Upload Windows build
- uses: actions/upload-artifact@v4
- with:
- name: windows-installer
- path: release/**/*.exe
- retention-days: 30
+ build:
+ name: ${{ matrix.name }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: Windows
+ os: windows-latest
+ build-script: build:win
+ artifact-name: windows-release
+ artifact-paths: |
+ release/**/*.exe
+ release/**/latest.yml
+ - name: macOS
+ os: macos-latest
+ build-script: build:mac
+ artifact-name: macos-release
+ artifact-paths: |
+ release/**/*.dmg
+ release/**/*.zip
+ release/**/*.blockmap
+ release/**/latest-mac.yml
+ - name: Linux
+ os: ubuntu-latest
+ build-script: build:linux
+ artifact-name: linux-release
+ artifact-paths: |
+ release/**/*.AppImage
+ release/**/*.deb
+ release/**/latest-linux.yml
- build-macos:
- runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup Node.js
- uses: actions/setup-node@v3
- with:
- node-version: '22'
-
- - name: Setup Python
- uses: actions/setup-python@v4
+ uses: actions/setup-node@v4
with:
- python-version: '3.11'
+ node-version: 22
+ cache: npm
- name: Install dependencies
run: npm ci
@@ -56,46 +59,21 @@ jobs:
- name: Install app dependencies
run: npx electron-builder install-app-deps
- - name: Build macOS app
- run: npm run build:mac
+ - name: Build and publish release assets
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ run: npm run ${{ matrix.build-script }} -- --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Upload macOS build
- uses: actions/upload-artifact@v4
- with:
- name: macos-installer
- path: release/**/*.dmg
- retention-days: 30
-
- build-linux:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
-
- - name: Setup Node.js
- uses: actions/setup-node@v3
- with:
- node-version: '22'
-
- - name: Install dependencies
- run: npm ci
-
- - name: Install app dependencies
- run: npx electron-builder install-app-deps
-
- - name: Build Linux app
- run: npm run build:linux
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build workflow artifacts
+ if: github.event_name == 'workflow_dispatch'
+ run: npm run ${{ matrix.build-script }} -- --publish never
- - name: Upload Linux build
+ - name: Upload workflow artifacts
+ if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
- name: linux-installer
- path: |
- release/**/*.AppImage
- release/**/*.zsync
- release/**/*.deb
+ name: ${{ matrix.artifact-name }}-${{ github.run_number }}
+ path: ${{ matrix.artifact-paths }}
+ if-no-files-found: error
retention-days: 30
diff --git a/.gitignore b/.gitignore
index 70cc387d..96e25e63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,32 +1,40 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-electron
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
-release/**
-*.kiro/
-# npx electron-builder --mac --win
-
-# Playwright
-test-results
-playwright-report/
\ No newline at end of file
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-electron
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+release/**
+*.kiro/
+# npx electron-builder --mac --win
+
+# Playwright
+test-results
+playwright-report/
+
+# Local agent / scratch artifacts
+.captures/
+.omc/
+.omx/
+.tmp/
+tmp/
+backend/data/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f2bbbe29..e1ad225d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,7 +10,7 @@ Thank you for considering contributing to this project! By contributing, you hel
2. **Clone Your Fork**
- Clone your forked repository to your local machine:
```bash
- git clone https://github.com/your-username/openscreen.git
+ git clone https://github.com/your-username/autoscreen.git
```
3. **Create a New Branch**
@@ -43,7 +43,7 @@ Thank you for considering contributing to this project! By contributing, you hel
## Reporting Issues
-If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/siddharthvaddem/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively.
+If you encounter a bug or have a feature request, please use the official Auto Screen support/issues channel for this repository. Provide as much detail as possible to help us address the issue effectively.
## Style Guide
diff --git a/README.md b/README.md
index b42355e7..a7e422b2 100644
--- a/README.md
+++ b/README.md
@@ -2,31 +2,27 @@
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
-
+
-
-
-
-
-# OpenScreen
+# Auto Screen
-OpenScreen is your free, open-source alternative to Screen Studio (sort of).
+Auto Screen is your free, open-source alternative to Screen Studio (sort of).
-If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
+If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. Auto Screen does not offer all Screen Studio features, but covers the basics well!
-Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
+Screen Studio is an awesome product and this is definitely not a 1:1 clone. Auto Screen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
-OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool ๐ and give a shoutout if you feel like it !)
+Auto Screen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool ๐ and give a shoutout if you feel like it !)
-
-
+
+
## Core Features
@@ -43,34 +39,123 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
## Installation
-Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
+Download the latest installer for your platform from the official Auto Screen release channel.
+
+### Release artifacts
+
+Auto Screen now builds separate artifacts per operating system.
+
+| OS | Recommended download | Notes |
+| --- | --- | --- |
+| macOS Apple Silicon | `Auto Screen--mac-arm64.dmg` | Primary installer for M1/M2/M3 Macs |
+| macOS Intel | `Auto Screen--mac-x64.dmg` | Installer for Intel Macs |
+| Windows 64-bit | `Auto Screen--win-x64.exe` | NSIS installer |
+| Linux AppImage | `Auto Screen--linux-x64.AppImage` | Best for direct download distribution |
+| Linux Debian/Ubuntu | `Auto Screen--linux-x64.deb` | Recommended for Debian-based systems |
+
+Generated files are written to:
+
+```bash
+release//
+```
+
+To print the exact files created after a build:
+
+```bash
+npm run release:artifacts
+```
+
+To remove unpacked folders, old installer names, and keep only release-ready files:
+
+```bash
+npm run release:clean
+```
+
+### Build commands by OS
+
+```bash
+# macOS universal release targets
+npm run build:mac
+
+# macOS per architecture
+npm run build:mac:arm64
+npm run build:mac:x64
+
+# Windows
+npm run build:win
+npm run build:win:x64
+
+# Linux
+npm run build:linux
+npm run build:linux:x64
+```
+
+### Recommended release hosts
+
+| Target | Recommended build host | Status |
+| --- | --- | --- |
+| macOS `.dmg` | macOS | Verified |
+| Windows `.exe` | Windows | Config prepared |
+| Linux `.AppImage` / `.deb` | macOS or Linux | Verified on macOS |
+
+For final commercial release, build each platform on its native OS when possible, then run `npm run release:clean` before upload.
+
+### GitHub release automation
+
+The repo now supports a two-lane release workflow in GitHub Actions:
+
+- `workflow_dispatch`: builds macOS, Windows, and Linux packages and uploads the release-ready artifacts to the workflow run.
+- `push` tags matching `v*`: builds the same packages and publishes the installers plus updater metadata to GitHub Releases.
+
+Tag builds should match `package.json` versioning. Example: version `1.3.0` should be released with tag `v1.3.0`.
+
+Expected release assets now include:
+
+- macOS: `.dmg`, `.zip`, `.blockmap`, `latest-mac.yml`
+- Windows: `.exe`, `latest.yml`
+- Linux: `.AppImage`, `.deb`, `latest-linux.yml`
### macOS
-If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
+If macOS Gatekeeper blocks the app before signing/notarization is fully configured, you can temporarily bypass quarantine after install:
```bash
-xattr -rd com.apple.quarantine /Applications/Openscreen.app
+xattr -rd com.apple.quarantine /Applications/Auto\ Screen.app
```
-Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command.
+Give your terminal Full Disk Access in **System Settings > Privacy & Security** if needed, then run the command above.
-After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
+After that, open **System Settings > Privacy & Security** and allow the required permissions for screen recording, microphone, camera, and accessibility.
+
+### Windows
+
+Use the `.exe` installer for most users.
+
+- `Auto Screen--win-x64.exe`
+
+If Windows SmartScreen warns before code signing is configured, choose **More info** โ **Run anyway** only for internal testing builds.
### Linux
-Download the `.AppImage` file from the releases page. Make it executable and run:
+For AppImage:
+
+```bash
+chmod +x Auto\ Screen--linux-x64.AppImage
+./Auto\ Screen--linux-x64.AppImage
+```
+
+For Debian/Ubuntu:
```bash
-chmod +x Openscreen-Linux-*.AppImage
-./Openscreen-Linux-*.AppImage
+sudo apt install ./Auto\ Screen--linux-x64.deb
```
You may need to grant screen recording permissions depending on your desktop environment.
-**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
+If the AppImage fails with a sandbox error, run:
+
```bash
-./Openscreen-Linux-*.AppImage --no-sandbox
+./Auto\ Screen--linux-x64.AppImage --no-sandbox
```
### Limitations
@@ -89,13 +174,16 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron
- PixiJS
- dnd-timeline
+## Local MCP workflow
+If you are wiring Auto Screen to CLI agents such as Codex CLI or Claude Code, see [docs/local-mcp-workflow.md](docs/local-mcp-workflow.md).
+
---
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue ๐_
## Contributing
-Contributions are welcome! If youโd like to help out or see whatโs currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
+Contributions are welcome! If youโd like to help out or see whatโs currently being worked on, please use the official Auto Screen contribution channel and project board for the latest roadmap and contribution guidelines.
## License
diff --git a/assets/branding/autoscreen-brandmark.svg b/assets/branding/autoscreen-brandmark.svg
new file mode 100644
index 00000000..ba17689c
--- /dev/null
+++ b/assets/branding/autoscreen-brandmark.svg
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/branding/autoscreen-recording-brandmark.svg b/assets/branding/autoscreen-recording-brandmark.svg
new file mode 100644
index 00000000..aaffd03d
--- /dev/null
+++ b/assets/branding/autoscreen-recording-brandmark.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 00000000..7d8e8e4d
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,63 @@
+# Auto Screen Backend MVP Skeleton
+
+ํ์ฌ ํด๋๋ Auto Screen์ ๊ณ์ /๊ตฌ๋
/entitlement ์๋ฒ ๋ผ๋์
๋๋ค.
+
+## ๋ชฉํ
+- ์ด๋ฉ์ผ + Google ๋ก๊ทธ์ธ
+- ์ดํ Kakao / Naver ํ์ฅ
+- Toss ์ 15,900์ ๊ตฌ๋
+- Electron desktop session exchange
+- entitlement ๊ธฐ๋ฐ Free / Pro ๊ธฐ๋ฅ ๋ถ๊ธฐ
+
+## ์์ ์๋ํฌ์ธํธ
+- `GET /health`
+- `GET /api/config/public`
+- `POST /api/auth/desktop/exchange`
+- `POST /api/auth/refresh`
+- `POST /api/auth/logout`
+- `POST /api/auth/phone/request`
+- `POST /api/auth/phone/verify`
+- `POST /api/auth/signup`
+- `POST /api/auth/login`
+- `GET /api/admin/storage/status`
+- `GET /api/admin/signup-audit`
+- `POST /api/billing/checkout/session`
+- `POST /api/billing/webhooks/toss`
+- `GET /api/billing/subscription`
+- `GET /api/entitlements`
+
+## ํ๋งคํ ๊ฐ์
์ ์ฑ
๋ฉ๋ชจ
+- ํ์๊ฐ์
ํ์ ํญ๋ชฉ: ์์ด๋ ๋น๋ฐ๋ฒํธ ์ฑ ์ด๋ฆ ์ด๋ฉ์ผ ํด๋ํฐ ๋ฒํธ
+- ๋ฌด๋ฃ ํ๋์ ํด๋ํฐ ์ธ์ฆ ์๋ฃ ๊ธฐ์ค์ผ๋ก 1ํ ์ง๊ธ
+- ๋๋ฐ์ด์ค ์๋ณ์์ IP๋ฅผ ๊ฐ์ด ์ ์ฅํด์ ๋ฐ๋ณต ์
์ฉ ํ์ง
+- ๋ฌธ์ ์ธ์ฆ ๊ณต๊ธ์๋ ๊ตญ๋ด ๊ธฐ์ค Solapi ์ฐ์ ๊ฒํ
+- ์ฝ๊ด ๋์๋ ํ์/์ ํ ๋ถ๋ฆฌ ์ ์ฅ ํ์
+
+## ์คํ ์์
+```bash
+cd backend
+npm install
+npm run dev
+```
+
+## ํ์ฌ ์ํ
+- Express ์ฑ ๋ผ๋ ์์ฑ ์๋ฃ
+- ํ์ผ ๊ธฐ๋ฐ ์๋ฒ ์ ์ฅ์๋ก signup login phone verification ๋์ ๊ฐ๋ฅ
+- Solapi dry run ๊ธฐ๋ณธ๊ฐ์ผ๋ก ๊ฐ๋ฐ ๋ชจ๋ ์ธ์ฆ ์ฝ๋ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฐ๋ฅ
+- ์ค์ Postgres ์ฐ๊ฒฐ, OAuth, Toss, deep link exchange๋ ๋ค์ ๋จ๊ณ์์ ๊ตฌํ
+
+## ์ธ์ฆ ๊ฐ๋ฐ ๋ฉ๋ชจ
+- ์๋ฒ ์ ์ฅ ํ์ผ ๊ฒฝ๋ก: `backend/data/auth-store.json`
+- ๊ธฐ๋ณธ์ `SMS_DRY_RUN=true` ์ด๋ผ์ ์ค์ ๋ฌธ์ ๋์ ์ฝ๋ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ๋ฐํ
+- ์ค๋ฌธ์ ์ ํ ์ ์๋ ํ๊ฒฝ๋ณ์ ํ์
+ - `SMS_DRY_RUN=false`
+ - `SMS_SENDER`
+ - `SMS_API_KEY`
+ - `SMS_API_SECRET`
+ - ์ ํ `SMS_API_BASE_URL`
+- Postgres ์ค๋น ์ํ ํ์ธ์ฉ ํ๊ฒฝ๋ณ์
+ - `DATABASE_URL`
+ - ์ ํ `DATABASE_SSL=true`
+- `DATABASE_URL` ์ด ์์ผ๋ฉด ์ธ์ฆ ์ฐ๊ธฐ๋ Postgres ๊ฒฝ๋ก๋ฅผ ์ฐ์ ์ฌ์ฉ
+- ๊ด๋ฆฌ์ ์กฐํ ๋ณดํธ๊ฐ ํ์ํ๋ฉด
+ - `ADMIN_API_TOKEN`
diff --git a/backend/package-lock.json b/backend/package-lock.json
new file mode 100644
index 00000000..5a558dd3
--- /dev/null
+++ b/backend/package-lock.json
@@ -0,0 +1,1660 @@
+{
+ "name": "autoscreen-backend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "autoscreen-backend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@types/pg": "^8.20.0",
+ "express": "^5.2.1",
+ "pg": "^8.20.0",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.6",
+ "@types/node": "^25.0.3",
+ "tsx": "^4.20.6",
+ "typescript": "^5.2.2"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.5.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
+ "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.12.0",
+ "pg-pool": "^3.13.0",
+ "pg-protocol": "^1.13.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 00000000..ac254b29
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "autoscreen-backend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc -p tsconfig.json",
+ "start": "node dist/index.js"
+ },
+ "dependencies": {
+ "@types/pg": "^8.20.0",
+ "express": "^5.2.1",
+ "pg": "^8.20.0",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.6",
+ "@types/node": "^25.0.3",
+ "tsx": "^4.20.6",
+ "typescript": "^5.2.2"
+ }
+}
diff --git a/backend/src/app.ts b/backend/src/app.ts
new file mode 100644
index 00000000..2804e3d7
--- /dev/null
+++ b/backend/src/app.ts
@@ -0,0 +1,35 @@
+import express from "express";
+import { config } from "./config.js";
+import { adminRouter } from "./routes/admin.js";
+import { authRouter } from "./routes/auth.js";
+import { billingRouter } from "./routes/billing.js";
+import { entitlementsRouter } from "./routes/entitlements.js";
+import { healthRouter } from "./routes/health.js";
+
+export function createApp() {
+ const app = express();
+
+ app.use(express.json());
+ app.use(express.urlencoded({ extended: true }));
+
+ app.use(healthRouter);
+ app.get("/api/config/public", (_req, res) => {
+ res.json({
+ ok: true,
+ appName: config.appName,
+ appOrigin: config.appOrigin,
+ googleAuthEnabled: config.googleAuthEnabled,
+ kakaoAuthEnabled: config.kakaoAuthEnabled,
+ naverAuthEnabled: config.naverAuthEnabled,
+ tossBillingEnabled: config.tossBillingEnabled,
+ proMonthlyPriceKrw: 15900,
+ });
+ });
+
+ app.use("/api/auth", authRouter);
+ app.use("/api/admin", adminRouter);
+ app.use("/api/billing", billingRouter);
+ app.use("/api", entitlementsRouter);
+
+ return app;
+}
diff --git a/backend/src/config.ts b/backend/src/config.ts
new file mode 100644
index 00000000..216c03fb
--- /dev/null
+++ b/backend/src/config.ts
@@ -0,0 +1,32 @@
+export const config = {
+ port: Number(process.env.PORT || 4242),
+ appName: "Auto Screen Backend",
+ appOrigin: process.env.APP_ORIGIN || "https://autoscreen.app",
+ pricingPath: "/pricing",
+ googleAuthEnabled: process.env.GOOGLE_AUTH_ENABLED === "true",
+ kakaoAuthEnabled: process.env.KAKAO_AUTH_ENABLED === "true",
+ naverAuthEnabled: process.env.NAVER_AUTH_ENABLED === "true",
+ tossBillingEnabled: process.env.TOSS_BILLING_ENABLED === "true",
+ databaseUrl: process.env.DATABASE_URL || "",
+ databaseSsl: process.env.DATABASE_SSL === "true",
+ adminApiToken: process.env.ADMIN_API_TOKEN || "",
+ smsProvider: process.env.SMS_PROVIDER || "solapi",
+ smsSender: process.env.SMS_SENDER || "",
+ smsApiKey: process.env.SMS_API_KEY || "",
+ smsApiSecret: process.env.SMS_API_SECRET || "",
+ smsApiBaseUrl: process.env.SMS_API_BASE_URL || "https://api.solapi.com",
+ smsDryRun: process.env.SMS_DRY_RUN !== "false",
+ freeTrialDays: Number(process.env.FREE_TRIAL_DAYS || 7),
+ freeTrialPerPhoneLimit: Number(process.env.FREE_TRIAL_PER_PHONE_LIMIT || 1),
+ freeTrialPerDeviceLimit: Number(process.env.FREE_TRIAL_PER_DEVICE_LIMIT || 1),
+ phoneVerificationRequestCooldownMs: Number(
+ process.env.PHONE_VERIFICATION_REQUEST_COOLDOWN_MS || 30000,
+ ),
+ phoneVerificationCodeTtlMs: Number(process.env.PHONE_VERIFICATION_CODE_TTL_MS || 180000),
+ phoneVerificationMaxAttempts: Number(process.env.PHONE_VERIFICATION_MAX_ATTEMPTS || 5),
+ phoneVerificationMaxRequestsPerHour: Number(
+ process.env.PHONE_VERIFICATION_MAX_REQUESTS_PER_HOUR || 5,
+ ),
+ termsVersion: process.env.TERMS_VERSION || "2026-04-09",
+ privacyVersion: process.env.PRIVACY_VERSION || "2026-04-09",
+} as const;
diff --git a/backend/src/db/pg.ts b/backend/src/db/pg.ts
new file mode 100644
index 00000000..fe182961
--- /dev/null
+++ b/backend/src/db/pg.ts
@@ -0,0 +1,51 @@
+import { Pool } from "pg";
+import { config } from "../config.js";
+
+let pool: Pool | null = null;
+
+export function isPostgresEnabled() {
+ return Boolean(config.databaseUrl);
+}
+
+export function getPgPool() {
+ if (!config.databaseUrl) {
+ throw new Error("DATABASE_URL ์ด ๋น์ด ์์ต๋๋ค.");
+ }
+ if (!pool) {
+ pool = new Pool({
+ connectionString: config.databaseUrl,
+ max: 5,
+ ssl: config.databaseSsl ? { rejectUnauthorized: false } : undefined,
+ });
+ }
+ return pool;
+}
+
+export async function getPostgresStatus() {
+ if (!config.databaseUrl) {
+ return {
+ configured: false,
+ driver: "file",
+ connected: false,
+ message: "DATABASE_URL ์ด ์์ง ์ค์ ๋์ง ์์์ต๋๋ค.",
+ };
+ }
+
+ try {
+ const result = await getPgPool().query("select now() as now");
+ return {
+ configured: true,
+ driver: "postgres",
+ connected: true,
+ serverTime: result.rows[0]?.now,
+ message: "Postgres ์ฐ๊ฒฐ์ด ์ ์์
๋๋ค.",
+ };
+ } catch (error) {
+ return {
+ configured: true,
+ driver: "postgres",
+ connected: false,
+ message: error instanceof Error ? error.message : "Postgres ์ฐ๊ฒฐ ํ์ธ์ ์คํจํ์ต๋๋ค.",
+ };
+ }
+}
diff --git a/backend/src/db/sql/001_users.sql b/backend/src/db/sql/001_users.sql
new file mode 100644
index 00000000..5f7d0b43
--- /dev/null
+++ b/backend/src/db/sql/001_users.sql
@@ -0,0 +1,21 @@
+create table if not exists users (
+ id uuid primary key,
+ username text not null unique,
+ email text not null unique,
+ family_name text not null,
+ given_name text not null,
+ display_name text not null,
+ phone_number text not null unique,
+ phone_verified_at timestamptz not null,
+ password_salt text not null,
+ password_hash text not null,
+ signup_device_id text,
+ signup_ip inet,
+ plan text not null default 'free',
+ subscription_status text not null default 'trial',
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ last_login_at timestamptz not null default now()
+);
+
+create index if not exists idx_users_signup_device_id on users(signup_device_id);
diff --git a/backend/src/db/sql/002_devices.sql b/backend/src/db/sql/002_devices.sql
new file mode 100644
index 00000000..7f853d7d
--- /dev/null
+++ b/backend/src/db/sql/002_devices.sql
@@ -0,0 +1,8 @@
+create table if not exists devices (
+ id uuid primary key,
+ user_id uuid not null references users(id) on delete cascade,
+ device_name text,
+ platform text,
+ last_seen_at timestamptz,
+ created_at timestamptz not null default now()
+);
diff --git a/backend/src/db/sql/003_subscriptions.sql b/backend/src/db/sql/003_subscriptions.sql
new file mode 100644
index 00000000..e42df1dd
--- /dev/null
+++ b/backend/src/db/sql/003_subscriptions.sql
@@ -0,0 +1,15 @@
+create table if not exists subscriptions (
+ id uuid primary key,
+ user_id uuid not null references users(id) on delete cascade,
+ provider text not null default 'toss',
+ plan_code text not null default 'pro_monthly_15900_krw',
+ status text not null default 'inactive',
+ provider_customer_key text,
+ provider_billing_key text,
+ current_period_start timestamptz,
+ current_period_end timestamptz,
+ cancel_at timestamptz,
+ canceled_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
diff --git a/backend/src/db/sql/004_desktop_login_codes.sql b/backend/src/db/sql/004_desktop_login_codes.sql
new file mode 100644
index 00000000..e8662396
--- /dev/null
+++ b/backend/src/db/sql/004_desktop_login_codes.sql
@@ -0,0 +1,8 @@
+create table if not exists desktop_login_codes (
+ id uuid primary key,
+ user_id uuid not null references users(id) on delete cascade,
+ code text not null unique,
+ used_at timestamptz,
+ expires_at timestamptz not null,
+ created_at timestamptz not null default now()
+);
diff --git a/backend/src/db/sql/005_phone_verifications.sql b/backend/src/db/sql/005_phone_verifications.sql
new file mode 100644
index 00000000..7d9268da
--- /dev/null
+++ b/backend/src/db/sql/005_phone_verifications.sql
@@ -0,0 +1,21 @@
+create table if not exists phone_verifications (
+ id uuid primary key,
+ phone_number text not null,
+ purpose text not null,
+ verification_code_hash text not null,
+ verification_token text,
+ attempt_count integer not null default 0,
+ request_count integer not null default 1,
+ verified_at timestamptz,
+ expires_at timestamptz not null,
+ device_id text,
+ request_ip inet,
+ requested_at timestamptz not null default now(),
+ sms_provider text,
+ sms_message_id text,
+ created_at timestamptz not null default now()
+);
+
+create index if not exists idx_phone_verifications_phone_number on phone_verifications(phone_number);
+create index if not exists idx_phone_verifications_device_id on phone_verifications(device_id);
+create index if not exists idx_phone_verifications_requested_at on phone_verifications(requested_at desc);
diff --git a/backend/src/db/sql/006_billing_events.sql b/backend/src/db/sql/006_billing_events.sql
new file mode 100644
index 00000000..ffc6cd9d
--- /dev/null
+++ b/backend/src/db/sql/006_billing_events.sql
@@ -0,0 +1,9 @@
+create table if not exists billing_events (
+ id uuid primary key,
+ provider text not null,
+ event_type text not null,
+ event_id text,
+ user_id uuid,
+ payload jsonb not null,
+ created_at timestamptz not null default now()
+);
diff --git a/backend/src/db/sql/007_signup_audit.sql b/backend/src/db/sql/007_signup_audit.sql
new file mode 100644
index 00000000..0bf44f4c
--- /dev/null
+++ b/backend/src/db/sql/007_signup_audit.sql
@@ -0,0 +1,36 @@
+create table if not exists signup_audit_logs (
+ id uuid primary key,
+ username text,
+ email text,
+ phone_number text,
+ device_id text,
+ signup_ip inet,
+ outcome text not null,
+ reason text,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists user_agreements (
+ id uuid primary key,
+ user_id uuid not null references users(id) on delete cascade,
+ terms_version text not null,
+ privacy_version text not null,
+ marketing_opt_in boolean not null default false,
+ accepted_terms_at timestamptz not null,
+ accepted_privacy_at timestamptz not null,
+ accepted_marketing_at timestamptz,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists free_trial_grants (
+ id uuid primary key,
+ user_id uuid not null references users(id) on delete cascade,
+ phone_number text not null,
+ device_id text,
+ granted_at timestamptz not null default now(),
+ expires_at timestamptz not null,
+ source text not null default 'signup'
+);
+
+create unique index if not exists uniq_free_trial_phone on free_trial_grants(phone_number);
+create unique index if not exists uniq_free_trial_device on free_trial_grants(device_id) where device_id is not null;
diff --git a/backend/src/index.ts b/backend/src/index.ts
new file mode 100644
index 00000000..9e52c0dc
--- /dev/null
+++ b/backend/src/index.ts
@@ -0,0 +1,8 @@
+import { createApp } from "./app.js";
+import { config } from "./config.js";
+
+const app = createApp();
+
+app.listen(config.port, () => {
+ console.log(`[Auto Screen Backend] listening on http://localhost:${config.port}`);
+});
diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts
new file mode 100644
index 00000000..838a56d1
--- /dev/null
+++ b/backend/src/routes/admin.ts
@@ -0,0 +1,42 @@
+import { Router } from "express";
+import { z } from "zod";
+import { config } from "../config.js";
+import { getAdminStorageStatus, listSignupAuditLogs } from "../services/admin-service.js";
+
+export const adminRouter = Router();
+
+function isAuthorized(token: string | undefined) {
+ if (!config.adminApiToken) {
+ return true;
+ }
+ return token === config.adminApiToken;
+}
+
+adminRouter.use((req, res, next) => {
+ const token = req.header("x-admin-token") || req.query.token?.toString();
+ if (!isAuthorized(token)) {
+ res.status(401).json({ ok: false, error: "๊ด๋ฆฌ์ ํ ํฐ์ด ํ์ํฉ๋๋ค." });
+ return;
+ }
+ next();
+});
+
+adminRouter.get("/storage/status", async (_req, res) => {
+ const status = await getAdminStorageStatus();
+ res.json({ ok: true, ...status });
+});
+
+adminRouter.get("/signup-audit", async (req, res) => {
+ const schema = z.object({
+ limit: z.coerce.number().min(1).max(100).optional(),
+ search: z.string().optional(),
+ outcome: z.string().optional(),
+ });
+ const parsed = schema.safeParse(req.query);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+ const result = await listSignupAuditLogs(parsed.data);
+ res.json({ ok: true, ...result });
+});
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
new file mode 100644
index 00000000..17d761d2
--- /dev/null
+++ b/backend/src/routes/auth.ts
@@ -0,0 +1,171 @@
+import { type Request, Router } from "express";
+import { z } from "zod";
+import {
+ confirmPhoneVerification,
+ exchangeDesktopCode,
+ loginWithEmail,
+ logoutDesktopSession,
+ refreshDesktopSession,
+ requestPhoneVerification,
+ signupWithEmail,
+} from "../services/auth-service.js";
+
+export const authRouter = Router();
+
+const exchangeSchema = z.object({
+ code: z.string().min(1),
+ deviceName: z.string().optional(),
+ platform: z.string().optional(),
+});
+
+const refreshSchema = z.object({
+ refreshToken: z.string().min(1),
+});
+
+const phoneVerificationRequestSchema = z.object({
+ phoneNumber: z.string().min(10),
+ purpose: z.enum(["signup", "login"]),
+ deviceId: z.string().optional(),
+});
+
+const phoneVerificationConfirmSchema = phoneVerificationRequestSchema.extend({
+ code: z.string().min(4).max(6),
+});
+
+const emailSignupSchema = z.object({
+ username: z.string().min(4),
+ email: z.string().email(),
+ familyName: z.string().min(1),
+ givenName: z.string().min(1),
+ phoneNumber: z.string().min(10),
+ password: z.string().min(8),
+ verificationToken: z.string().min(8),
+ deviceId: z.string().optional(),
+ agreements: z.object({
+ terms: z.literal(true),
+ privacy: z.literal(true),
+ marketing: z.boolean(),
+ }),
+});
+
+const emailLoginSchema = z.object({
+ identifier: z.string().min(1),
+ password: z.string().min(1),
+ deviceId: z.string().optional(),
+});
+
+function getRequestIp(req: Request) {
+ const forwarded = req.headers["x-forwarded-for"];
+ if (typeof forwarded === "string") {
+ return forwarded.split(",")[0]?.trim();
+ }
+ if (Array.isArray(forwarded)) {
+ return forwarded[0]?.trim();
+ }
+ return req.ip;
+}
+
+authRouter.post("/desktop/exchange", async (req, res) => {
+ const parsed = exchangeSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ const session = await exchangeDesktopCode(parsed.data);
+ res.json({ ok: true, session });
+});
+
+authRouter.post("/refresh", async (req, res) => {
+ const parsed = refreshSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ const result = await refreshDesktopSession(parsed.data.refreshToken);
+ res.json({ ok: true, ...result });
+});
+
+authRouter.post("/logout", async (req, res) => {
+ const parsed = refreshSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ const result = await logoutDesktopSession(parsed.data.refreshToken);
+ res.json({ ok: true, ...result });
+});
+
+authRouter.post("/phone/request", async (req, res) => {
+ const parsed = phoneVerificationRequestSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ try {
+ const result = await requestPhoneVerification(parsed.data, { requestIp: getRequestIp(req) });
+ res.json({ ok: true, ...result });
+ } catch (error) {
+ res.status(400).json({
+ ok: false,
+ error: error instanceof Error ? error.message : "๋ฌธ์ ์ธ์ฆ ์์ฒญ์ ์คํจํ์ต๋๋ค.",
+ });
+ }
+});
+
+authRouter.post("/phone/verify", async (req, res) => {
+ const parsed = phoneVerificationConfirmSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ try {
+ const result = await confirmPhoneVerification(parsed.data);
+ res.json({ ok: true, ...result });
+ } catch (error) {
+ res.status(400).json({
+ ok: false,
+ error: error instanceof Error ? error.message : "ํด๋ํฐ ์ธ์ฆ์ ์คํจํ์ต๋๋ค.",
+ });
+ }
+});
+
+authRouter.post("/signup", async (req, res) => {
+ const parsed = emailSignupSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ try {
+ const result = await signupWithEmail(parsed.data, { requestIp: getRequestIp(req) });
+ res.json({ ok: true, ...result });
+ } catch (error) {
+ res.status(400).json({
+ ok: false,
+ error: error instanceof Error ? error.message : "ํ์๊ฐ์
์ ์คํจํ์ต๋๋ค.",
+ });
+ }
+});
+
+authRouter.post("/login", async (req, res) => {
+ const parsed = emailLoginSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ try {
+ const result = await loginWithEmail(parsed.data);
+ res.json({ ok: true, ...result });
+ } catch (error) {
+ res.status(400).json({
+ ok: false,
+ error: error instanceof Error ? error.message : "๋ก๊ทธ์ธ์ ์คํจํ์ต๋๋ค.",
+ });
+ }
+});
diff --git a/backend/src/routes/billing.ts b/backend/src/routes/billing.ts
new file mode 100644
index 00000000..b7ecf39b
--- /dev/null
+++ b/backend/src/routes/billing.ts
@@ -0,0 +1,34 @@
+import { Router } from "express";
+import { z } from "zod";
+import {
+ createCheckoutSession,
+ getCurrentSubscription,
+ handleTossWebhook,
+} from "../services/subscription-service.js";
+
+export const billingRouter = Router();
+
+const checkoutSchema = z.object({
+ planCode: z.literal("pro_monthly_15900_krw"),
+});
+
+billingRouter.post("/checkout/session", async (req, res) => {
+ const parsed = checkoutSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ ok: false, error: parsed.error.flatten() });
+ return;
+ }
+
+ const session = await createCheckoutSession(parsed.data.planCode);
+ res.json({ ok: true, ...session });
+});
+
+billingRouter.post("/webhooks/toss", async (req, res) => {
+ const result = await handleTossWebhook(req.body);
+ res.json({ ok: true, ...result });
+});
+
+billingRouter.get("/subscription", async (_req, res) => {
+ const subscription = await getCurrentSubscription();
+ res.json({ ok: true, ...subscription });
+});
diff --git a/backend/src/routes/entitlements.ts b/backend/src/routes/entitlements.ts
new file mode 100644
index 00000000..32093cfa
--- /dev/null
+++ b/backend/src/routes/entitlements.ts
@@ -0,0 +1,9 @@
+import { Router } from "express";
+import { getEntitlements } from "../services/subscription-service.js";
+
+export const entitlementsRouter = Router();
+
+entitlementsRouter.get("/entitlements", async (_req, res) => {
+ const result = await getEntitlements();
+ res.json({ ok: true, ...result });
+});
diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts
new file mode 100644
index 00000000..dfe72781
--- /dev/null
+++ b/backend/src/routes/health.ts
@@ -0,0 +1,7 @@
+import { Router } from "express";
+
+export const healthRouter = Router();
+
+healthRouter.get("/health", (_req, res) => {
+ res.json({ ok: true, service: "autoscreen-backend" });
+});
diff --git a/backend/src/services/admin-service.ts b/backend/src/services/admin-service.ts
new file mode 100644
index 00000000..2a2c0d15
--- /dev/null
+++ b/backend/src/services/admin-service.ts
@@ -0,0 +1,115 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { getPgPool, getPostgresStatus, isPostgresEnabled } from "../db/pg.js";
+
+interface SignupAuditLogRecord {
+ id: string;
+ username?: string;
+ email?: string;
+ phoneNumber?: string;
+ deviceId?: string;
+ signupIp?: string;
+ outcome: string;
+ reason?: string;
+ createdAt: string;
+}
+
+interface AuthStoreSnapshot {
+ signupAuditLogs?: SignupAuditLogRecord[];
+}
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const STORE_PATH = path.join(__dirname, "../../data/auth-store.json");
+
+async function readFileAuditLogs(): Promise {
+ try {
+ const raw = await fs.readFile(STORE_PATH, "utf8");
+ const parsed = JSON.parse(raw) as AuthStoreSnapshot;
+ return Array.isArray(parsed.signupAuditLogs) ? parsed.signupAuditLogs : [];
+ } catch {
+ return [];
+ }
+}
+
+export async function listSignupAuditLogs(options?: {
+ limit?: number;
+ search?: string;
+ outcome?: string;
+}) {
+ const limit = Math.min(Math.max(options?.limit || 20, 1), 100);
+ const search = options?.search?.trim().toLowerCase();
+ const outcome = options?.outcome?.trim().toLowerCase();
+
+ if (isPostgresEnabled()) {
+ try {
+ const where: string[] = [];
+ const values: unknown[] = [];
+ if (search) {
+ values.push(`%${search}%`);
+ where.push(`(
+ coalesce(username, '') ilike $${values.length}
+ or coalesce(email, '') ilike $${values.length}
+ or coalesce(phone_number, '') ilike $${values.length}
+ or coalesce(device_id, '') ilike $${values.length}
+ or coalesce(reason, '') ilike $${values.length}
+ )`);
+ }
+ if (outcome) {
+ values.push(outcome);
+ where.push(`lower(outcome) = $${values.length}`);
+ }
+ values.push(limit);
+ const sql = `
+ select id, username, email, phone_number as "phoneNumber", device_id as "deviceId", signup_ip as "signupIp", outcome, reason, created_at as "createdAt"
+ from signup_audit_logs
+ ${where.length ? `where ${where.join(" and ")}` : ""}
+ order by created_at desc
+ limit $${values.length}
+ `;
+ const result = await getPgPool().query(sql, values);
+ return {
+ source: "postgres",
+ logs: result.rows,
+ };
+ } catch {
+ const logs = await readFileAuditLogs();
+ return {
+ source: "file-fallback",
+ logs: logs
+ .filter((item) => {
+ if (outcome && item.outcome.toLowerCase() !== outcome) return false;
+ if (!search) return true;
+ return [item.username, item.email, item.phoneNumber, item.deviceId, item.reason]
+ .filter(Boolean)
+ .some((value) => String(value).toLowerCase().includes(search));
+ })
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ .slice(0, limit),
+ };
+ }
+ }
+
+ const logs = await readFileAuditLogs();
+ return {
+ source: "file",
+ logs: logs
+ .filter((item) => {
+ if (outcome && item.outcome.toLowerCase() !== outcome) return false;
+ if (!search) return true;
+ return [item.username, item.email, item.phoneNumber, item.deviceId, item.reason]
+ .filter(Boolean)
+ .some((value) => String(value).toLowerCase().includes(search));
+ })
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ .slice(0, limit),
+ };
+}
+
+export async function getAdminStorageStatus() {
+ const postgres = await getPostgresStatus();
+ return {
+ storageDriver: postgres.configured ? "postgres-ready" : "file",
+ postgres,
+ };
+}
diff --git a/backend/src/services/auth-postgres-service.ts b/backend/src/services/auth-postgres-service.ts
new file mode 100644
index 00000000..95dfd1c9
--- /dev/null
+++ b/backend/src/services/auth-postgres-service.ts
@@ -0,0 +1,455 @@
+import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
+import { config } from "../config.js";
+import { getPgPool } from "../db/pg.js";
+import { sendVerificationSms } from "./sms-service.js";
+
+interface RequestMeta {
+ requestIp?: string;
+}
+
+interface DesktopSessionResponse {
+ accessToken: string;
+ refreshToken: string;
+ expiresIn: number;
+ user: {
+ id: string;
+ email: string;
+ displayName: string;
+ };
+ subscription: {
+ plan: "free" | "pro";
+ status: "inactive" | "trial" | "active";
+ };
+ entitlements: string[];
+}
+
+interface PhoneVerificationRequest {
+ phoneNumber: string;
+ purpose: "signup" | "login";
+ deviceId?: string;
+}
+
+interface PhoneVerificationConfirmRequest {
+ phoneNumber: string;
+ code: string;
+ purpose: "signup" | "login";
+ deviceId?: string;
+}
+
+interface EmailSignupRequest {
+ username: string;
+ email: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId?: string;
+ agreements: {
+ terms: boolean;
+ privacy: boolean;
+ marketing: boolean;
+ };
+}
+
+interface EmailLoginRequest {
+ identifier: string;
+ password: string;
+ deviceId?: string;
+}
+
+const SESSION_TTL_SECONDS = 60 * 60 * 24 * 14;
+
+function normalizeEmail(email: string) {
+ return email.trim().toLowerCase();
+}
+
+function normalizeUsername(username: string) {
+ return username.trim().toLowerCase();
+}
+
+function normalizePhone(phoneNumber: string) {
+ return phoneNumber.replace(/\D/g, "");
+}
+
+function hashPassword(password: string, salt: string) {
+ return scryptSync(password, salt, 64).toString("hex");
+}
+
+function hashVerificationCode(code: string) {
+ return createHash("sha256").update(code).digest("hex");
+}
+
+function createToken(length = 24) {
+ return randomBytes(length).toString("hex");
+}
+
+function createUserSession(user: {
+ id: string;
+ email: string;
+ displayName: string;
+ plan: "free" | "pro";
+ subscriptionStatus: "inactive" | "trial" | "active";
+}): DesktopSessionResponse {
+ const isPro = user.plan === "pro";
+ return {
+ accessToken: `desktop-access-${createToken(18)}`,
+ refreshToken: `desktop-refresh-${createToken(18)}`,
+ expiresIn: SESSION_TTL_SECONDS,
+ user: {
+ id: user.id,
+ email: user.email,
+ displayName: user.displayName,
+ },
+ subscription: {
+ plan: user.plan,
+ status: user.subscriptionStatus,
+ },
+ entitlements: isPro
+ ? ["basic_recording", "basic_editing", "pro_export", "mcp_editing"]
+ : ["basic_recording", "basic_editing"],
+ };
+}
+
+function maskPhone(phoneNumber: string) {
+ return phoneNumber.replace(/(\d{3})\d+(\d{2})$/, "$1****$2");
+}
+
+function validateSignupFields(input: EmailSignupRequest) {
+ const username = normalizeUsername(input.username);
+ const email = normalizeEmail(input.email);
+ const familyName = input.familyName.trim();
+ const givenName = input.givenName.trim();
+ const phoneNumber = normalizePhone(input.phoneNumber);
+ const password = input.password.trim();
+ const deviceId = input.deviceId?.trim();
+ if (!/^[a-z0-9_]{4,20}$/.test(username))
+ throw new Error("์์ด๋๋ ์๋ฌธ ์๋ฌธ์ ์ซ์ ๋ฐ์ค๋ก 4์ ์ด์ 20์ ์ดํ๋ง ๊ฐ๋ฅํฉ๋๋ค.");
+ if (!email || !email.includes("@")) throw new Error("์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์.");
+ if (!familyName || !givenName) throw new Error("์ฑ๊ณผ ์ด๋ฆ์ ๋ชจ๋ ์
๋ ฅํด์ฃผ์ธ์.");
+ if (phoneNumber.length < 10 || phoneNumber.length > 11)
+ throw new Error("ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ํํ ์
๋ ฅํด์ฃผ์ธ์.");
+ if (password.length < 8) throw new Error("๋น๋ฐ๋ฒํธ๋ 8์ ์ด์ ์
๋ ฅํด์ฃผ์ธ์.");
+ if (!input.agreements.terms || !input.agreements.privacy)
+ throw new Error("์ด์ฉ์ฝ๊ด๊ณผ ๊ฐ์ธ์ ๋ณด ์์ง ๋ฐ ์ด์ฉ ๋์๊ฐ ํ์ํฉ๋๋ค.");
+ if (!input.verificationToken || input.verificationToken.trim().length < 16)
+ throw new Error("ํด๋ํฐ ์ธ์ฆ์ ๋จผ์ ์๋ฃํด์ฃผ์ธ์.");
+ if (deviceId && deviceId.length < 8) throw new Error("๋๋ฐ์ด์ค ์๋ณ์๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
+ return { username, email, familyName, givenName, phoneNumber, password, deviceId };
+}
+
+async function appendAuditLog(payload: {
+ username?: string;
+ email?: string;
+ phoneNumber?: string;
+ deviceId?: string;
+ signupIp?: string;
+ outcome: string;
+ reason?: string;
+}) {
+ await getPgPool().query(
+ `insert into signup_audit_logs (id, username, email, phone_number, device_id, signup_ip, outcome, reason, created_at)
+ values ($1,$2,$3,$4,$5,$6,$7,$8,now())`,
+ [
+ randomUUID(),
+ payload.username || null,
+ payload.email || null,
+ payload.phoneNumber || null,
+ payload.deviceId || null,
+ payload.signupIp || null,
+ payload.outcome,
+ payload.reason || null,
+ ],
+ );
+}
+
+export async function requestPhoneVerificationPg(
+ payload: PhoneVerificationRequest,
+ meta?: RequestMeta,
+) {
+ const phoneNumber = normalizePhone(payload.phoneNumber);
+ if (phoneNumber.length < 10 || phoneNumber.length > 11)
+ throw new Error("ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ํํ ์
๋ ฅํด์ฃผ์ธ์.");
+ const deviceId = payload.deviceId?.trim();
+ const pool = getPgPool();
+
+ const phoneTrialCount = await pool.query(
+ `select count(*)::int as count from free_trial_grants where phone_number = $1`,
+ [phoneNumber],
+ );
+ if (phoneTrialCount.rows[0]?.count >= config.freeTrialPerPhoneLimit)
+ throw new Error("์ด ํด๋ํฐ ๋ฒํธ๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์ฌ์ฉ๋์์ต๋๋ค.");
+ if (deviceId) {
+ const deviceTrialCount = await pool.query(
+ `select count(*)::int as count from free_trial_grants where device_id = $1`,
+ [deviceId],
+ );
+ if (deviceTrialCount.rows[0]?.count >= config.freeTrialPerDeviceLimit)
+ throw new Error("์ด ๋๋ฐ์ด์ค์์๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์์ฑ๋์์ต๋๋ค.");
+ }
+
+ const latest = await pool.query(
+ `select id, requested_at as "requestedAt" from phone_verifications where phone_number = $1 and purpose = $2 order by requested_at desc limit 1`,
+ [phoneNumber, payload.purpose],
+ );
+ const latestRequestedAt = latest.rows[0]?.requestedAt
+ ? new Date(latest.rows[0].requestedAt).getTime()
+ : 0;
+ if (
+ latestRequestedAt &&
+ Date.now() - latestRequestedAt < config.phoneVerificationRequestCooldownMs
+ )
+ throw new Error("์ ์ ํ ๋ค์ ์์ฒญํด์ฃผ์ธ์.");
+
+ const recentRequestCount = await pool.query(
+ `select count(*)::int as count from phone_verifications where phone_number = $1 and created_at >= now() - interval '1 hour'`,
+ [phoneNumber],
+ );
+ if (recentRequestCount.rows[0]?.count >= config.phoneVerificationMaxRequestsPerHour)
+ throw new Error("๋ฌธ์ ์์ฒญ์ด ๋๋ฌด ๋ง์ต๋๋ค. ํ ์๊ฐ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.");
+
+ const code = String(Math.floor(Math.random() * 900000) + 100000);
+ const expiresAt = new Date(Date.now() + config.phoneVerificationCodeTtlMs).toISOString();
+ const smsResult = await sendVerificationSms(phoneNumber, code);
+ if (!smsResult.success && !smsResult.previewCode)
+ throw new Error(smsResult.error || "๋ฌธ์ ์ธ์ฆ ์ฝ๋๋ฅผ ๋ฐ์กํ์ง ๋ชปํ์ต๋๋ค.");
+
+ await pool.query(`delete from phone_verifications where phone_number = $1 and purpose = $2`, [
+ phoneNumber,
+ payload.purpose,
+ ]);
+ await pool.query(
+ `insert into phone_verifications (id, phone_number, purpose, verification_code_hash, verification_token, attempt_count, request_count, verified_at, expires_at, device_id, request_ip, requested_at, sms_provider, sms_message_id, created_at)
+ values ($1,$2,$3,$4,null,0,1,null,$5,$6,$7,now(),$8,$9,now())`,
+ [
+ randomUUID(),
+ phoneNumber,
+ payload.purpose,
+ hashVerificationCode(code),
+ expiresAt,
+ deviceId || null,
+ meta?.requestIp || null,
+ smsResult.provider,
+ smsResult.messageId || null,
+ ],
+ );
+
+ return {
+ requested: true,
+ provider: smsResult.provider,
+ purpose: payload.purpose,
+ maskedPhoneNumber: maskPhone(phoneNumber),
+ previewCode: smsResult.previewCode,
+ retryAfterSec: Math.ceil(config.phoneVerificationRequestCooldownMs / 1000),
+ expiresInSec: Math.ceil(config.phoneVerificationCodeTtlMs / 1000),
+ expiresAt,
+ message: smsResult.previewCode
+ ? "๊ฐ๋ฐ ๋ชจ๋์์๋ ํ๋ฉด์์ ์ธ์ฆ ์ฝ๋๋ฅผ ํ์ธํ ์ ์์ต๋๋ค."
+ : "๋ฌธ์ ์ธ์ฆ ์ฝ๋๋ฅผ ๋ฐ์กํ์ต๋๋ค.",
+ };
+}
+
+export async function confirmPhoneVerificationPg(payload: PhoneVerificationConfirmRequest) {
+ const phoneNumber = normalizePhone(payload.phoneNumber);
+ const code = payload.code.trim();
+ const deviceId = payload.deviceId?.trim();
+ const pool = getPgPool();
+ const result = await pool.query(
+ `select id, verification_code_hash as "codeHash", attempt_count as "attemptCount", verification_token as "verificationToken", expires_at as "expiresAt", device_id as "deviceId"
+ from phone_verifications where phone_number = $1 and purpose = $2 order by requested_at desc limit 1`,
+ [phoneNumber, payload.purpose],
+ );
+ const record = result.rows[0];
+ if (!record) throw new Error("๋จผ์ ๋ฌธ์ ์ธ์ฆ ์์ฒญ์ ํด์ฃผ์ธ์.");
+ if (deviceId && record.deviceId && record.deviceId !== deviceId)
+ throw new Error("๊ฐ์ ๋๋ฐ์ด์ค์์ ๋ค์ ์ธ์ฆ์ ์งํํด์ฃผ์ธ์.");
+ if (new Date(record.expiresAt).getTime() < Date.now())
+ throw new Error("์ธ์ฆ ์ฝ๋๊ฐ ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์์ฒญํด์ฃผ์ธ์.");
+ if (record.attemptCount >= config.phoneVerificationMaxAttempts)
+ throw new Error("์ธ์ฆ ์๋๊ฐ ๋๋ฌด ๋ง์ต๋๋ค. ๋ค์ ์์ฒญํด์ฃผ์ธ์.");
+ const expectedHash = Buffer.from(record.codeHash, "hex");
+ const candidateHash = Buffer.from(hashVerificationCode(code), "hex");
+ const isMatched =
+ expectedHash.length === candidateHash.length && timingSafeEqual(expectedHash, candidateHash);
+ if (!isMatched) {
+ await pool.query(
+ `update phone_verifications set attempt_count = attempt_count + 1 where id = $1`,
+ [record.id],
+ );
+ throw new Error("์ธ์ฆ ์ฝ๋๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
+ }
+ const verificationToken = createToken();
+ await pool.query(
+ `update phone_verifications set attempt_count = attempt_count + 1, verified_at = now(), verification_token = $2 where id = $1`,
+ [record.id, verificationToken],
+ );
+ return {
+ verified: true,
+ verificationToken,
+ expiresAt: record.expiresAt,
+ message: "ํด๋ํฐ ์ธ์ฆ์ด ์๋ฃ๋์์ต๋๋ค.",
+ };
+}
+
+export async function signupWithEmailPg(payload: EmailSignupRequest, meta?: RequestMeta) {
+ const parsed = validateSignupFields(payload);
+ const pool = getPgPool();
+ const verificationResult = await pool.query(
+ `select id, verified_at as "verifiedAt", expires_at as "expiresAt" from phone_verifications where phone_number = $1 and purpose = 'signup' and verification_token = $2 limit 1`,
+ [parsed.phoneNumber, payload.verificationToken],
+ );
+ const verification = verificationResult.rows[0];
+ if (!verification?.verifiedAt) {
+ await appendAuditLog({
+ username: parsed.username,
+ email: parsed.email,
+ phoneNumber: parsed.phoneNumber,
+ deviceId: parsed.deviceId,
+ signupIp: meta?.requestIp,
+ outcome: "rejected",
+ reason: "phone_not_verified",
+ });
+ throw new Error("ํด๋ํฐ ์ธ์ฆ์ด ์๋ฃ๋์ง ์์์ต๋๋ค.");
+ }
+ if (new Date(verification.expiresAt).getTime() < Date.now()) {
+ await appendAuditLog({
+ username: parsed.username,
+ email: parsed.email,
+ phoneNumber: parsed.phoneNumber,
+ deviceId: parsed.deviceId,
+ signupIp: meta?.requestIp,
+ outcome: "rejected",
+ reason: "phone_verification_expired",
+ });
+ throw new Error("ํด๋ํฐ ์ธ์ฆ์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์งํํด์ฃผ์ธ์.");
+ }
+ const dupUser = await pool.query(
+ `select username, email, phone_number as "phoneNumber" from users where username = $1 or email = $2 or phone_number = $3 limit 1`,
+ [parsed.username, parsed.email, parsed.phoneNumber],
+ );
+ if (dupUser.rows[0]?.username === parsed.username)
+ throw new Error("์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์์ด๋์
๋๋ค.");
+ if (dupUser.rows[0]?.email === parsed.email) throw new Error("์ด๋ฏธ ๊ฐ์
๋ ์ด๋ฉ์ผ์
๋๋ค.");
+ if (dupUser.rows[0]?.phoneNumber === parsed.phoneNumber)
+ throw new Error("์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ํด๋ํฐ ๋ฒํธ์
๋๋ค.");
+ const phoneTrialCount = await pool.query(
+ `select count(*)::int as count from free_trial_grants where phone_number = $1`,
+ [parsed.phoneNumber],
+ );
+ if (phoneTrialCount.rows[0]?.count >= config.freeTrialPerPhoneLimit)
+ throw new Error("์ด ํด๋ํฐ ๋ฒํธ๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์ฌ์ฉ๋์์ต๋๋ค.");
+ if (parsed.deviceId) {
+ const deviceTrialCount = await pool.query(
+ `select count(*)::int as count from free_trial_grants where device_id = $1`,
+ [parsed.deviceId],
+ );
+ if (deviceTrialCount.rows[0]?.count >= config.freeTrialPerDeviceLimit)
+ throw new Error("์ด ๋๋ฐ์ด์ค์์๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์์ฑ๋์์ต๋๋ค.");
+ }
+ const salt = randomBytes(16).toString("hex");
+ const userId = randomUUID();
+ const now = new Date().toISOString();
+ const client = await pool.connect();
+ try {
+ await client.query("begin");
+ await client.query(
+ `insert into users (id, username, email, family_name, given_name, display_name, phone_number, phone_verified_at, password_salt, password_hash, signup_device_id, signup_ip, plan, subscription_status, created_at, updated_at, last_login_at)
+ values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'free','trial',$13,$13,$13)`,
+ [
+ userId,
+ parsed.username,
+ parsed.email,
+ parsed.familyName,
+ parsed.givenName,
+ `${parsed.familyName}${parsed.givenName}`,
+ parsed.phoneNumber,
+ verification.verifiedAt,
+ salt,
+ hashPassword(parsed.password, salt),
+ parsed.deviceId || null,
+ meta?.requestIp || null,
+ now,
+ ],
+ );
+ await client.query(
+ `insert into user_agreements (id, user_id, terms_version, privacy_version, marketing_opt_in, accepted_terms_at, accepted_privacy_at, accepted_marketing_at, created_at)
+ values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
+ [
+ randomUUID(),
+ userId,
+ config.termsVersion,
+ config.privacyVersion,
+ payload.agreements.marketing,
+ now,
+ now,
+ payload.agreements.marketing ? now : null,
+ now,
+ ],
+ );
+ await client.query(
+ `insert into free_trial_grants (id, user_id, phone_number, device_id, granted_at, expires_at, source)
+ values ($1,$2,$3,$4,$5,$6,'signup')`,
+ [
+ randomUUID(),
+ userId,
+ parsed.phoneNumber,
+ parsed.deviceId || null,
+ now,
+ new Date(Date.now() + config.freeTrialDays * 24 * 60 * 60 * 1000).toISOString(),
+ ],
+ );
+ await client.query(`delete from phone_verifications where id = $1`, [verification.id]);
+ await client.query(
+ `insert into signup_audit_logs (id, username, email, phone_number, device_id, signup_ip, outcome, reason, created_at)
+ values ($1,$2,$3,$4,$5,$6,'created','signup_success',$7)`,
+ [
+ randomUUID(),
+ parsed.username,
+ parsed.email,
+ parsed.phoneNumber,
+ parsed.deviceId || null,
+ meta?.requestIp || null,
+ now,
+ ],
+ );
+ await client.query("commit");
+ } catch (error) {
+ await client.query("rollback");
+ throw error;
+ } finally {
+ client.release();
+ }
+ return {
+ created: true,
+ message: "email signup ready",
+ session: createUserSession({
+ id: userId,
+ email: parsed.email,
+ displayName: `${parsed.familyName}${parsed.givenName}`,
+ plan: "free",
+ subscriptionStatus: "trial",
+ }),
+ };
+}
+
+export async function loginWithEmailPg(payload: EmailLoginRequest) {
+ const identifier = payload.identifier.trim().toLowerCase();
+ const password = payload.password.trim();
+ if (!identifier || !password) throw new Error("์์ด๋์ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.");
+ const result = await getPgPool().query(
+ `select id, email, display_name as "displayName", password_hash as "passwordHash", password_salt as "passwordSalt", plan, subscription_status as "subscriptionStatus" from users where username = $1 or email = $1 limit 1`,
+ [identifier],
+ );
+ const user = result.rows[0];
+ if (!user) throw new Error("๊ฐ์
๋ ๊ณ์ ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค.");
+ const expectedHash = Buffer.from(user.passwordHash, "hex");
+ const candidateHash = Buffer.from(hashPassword(password, user.passwordSalt), "hex");
+ const isMatched =
+ expectedHash.length === candidateHash.length && timingSafeEqual(expectedHash, candidateHash);
+ if (!isMatched) throw new Error("๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
+ await getPgPool().query(
+ `update users set last_login_at = now(), updated_at = now() where id = $1`,
+ [user.id],
+ );
+ return { loggedIn: true, message: "email login ready", session: createUserSession(user) };
+}
diff --git a/backend/src/services/auth-service.ts b/backend/src/services/auth-service.ts
new file mode 100644
index 00000000..e9e4bcac
--- /dev/null
+++ b/backend/src/services/auth-service.ts
@@ -0,0 +1,655 @@
+import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { config } from "../config.js";
+import { isPostgresEnabled } from "../db/pg.js";
+import {
+ confirmPhoneVerificationPg,
+ loginWithEmailPg,
+ requestPhoneVerificationPg,
+ signupWithEmailPg,
+} from "./auth-postgres-service.js";
+import { sendVerificationSms } from "./sms-service.js";
+
+export interface DesktopExchangeRequest {
+ code: string;
+ deviceName?: string;
+ platform?: string;
+}
+
+export interface DesktopSessionResponse {
+ accessToken: string;
+ refreshToken: string;
+ expiresIn: number;
+ user: {
+ id: string;
+ email: string;
+ displayName: string;
+ };
+ subscription: {
+ plan: "free" | "pro";
+ status: "inactive" | "trial" | "active";
+ };
+ entitlements: string[];
+}
+
+export interface PhoneVerificationRequest {
+ phoneNumber: string;
+ purpose: "signup" | "login";
+ deviceId?: string;
+}
+
+export interface PhoneVerificationConfirmRequest {
+ phoneNumber: string;
+ code: string;
+ purpose: "signup" | "login";
+ deviceId?: string;
+}
+
+export interface EmailSignupRequest {
+ username: string;
+ email: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId?: string;
+ agreements: {
+ terms: boolean;
+ privacy: boolean;
+ marketing: boolean;
+ };
+}
+
+export interface EmailLoginRequest {
+ identifier: string;
+ password: string;
+ deviceId?: string;
+}
+
+interface RequestMeta {
+ requestIp?: string;
+}
+
+interface ServerUserRecord {
+ id: string;
+ username: string;
+ email: string;
+ familyName: string;
+ givenName: string;
+ displayName: string;
+ phoneNumber: string;
+ phoneVerifiedAt: string;
+ passwordSalt: string;
+ passwordHash: string;
+ signupDeviceId?: string;
+ signupIp?: string;
+ plan: "free" | "pro";
+ subscriptionStatus: "inactive" | "trial" | "active";
+ createdAt: string;
+ updatedAt: string;
+ lastLoginAt: string;
+}
+
+interface PhoneVerificationRecord {
+ id: string;
+ phoneNumber: string;
+ purpose: "signup" | "login";
+ codeHash: string;
+ verificationToken?: string;
+ attemptCount: number;
+ requestCount: number;
+ deviceId?: string;
+ requestIp?: string;
+ createdAt: string;
+ lastRequestedAt: string;
+ expiresAt: string;
+ verifiedAt?: string;
+ smsProvider?: string;
+ smsMessageId?: string;
+}
+
+interface SignupAuditLogRecord {
+ id: string;
+ username?: string;
+ email?: string;
+ phoneNumber?: string;
+ deviceId?: string;
+ signupIp?: string;
+ outcome: string;
+ reason?: string;
+ createdAt: string;
+}
+
+interface UserAgreementRecord {
+ id: string;
+ userId: string;
+ termsVersion: string;
+ privacyVersion: string;
+ marketingOptIn: boolean;
+ acceptedTermsAt: string;
+ acceptedPrivacyAt: string;
+ acceptedMarketingAt?: string;
+ createdAt: string;
+}
+
+interface FreeTrialGrantRecord {
+ id: string;
+ userId: string;
+ phoneNumber: string;
+ deviceId?: string;
+ grantedAt: string;
+ expiresAt: string;
+ source: string;
+}
+
+interface AuthStore {
+ users: ServerUserRecord[];
+ phoneVerifications: PhoneVerificationRecord[];
+ signupAuditLogs: SignupAuditLogRecord[];
+ userAgreements: UserAgreementRecord[];
+ freeTrialGrants: FreeTrialGrantRecord[];
+}
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const STORE_PATH = path.join(__dirname, "../../data/auth-store.json");
+const SESSION_TTL_SECONDS = 60 * 60 * 24 * 14;
+
+function normalizeEmail(email: string) {
+ return email.trim().toLowerCase();
+}
+
+function normalizeUsername(username: string) {
+ return username.trim().toLowerCase();
+}
+
+function normalizePhone(phoneNumber: string) {
+ return phoneNumber.replace(/\D/g, "");
+}
+
+function hashPassword(password: string, salt: string) {
+ return scryptSync(password, salt, 64).toString("hex");
+}
+
+function hashVerificationCode(code: string) {
+ return createHash("sha256").update(code).digest("hex");
+}
+
+function createToken(length = 24) {
+ return randomBytes(length).toString("hex");
+}
+
+function createStubSession(seed: string): DesktopSessionResponse {
+ return {
+ accessToken: `stub-access-${seed}`,
+ refreshToken: `stub-refresh-${seed}`,
+ expiresIn: SESSION_TTL_SECONDS,
+ user: {
+ id: `user_${seed}`,
+ email: "hello@autoscreen.app",
+ displayName: "Auto Screen User",
+ },
+ subscription: {
+ plan: seed.includes("pro") ? "pro" : "free",
+ status: seed.includes("pro") ? "active" : "inactive",
+ },
+ entitlements: seed.includes("pro")
+ ? ["basic_recording", "basic_editing", "pro_export", "mcp_editing"]
+ : ["basic_recording", "basic_editing"],
+ };
+}
+
+function createUserSession(user: ServerUserRecord): DesktopSessionResponse {
+ const isPro = user.plan === "pro";
+ return {
+ accessToken: `desktop-access-${createToken(18)}`,
+ refreshToken: `desktop-refresh-${createToken(18)}`,
+ expiresIn: SESSION_TTL_SECONDS,
+ user: {
+ id: user.id,
+ email: user.email,
+ displayName: user.displayName,
+ },
+ subscription: {
+ plan: user.plan,
+ status: user.subscriptionStatus,
+ },
+ entitlements: isPro
+ ? ["basic_recording", "basic_editing", "pro_export", "mcp_editing"]
+ : ["basic_recording", "basic_editing"],
+ };
+}
+
+async function readStore(): Promise {
+ try {
+ const raw = await fs.readFile(STORE_PATH, "utf8");
+ const parsed = JSON.parse(raw) as Partial;
+ return {
+ users: Array.isArray(parsed.users) ? parsed.users : [],
+ phoneVerifications: Array.isArray(parsed.phoneVerifications) ? parsed.phoneVerifications : [],
+ signupAuditLogs: Array.isArray(parsed.signupAuditLogs) ? parsed.signupAuditLogs : [],
+ userAgreements: Array.isArray(parsed.userAgreements) ? parsed.userAgreements : [],
+ freeTrialGrants: Array.isArray(parsed.freeTrialGrants) ? parsed.freeTrialGrants : [],
+ };
+ } catch {
+ return {
+ users: [],
+ phoneVerifications: [],
+ signupAuditLogs: [],
+ userAgreements: [],
+ freeTrialGrants: [],
+ };
+ }
+}
+
+async function writeStore(store: AuthStore) {
+ await fs.mkdir(path.dirname(STORE_PATH), { recursive: true });
+ await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2), "utf8");
+}
+
+function validateSignupFields(input: EmailSignupRequest) {
+ const username = normalizeUsername(input.username);
+ const email = normalizeEmail(input.email);
+ const familyName = input.familyName.trim();
+ const givenName = input.givenName.trim();
+ const phoneNumber = normalizePhone(input.phoneNumber);
+ const password = input.password.trim();
+ const deviceId = input.deviceId?.trim();
+
+ if (!/^[a-z0-9_]{4,20}$/.test(username)) {
+ return { error: "์์ด๋๋ ์๋ฌธ ์๋ฌธ์ ์ซ์ ๋ฐ์ค๋ก 4์ ์ด์ 20์ ์ดํ๋ง ๊ฐ๋ฅํฉ๋๋ค." };
+ }
+
+ if (!email || !email.includes("@")) {
+ return { error: "์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (!familyName || !givenName) {
+ return { error: "์ฑ๊ณผ ์ด๋ฆ์ ๋ชจ๋ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (phoneNumber.length < 10 || phoneNumber.length > 11) {
+ return { error: "ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ํํ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (password.length < 8) {
+ return { error: "๋น๋ฐ๋ฒํธ๋ 8์ ์ด์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (!input.agreements.terms || !input.agreements.privacy) {
+ return { error: "์ด์ฉ์ฝ๊ด๊ณผ ๊ฐ์ธ์ ๋ณด ์์ง ๋ฐ ์ด์ฉ ๋์๊ฐ ํ์ํฉ๋๋ค." };
+ }
+
+ if (!input.verificationToken || input.verificationToken.trim().length < 16) {
+ return { error: "ํด๋ํฐ ์ธ์ฆ์ ๋จผ์ ์๋ฃํด์ฃผ์ธ์." };
+ }
+
+ if (deviceId && deviceId.length < 8) {
+ return { error: "๋๋ฐ์ด์ค ์๋ณ์๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค." };
+ }
+
+ return { username, email, familyName, givenName, phoneNumber, password, deviceId };
+}
+
+function maskPhone(phoneNumber: string) {
+ return phoneNumber.replace(/(\d{3})\d+(\d{2})$/, "$1****$2");
+}
+
+function getRecentRequestCount(verifications: PhoneVerificationRecord[], phoneNumber: string) {
+ const oneHourAgo = Date.now() - 1000 * 60 * 60;
+ return verifications.filter(
+ (item) => item.phoneNumber === phoneNumber && new Date(item.createdAt).getTime() >= oneHourAgo,
+ ).length;
+}
+
+function cleanupExpiredVerifications(verifications: PhoneVerificationRecord[]) {
+ const now = Date.now();
+ return verifications.filter((item) => {
+ if (item.verifiedAt) return true;
+ return new Date(item.expiresAt).getTime() + config.phoneVerificationCodeTtlMs > now;
+ });
+}
+
+function appendAuditLog(store: AuthStore, payload: Omit) {
+ store.signupAuditLogs.push({
+ id: randomUUID(),
+ createdAt: new Date().toISOString(),
+ ...payload,
+ });
+}
+
+export async function exchangeDesktopCode(
+ payload: DesktopExchangeRequest,
+): Promise {
+ return createStubSession(payload.code || "exchange");
+}
+
+export async function refreshDesktopSession(refreshToken: string) {
+ return {
+ refreshed: Boolean(refreshToken),
+ message: "refresh skeleton ready",
+ session: createStubSession(refreshToken || "refresh"),
+ };
+}
+
+export async function logoutDesktopSession(refreshToken: string) {
+ return {
+ loggedOut: Boolean(refreshToken),
+ message: "logout skeleton ready",
+ };
+}
+
+export async function requestPhoneVerification(
+ payload: PhoneVerificationRequest,
+ meta?: RequestMeta,
+) {
+ if (isPostgresEnabled()) {
+ return await requestPhoneVerificationPg(payload, meta);
+ }
+ const phoneNumber = normalizePhone(payload.phoneNumber);
+ if (phoneNumber.length < 10 || phoneNumber.length > 11) {
+ throw new Error("ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ํํ ์
๋ ฅํด์ฃผ์ธ์.");
+ }
+
+ const deviceId = payload.deviceId?.trim();
+ const store = await readStore();
+ store.phoneVerifications = cleanupExpiredVerifications(store.phoneVerifications);
+
+ if (
+ store.freeTrialGrants.filter((item) => item.phoneNumber === phoneNumber).length >=
+ config.freeTrialPerPhoneLimit
+ ) {
+ throw new Error("์ด ํด๋ํฐ ๋ฒํธ๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์ฌ์ฉ๋์์ต๋๋ค.");
+ }
+
+ if (
+ deviceId &&
+ store.freeTrialGrants.filter((item) => item.deviceId === deviceId).length >=
+ config.freeTrialPerDeviceLimit
+ ) {
+ throw new Error("์ด ๋๋ฐ์ด์ค์์๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์์ฑ๋์์ต๋๋ค.");
+ }
+
+ const latest = [...store.phoneVerifications]
+ .filter((item) => item.phoneNumber === phoneNumber && item.purpose === payload.purpose)
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
+
+ if (
+ latest &&
+ Date.now() - new Date(latest.lastRequestedAt).getTime() <
+ config.phoneVerificationRequestCooldownMs
+ ) {
+ throw new Error("์ ์ ํ ๋ค์ ์์ฒญํด์ฃผ์ธ์.");
+ }
+
+ if (
+ getRecentRequestCount(store.phoneVerifications, phoneNumber) >=
+ config.phoneVerificationMaxRequestsPerHour
+ ) {
+ throw new Error("๋ฌธ์ ์์ฒญ์ด ๋๋ฌด ๋ง์ต๋๋ค. ํ ์๊ฐ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.");
+ }
+
+ const code = String(Math.floor(Math.random() * 900000) + 100000);
+ const now = new Date().toISOString();
+ const expiresAt = new Date(Date.now() + config.phoneVerificationCodeTtlMs).toISOString();
+ const smsResult = await sendVerificationSms(phoneNumber, code);
+ if (!smsResult.success && !smsResult.previewCode) {
+ throw new Error(smsResult.error || "๋ฌธ์ ์ธ์ฆ ์ฝ๋๋ฅผ ๋ฐ์กํ์ง ๋ชปํ์ต๋๋ค.");
+ }
+
+ store.phoneVerifications = store.phoneVerifications.filter(
+ (item) => !(item.phoneNumber === phoneNumber && item.purpose === payload.purpose),
+ );
+ store.phoneVerifications.push({
+ id: randomUUID(),
+ phoneNumber,
+ purpose: payload.purpose,
+ codeHash: hashVerificationCode(code),
+ attemptCount: 0,
+ requestCount: latest ? latest.requestCount + 1 : 1,
+ deviceId,
+ requestIp: meta?.requestIp,
+ createdAt: now,
+ lastRequestedAt: now,
+ expiresAt,
+ smsProvider: smsResult.provider,
+ smsMessageId: smsResult.messageId,
+ });
+ await writeStore(store);
+
+ return {
+ requested: true,
+ provider: smsResult.provider,
+ purpose: payload.purpose,
+ maskedPhoneNumber: maskPhone(phoneNumber),
+ previewCode: smsResult.previewCode,
+ retryAfterSec: Math.ceil(config.phoneVerificationRequestCooldownMs / 1000),
+ expiresInSec: Math.ceil(config.phoneVerificationCodeTtlMs / 1000),
+ expiresAt,
+ message: smsResult.previewCode
+ ? "๊ฐ๋ฐ ๋ชจ๋์์๋ ํ๋ฉด์์ ์ธ์ฆ ์ฝ๋๋ฅผ ํ์ธํ ์ ์์ต๋๋ค."
+ : "๋ฌธ์ ์ธ์ฆ ์ฝ๋๋ฅผ ๋ฐ์กํ์ต๋๋ค.",
+ };
+}
+
+export async function confirmPhoneVerification(payload: PhoneVerificationConfirmRequest) {
+ if (isPostgresEnabled()) {
+ return await confirmPhoneVerificationPg(payload);
+ }
+ const phoneNumber = normalizePhone(payload.phoneNumber);
+ const code = payload.code.trim();
+ const deviceId = payload.deviceId?.trim();
+ const store = await readStore();
+ store.phoneVerifications = cleanupExpiredVerifications(store.phoneVerifications);
+
+ const record = [...store.phoneVerifications]
+ .filter((item) => item.phoneNumber === phoneNumber && item.purpose === payload.purpose)
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
+
+ if (!record) {
+ throw new Error("๋จผ์ ๋ฌธ์ ์ธ์ฆ ์์ฒญ์ ํด์ฃผ์ธ์.");
+ }
+
+ if (deviceId && record.deviceId && record.deviceId !== deviceId) {
+ throw new Error("๊ฐ์ ๋๋ฐ์ด์ค์์ ๋ค์ ์ธ์ฆ์ ์งํํด์ฃผ์ธ์.");
+ }
+
+ if (new Date(record.expiresAt).getTime() < Date.now()) {
+ throw new Error("์ธ์ฆ ์ฝ๋๊ฐ ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์์ฒญํด์ฃผ์ธ์.");
+ }
+
+ if (record.attemptCount >= config.phoneVerificationMaxAttempts) {
+ throw new Error("์ธ์ฆ ์๋๊ฐ ๋๋ฌด ๋ง์ต๋๋ค. ๋ค์ ์์ฒญํด์ฃผ์ธ์.");
+ }
+
+ const expectedHash = Buffer.from(record.codeHash, "hex");
+ const receivedHash = Buffer.from(hashVerificationCode(code), "hex");
+ const isValid =
+ expectedHash.length === receivedHash.length && timingSafeEqual(expectedHash, receivedHash);
+
+ if (!isValid) {
+ record.attemptCount += 1;
+ await writeStore(store);
+ throw new Error("์ธ์ฆ ์ฝ๋๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
+ }
+
+ record.attemptCount += 1;
+ record.verifiedAt = new Date().toISOString();
+ record.verificationToken = createToken();
+ await writeStore(store);
+
+ return {
+ verified: true,
+ verificationToken: record.verificationToken,
+ expiresAt: record.expiresAt,
+ message: "ํด๋ํฐ ์ธ์ฆ์ด ์๋ฃ๋์์ต๋๋ค.",
+ };
+}
+
+export async function signupWithEmail(payload: EmailSignupRequest, meta?: RequestMeta) {
+ if (isPostgresEnabled()) {
+ return await signupWithEmailPg(payload, meta);
+ }
+ const parsed = validateSignupFields(payload);
+ if ("error" in parsed) {
+ throw new Error(parsed.error);
+ }
+
+ const store = await readStore();
+ const verification = store.phoneVerifications.find(
+ (item) =>
+ item.phoneNumber === parsed.phoneNumber &&
+ item.purpose === "signup" &&
+ item.verificationToken === payload.verificationToken,
+ );
+
+ if (!verification?.verifiedAt) {
+ appendAuditLog(store, {
+ username: parsed.username,
+ email: parsed.email,
+ phoneNumber: parsed.phoneNumber,
+ deviceId: parsed.deviceId,
+ signupIp: meta?.requestIp,
+ outcome: "rejected",
+ reason: "phone_not_verified",
+ });
+ await writeStore(store);
+ throw new Error("ํด๋ํฐ ์ธ์ฆ์ด ์๋ฃ๋์ง ์์์ต๋๋ค.");
+ }
+
+ if (new Date(verification.expiresAt).getTime() < Date.now()) {
+ appendAuditLog(store, {
+ username: parsed.username,
+ email: parsed.email,
+ phoneNumber: parsed.phoneNumber,
+ deviceId: parsed.deviceId,
+ signupIp: meta?.requestIp,
+ outcome: "rejected",
+ reason: "phone_verification_expired",
+ });
+ await writeStore(store);
+ throw new Error("ํด๋ํฐ ์ธ์ฆ์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์งํํด์ฃผ์ธ์.");
+ }
+
+ if (store.users.some((item) => item.username === parsed.username)) {
+ throw new Error("์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์์ด๋์
๋๋ค.");
+ }
+ if (store.users.some((item) => item.email === parsed.email)) {
+ throw new Error("์ด๋ฏธ ๊ฐ์
๋ ์ด๋ฉ์ผ์
๋๋ค.");
+ }
+ if (store.users.some((item) => item.phoneNumber === parsed.phoneNumber)) {
+ throw new Error("์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ํด๋ํฐ ๋ฒํธ์
๋๋ค.");
+ }
+ if (
+ store.freeTrialGrants.filter((item) => item.phoneNumber === parsed.phoneNumber).length >=
+ config.freeTrialPerPhoneLimit
+ ) {
+ throw new Error("์ด ํด๋ํฐ ๋ฒํธ๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์ฌ์ฉ๋์์ต๋๋ค.");
+ }
+ if (
+ parsed.deviceId &&
+ store.freeTrialGrants.filter((item) => item.deviceId === parsed.deviceId).length >=
+ config.freeTrialPerDeviceLimit
+ ) {
+ throw new Error("์ด ๋๋ฐ์ด์ค์์๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์์ฑ๋์์ต๋๋ค.");
+ }
+
+ const now = new Date().toISOString();
+ const salt = randomBytes(16).toString("hex");
+ const userId = randomUUID();
+ const user: ServerUserRecord = {
+ id: userId,
+ username: parsed.username,
+ email: parsed.email,
+ familyName: parsed.familyName,
+ givenName: parsed.givenName,
+ displayName: `${parsed.familyName}${parsed.givenName}`,
+ phoneNumber: parsed.phoneNumber,
+ phoneVerifiedAt: verification.verifiedAt,
+ passwordSalt: salt,
+ passwordHash: hashPassword(parsed.password, salt),
+ signupDeviceId: parsed.deviceId,
+ signupIp: meta?.requestIp,
+ plan: "free",
+ subscriptionStatus: "trial",
+ createdAt: now,
+ updatedAt: now,
+ lastLoginAt: now,
+ };
+ store.users.push(user);
+ store.userAgreements.push({
+ id: randomUUID(),
+ userId,
+ termsVersion: config.termsVersion,
+ privacyVersion: config.privacyVersion,
+ marketingOptIn: payload.agreements.marketing,
+ acceptedTermsAt: now,
+ acceptedPrivacyAt: now,
+ acceptedMarketingAt: payload.agreements.marketing ? now : undefined,
+ createdAt: now,
+ });
+ store.freeTrialGrants.push({
+ id: randomUUID(),
+ userId,
+ phoneNumber: parsed.phoneNumber,
+ deviceId: parsed.deviceId,
+ grantedAt: now,
+ expiresAt: new Date(Date.now() + config.freeTrialDays * 24 * 60 * 60 * 1000).toISOString(),
+ source: "signup",
+ });
+ store.phoneVerifications = store.phoneVerifications.filter((item) => item.id !== verification.id);
+ appendAuditLog(store, {
+ username: parsed.username,
+ email: parsed.email,
+ phoneNumber: parsed.phoneNumber,
+ deviceId: parsed.deviceId,
+ signupIp: meta?.requestIp,
+ outcome: "created",
+ reason: "signup_success",
+ });
+ await writeStore(store);
+
+ return {
+ created: true,
+ message: "email signup ready",
+ session: createUserSession(user),
+ };
+}
+
+export async function loginWithEmail(payload: EmailLoginRequest) {
+ if (isPostgresEnabled()) {
+ return await loginWithEmailPg(payload);
+ }
+ const identifier = payload.identifier.trim().toLowerCase();
+ const password = payload.password.trim();
+ if (!identifier || !password) {
+ throw new Error("์์ด๋์ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.");
+ }
+
+ const store = await readStore();
+ const user = store.users.find(
+ (item) => item.username === identifier || item.email === identifier,
+ );
+ if (!user) {
+ throw new Error("๊ฐ์
๋ ๊ณ์ ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค.");
+ }
+
+ const expectedHash = Buffer.from(user.passwordHash, "hex");
+ const candidateHash = Buffer.from(hashPassword(password, user.passwordSalt), "hex");
+ const isMatched =
+ expectedHash.length === candidateHash.length && timingSafeEqual(expectedHash, candidateHash);
+ if (!isMatched) {
+ throw new Error("๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
+ }
+
+ user.lastLoginAt = new Date().toISOString();
+ user.updatedAt = user.lastLoginAt;
+ await writeStore(store);
+ return {
+ loggedIn: true,
+ message: "email login ready",
+ session: createUserSession(user),
+ };
+}
diff --git a/backend/src/services/sms-service.ts b/backend/src/services/sms-service.ts
new file mode 100644
index 00000000..2777cb89
--- /dev/null
+++ b/backend/src/services/sms-service.ts
@@ -0,0 +1,92 @@
+import { createHmac, randomUUID } from "node:crypto";
+import { config } from "../config.js";
+
+export interface SmsSendResult {
+ success: boolean;
+ provider: string;
+ messageId?: string;
+ previewCode?: string;
+ error?: string;
+}
+
+function buildSolapiAuthorization() {
+ const date = new Date().toISOString();
+ const salt = randomUUID().replace(/-/g, "");
+ const signature = createHmac("sha256", config.smsApiSecret)
+ .update(date + salt)
+ .digest("hex");
+ return {
+ date,
+ salt,
+ header: `HMAC-SHA256 apiKey=${config.smsApiKey}, date=${date}, salt=${salt}, signature=${signature}`,
+ };
+}
+
+export async function sendVerificationSms(
+ phoneNumber: string,
+ code: string,
+): Promise {
+ if (config.smsProvider !== "solapi") {
+ return {
+ success: false,
+ provider: config.smsProvider,
+ error: "์ง์ํ์ง ์๋ SMS ๊ณต๊ธ์์
๋๋ค.",
+ };
+ }
+
+ if (config.smsDryRun || !config.smsSender || !config.smsApiKey || !config.smsApiSecret) {
+ return {
+ success: true,
+ provider: "solapi",
+ messageId: `dry-run-${phoneNumber}-${Date.now()}`,
+ previewCode: code,
+ };
+ }
+
+ try {
+ const auth = buildSolapiAuthorization();
+ const response = await fetch(`${config.smsApiBaseUrl}/messages/v4/send-many/detail`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: auth.header,
+ },
+ body: JSON.stringify({
+ message: {
+ from: config.smsSender,
+ text: `[Auto Screen] ์ธ์ฆ๋ฒํธ๋ ${code} ์
๋๋ค. 3๋ถ ์์ ์
๋ ฅํด์ฃผ์ธ์.`,
+ },
+ messages: [{ to: phoneNumber }],
+ }),
+ });
+
+ const payload = (await response.json().catch(() => null)) as {
+ messageId?: string;
+ statusMessage?: string;
+ errorMessage?: string;
+ } | null;
+
+ if (!response.ok) {
+ return {
+ success: false,
+ provider: "solapi",
+ error:
+ payload?.errorMessage ||
+ payload?.statusMessage ||
+ `Solapi ์์ฒญ ์คํจ (${response.status})`,
+ };
+ }
+
+ return {
+ success: true,
+ provider: "solapi",
+ messageId: payload?.messageId || `solapi-${Date.now()}`,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ provider: "solapi",
+ error: error instanceof Error ? error.message : "SMS ์ ์ก ์ค ์ ์ ์๋ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.",
+ };
+ }
+}
diff --git a/backend/src/services/subscription-service.ts b/backend/src/services/subscription-service.ts
new file mode 100644
index 00000000..810314e4
--- /dev/null
+++ b/backend/src/services/subscription-service.ts
@@ -0,0 +1,30 @@
+export type BillingPlanCode = "pro_monthly_15900_krw";
+
+export async function createCheckoutSession(planCode: BillingPlanCode) {
+ return {
+ planCode,
+ checkoutUrl: "https://autoscreen.app/pricing?checkout=stub",
+ message: "checkout skeleton ready",
+ };
+}
+
+export async function getCurrentSubscription() {
+ return {
+ plan: "free",
+ status: "inactive",
+ currentPeriodEnd: null,
+ };
+}
+
+export async function handleTossWebhook(payload: unknown) {
+ return {
+ accepted: true,
+ received: payload,
+ };
+}
+
+export async function getEntitlements() {
+ return {
+ entitlements: ["basic_recording", "basic_editing"],
+ };
+}
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
new file mode 100644
index 00000000..e4e653f4
--- /dev/null
+++ b/backend/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/biome.json b/biome.json
index c4c22f64..75950ab2 100644
--- a/biome.json
+++ b/biome.json
@@ -1,7 +1,10 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
- "files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
+ "files": {
+ "ignoreUnknown": false,
+ "includes": ["**", "!**/*.css", "!.omx/**", "!.omc/**", "!tmp/**"]
+ },
"formatter": {
"enabled": true,
"indentStyle": "tab",
@@ -92,7 +95,15 @@
"useGetterReturn": "error"
}
},
- "includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"]
+ "includes": [
+ "**",
+ "**/dist",
+ "**/.eslintrc.cjs",
+ "!**/*.css",
+ "!.omx/**",
+ "!.omc/**",
+ "!tmp/**"
+ ]
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"overrides": [
diff --git a/docs/clipwise-reference-notes.md b/docs/clipwise-reference-notes.md
new file mode 100644
index 00000000..bcf2125f
--- /dev/null
+++ b/docs/clipwise-reference-notes.md
@@ -0,0 +1,99 @@
+# Clipwise ์ฐธ๊ณ ๋ฉ๋ชจ for Auto Screen
+
+๋ถ์ ๋์
+- https://github.com/kwakseongjae/clipwise
+- ๋ก์ปฌ ๊ฒฝ๋ก: /Users/admin/Desktop/clipwise
+
+ํต์ฌ ์ธ์ฌ์ดํธ
+1. ๋จ์ ๋
นํ ํด์ด ์๋๋ผ โ์
๋ ฅ ๊ธฐ๋ฐ์ผ๋ก polished ๊ฒฐ๊ณผ๋ฅผ ๋ง๋ ๋คโ๋ ์ฒ ํ์ด ๊ฐํจ
+2. ์ฌ์ฉ์๊ฐ ์ธ๋ถ ์ฌ๋ผ์ด๋๋ฅผ ๋ง์ด ๋ง์ง์ง ์์๋ preset๊ณผ automation์ผ๋ก ๊ฒฐ๊ณผ ํ์ง์ ์ฌ๋ฆผ
+3. Zoom / Cursor / Output ํ์ง / ์๋๋ฆฌ์คํ๊ฐ ํนํ ๊ฐ์
+
+Auto Screen์ ๋ฐ๋ก ๋ฐ์ ๊ฐ์น๊ฐ ํฐ ํญ๋ชฉ
+
+Must์ ๊ฐ๊น์ด ํญ๋ชฉ
+1. Zoom ๊ฐ๋ ํ๋ฆฌ์
+- subtle / light / moderate / strong / dramatic ๊ฐ์ ์๋ฏธ ๊ธฐ๋ฐ ์ค ๊ฐ๋
+- ํ์ฌ ์ซ์ ์ค์ฌ๋ณด๋ค ์ด๋ณด์๊ฐ ์ดํดํ๊ธฐ ์ฌ์
+
+2. Spring ๊ธฐ๋ฐ ์ค easing
+- Clipwise๋ spring easing์ผ๋ก ์์ฐ์ค๋ฌ์ด ์นด๋ฉ๋ผ ๊ฐ๊ฐ์ ๊ฐ์กฐํจ
+- Auto Screen ์๋ ์ค์๋ ๋ฐ๋ก ์ฒด๊ฐ์ด ํฐ ๊ฐ์ ํฌ์ธํธ
+
+3. ์ธ์ ์ํธ์์ฉ์ ํ๋์ zoom zone์ผ๋ก ํฉ์น๊ธฐ
+- ๊ฐ๊น์ด ํด๋ฆญ/ํฌ์ปค์ค ํฌ์ธํธ๋ฅผ ํ๋์ ์ฐ์ ๊ตฌ๊ฐ์ผ๋ก ์ทจ๊ธ
+- ๋ถํ์ํ zoom-out โ zoom-in ๋ฐ๋ณต ๊ฐ์
+
+4. Focus point interpolation
+- ํฌ์ธํธ ๊ฐ ์ฆ์ ์ ํ๋ณด๋ค ๋ถ๋๋ฌ์ด pan
+- ํ์ฌ Auto Screen์ auto follow ํ์ง์ ์ฌ๋ฆฌ๋ ํต์ฌ
+
+5. Cursor ํจ๊ณผ ํจํค์ง
+- trail
+- ripple
+- highlight/halo
+- ํด๋ฆญ ์ง์ ๊ฐ์กฐ
+- ํ์ฌ Auto Screen์์ ์ฒด๊ฐ ๊ฐ์ ํญ์ด ํผ
+
+6. Output preset ์ฒด๊ณ
+- social / balanced / archive ๊ฐ์ ํ์ง ํ๋ฆฌ์
+- ํ์ฌ good/source/medium๋ณด๋ค ์๋ ๊ธฐ๋ฐ์ผ๋ก ์ดํดํ๊ธฐ ์ฌ์
+
+Should ํญ๋ชฉ
+7. Smart speed / wait compression ๊ฐ๋
+- ๊ธฐ๋ค๋ฆฌ๋ ๊ตฌ๊ฐ์ ์๋ ์์ถ
+- ๋ก๋ฉ/๋๊ธฐ ๊ตฌ๊ฐ์ ๋ ์ง๋ฃจํ๊ฒ ๋ณด์ด๊ฒ ํจ
+- Auto Screen์์๋ ์ด๊ธฐ์๋ โ์ ์ ๊ตฌ๊ฐ ์๋ ์์ถโ ํํ๋ก ๋จ์ํ ๊ฐ๋ฅ
+
+8. Keystroke HUD
+- ๊ฐ์/๋ฐ๋ชจ์ ์ ์ฉ
+- ํนํ ๋จ์ถํค ์ค์ฌ ์ค๋ช
์์์์ ๊ฐ์
+
+9. Scriptable / scenario export ๊ฒฝ๋ก
+- ์ฅ๊ธฐ์ ์ผ๋ก๋ AI agent์ ์ฐ๊ฒฐ์ฑ์ด ํผ
+- Auto Screen์์ ๋์ค์ โrecord recipeโ ๋๋ โdemo scriptโ ๊ธฐ๋ฅ์ผ๋ก ๋ฐ์ ๊ฐ๋ฅ
+
+10. Device/frame preset ์ฌ๊ณ ๋ฐฉ์
+- Browser / phone / tablet ๊ฐ์ presentation preset
+- Auto Screen์ export preset๊ณผ ์ฐ๊ฒฐ ๊ฐ๋ฅ
+
+Nice-to-have
+11. YAML ์๋๋ฆฌ์ค ๊ธฐ๋ฐ ๋
นํ ์๋ํ
+- ์ง๊ธ ๋น์ฅ์ ๋ฒ์๊ฐ ํผ
+- ํ์ง๋ง ์ฅ๊ธฐ์ ์ผ๋ก AI agent ์๋ ๋ฐ๋ชจ ์ ์๊ณผ ์์ฃผ ์ ๋ง์
+
+12. Watermark / transition library
+- ์ ํ ๋ง๊ฐ ํ์ง ํฅ์์ ๋์
+- ํ์ฌ 1์ฐจ ๊ตฌํ ์ฐ์ ์์๋ ์๋
+
+Auto Screen ์ฐ์ ์์ ์
๋ฐ์ดํธ
+
+1์ฐจ ๊ตฌํ ์ถ์ฒ
+- ํ๊ตญ์ด/์์ด ์ ํ + Settings ์ง์
์
+- ๊ธฐ๋ณธ ์คํ์ผ ๊ธฐ๋ณธ๊ฐ ๊ฐ์
+- Zoom intensity preset ์ถ๊ฐ
+- spring easing ๊ธฐ๋ฐ auto zoom ๊ฐ์
+- zoom zone merge + focus interpolation
+
+2์ฐจ ๊ตฌํ ์ถ์ฒ
+- cursor ripple / halo / smoothing package
+- output preset (social / balanced / archive)
+- export ๊ธฐ๋ณธ๊ฐ ๊ณ ๋ํ
+
+3์ฐจ ๊ตฌํ ์ถ์ฒ
+- smart speed / wait compression
+- keystroke HUD
+- preset ๋ฌถ์ ์ฒด๊ณ
+
+์ฅ๊ธฐ ๋ก๋๋งต
+- scriptable recording recipe
+- AI agent๊ฐ ์๋๋ฆฌ์ค๋ฅผ ์์ฑํ๊ณ Auto Screen์ด ๋ฐ๋ชจ ์์์ ์๋ ์ ์ํ๋ ํ๋ฆ
+
+๊ฒฐ๋ก
+- Clipwise๋ โ์๋ํ๋ ์ฐ์ถ ํ์งโ ์ธก๋ฉด์์ ๋งค์ฐ ์ข์ ๋ ํผ๋ฐ์ค๋ค.
+- Auto Screen์ GUI ์ค์ฌ ์ ํ์ด๋ฏ๋ก ๊ทธ๋๋ก ๋ณต์ ํ์ง ์๊ณ ,
+ 1) Zoom ํ์ง
+ 2) Cursor ์ฐ์ถ
+ 3) Output preset
+ 4) Smart compression
+ ์์ผ๋ก ํก์ํ๋ ๊ฒ์ด ๊ฐ์ฅ ์ข๋ค.
diff --git a/docs/local-mcp-workflow.md b/docs/local-mcp-workflow.md
new file mode 100644
index 00000000..f4b69f26
--- /dev/null
+++ b/docs/local-mcp-workflow.md
@@ -0,0 +1,113 @@
+# Auto Screen Local MCP Workflow
+
+## ๋ชฉํ
+Auto Screen์ **๋ก์ปฌ MCP ์๋ฒ**๋ก ์คํํ๊ณ , ํฐ๋ฏธ๋์์ ์คํํ **Codex CLI / Claude Code**๊ฐ ํธ์ง ํด์ ํธ์ถํด ์ฑ ์ํ๋ฅผ ๋ฐ๊พธ๋ ํ๋ฆ์ ์ ๋ฆฌํ ๋ฌธ์์
๋๋ค.
+
+## ํ์ฌ ๊ตฌํ ๋ฒ์
+- Electron main ํ๋ก์ธ์ค๊ฐ ๋ก์ปฌ MCP ์๋ฒ๋ฅผ ์์
+- Bearer token ๊ธฐ๋ฐ ๋ก์ปฌ ์ธ์ฆ
+- Renderer editor controller๊ฐ ๊ตฌ์กฐํ๋ ํธ์ง ๋ช
๋ น ์คํ
+- ํ์ฌ MCP tool
+ - `get_project_state`
+ - `remove_background`
+ - `set_background`
+ - `apply_auto_edit`
+ - `add_trim_region`
+ - `add_speed_region`
+ - `add_zoom_region`
+ - `undo`
+ - `redo`
+ - `export_video`
+
+## ์ฐ๊ฒฐ ์ ๋ณด ํ์ธ
+์ฑ ์คํ ํ main ๋ก๊ทธ์์ ์๋ ๊ฐ์ ํ์ธํฉ๋๋ค.
+
+- `Auto Screen MCP` โ MCP URL
+- `Auto Screen MCP Token` โ Bearer token
+
+์์:
+
+```text
+[Auto Screen MCP] http://127.0.0.1:43123/mcp
+[Auto Screen MCP Token]
+```
+
+## ๊ฒ์ฆ ์์
+์ฑ ์ฐฝ์ ๋ถํ์ํ๊ฒ ์์ฃผ ๋์ฐ์ง ์๊ธฐ ์ํด ์๋ ์์๋ฅผ ๊ถ์ฅํฉ๋๋ค.
+
+### 1) ์๋ฒ/์ธ์ฆ๋ง ๋จผ์ ํ์ธ
+editor ์ฐฝ์ด ์์ง ์์ด๋ ์๋ preflight๋ ํ์ธํ ์ ์์ต๋๋ค.
+
+```bash
+node scripts/test-mcp-client.mjs
+```
+
+์ด ์คํฌ๋ฆฝํธ๋ ๋จผ์ `GET /mcp/session`์ผ๋ก preflight๋ฅผ ์ํํฉ๋๋ค.
+
+### 2) ์ค์ ํธ์ง ํด ๊ฒ์ฆ
+์ค์ mutation tool์ editor window๊ฐ ์์ด์ผ ์์ ์ ์ผ๋ก ๋์ํฉ๋๋ค.
+
+๊ถ์ฅ ๋ฐฉ์:
+
+```bash
+AUTO_SCREEN_START_EDITOR=true npm run dev
+```
+
+๊ทธ ๋ค์ ๋ณ๋ ํฐ๋ฏธ๋์์:
+
+```bash
+node scripts/test-mcp-mutations.mjs
+node scripts/test-mcp-demo-once.mjs
+```
+
+## get_project_state ๋์
+ํ์ฌ `get_project_state`๋ ์๋ ์์๋ก ์๋ตํฉ๋๋ค.
+
+1. editor window๊ฐ ์์ผ๋ฉด live command ์คํ
+2. editor window๊ฐ ์์ง๋ง publish๋ ์ต์ ์ํ๊ฐ ์์ผ๋ฉด cached snapshot ๋ฐํ
+3. ๋ ๋ค ์์ผ๋ฉด `state: null` ๊ณผ ํจ๊ป ์๋ด ๋ฉ์์ง ๋ฐํ
+
+์ฆ, editor๊ฐ ์์ง ์ ์ด๋ฆฐ ์ํ์์๋ ์ ๋น์ด ์๋์ง ๋ฐ๋ก ์ ์ ์๊ฒ ํ์ต๋๋ค.
+
+## Hermes / Codex / Claude Code ์ญํ ๋ถ๋ด
+์ด ํ๋ก์ ํธ์์๋ ์ธ ์์ด์ ํธ๋ฅผ ์๋์ฒ๋ผ ์๋๋ค.
+
+### Hermes
+- ์์
๋ฒ์ ์ ๋ฆฌ
+- ์ฐ์ ์์ ๊ฒฐ์
+- ์ด๋ค ์์ด์ ํธ์๊ฒ ์ด๋ค ์ผ์ ๋งก๊ธธ์ง ์งํ
+- ์ต์ข
๋ฐ์/์ ๋ฆฌ
+
+### Codex
+- ์ฝ๋ ์์
+- ๋น๋
+- ํ
์คํธ ์คํฌ๋ฆฝํธ ์ ๊ฒ
+- ๋ฐํ์ ์ฌํ๊ณผ ์์
+
+### Claude Code
+- ๊ตฌ์กฐ ๋ฆฌ๋ทฐ
+- ์ธ์
/์ํ ํ๋ฆ ์ ๊ฒ
+- ์ค๊ณ์ ๋๋ฝ/๋ฆฌ์คํฌ ํ์ธ
+- ๋ค์ ๋ฆฌํฉํ ๋ง ์ฐ์ ์์ ์ ์
+
+## ์ค์ ์งํ ๋ก๊ทธ ์์
+- `[Hermes] MCP ์ธ์ฆ ํ๋ฆ๋ถํฐ ํ์ธ`
+- `[Codex] electron/mcp/server.ts ์์ `
+- `[Claude Code] session/transport ๊ตฌ์กฐ ๋ฆฌ๋ทฐ`
+- `[Hermes] ๋ ๊ฒฐ๊ณผ๋ฅผ ํฉ์ณ ์ต์ ์์ ๋ฐ์`
+
+## ํ์ฌ ๋จ์ ๊ตฌ์กฐ ๊ฐ์ ์ฐ์ ์์
+1. HUD window์ editor window ์ฐธ์กฐ ๋ถ๋ฆฌ
+2. state source of truth ์ ๋ฆฌ (`live` vs `cached`)
+3. ์ธ์
์ข
๋ฃ/์ฌ์ฐ๊ฒฐ ๋ก๊ทธ์ ๊ด์ธก์ฑ ๋ ๋ณด๊ฐ
+
+## session transport ๊ตฌ์กฐ ๋ฉ๋ชจ
+ํ์ฌ MCP ์๋ฒ๋ stateful session ๋ชจ๋๋ก ๋์ํฉ๋๋ค.
+
+- initialize ์์ฒญ๋ง๋ค ์ `server + transport` ์ ์์ฑ
+- `mcp-session-id` ํค๋๋ก ๊ธฐ์กด ์ธ์
transport ์ฌ์ฌ์ฉ
+- `GET /mcp`์ `DELETE /mcp`๋ session id ๊ธฐ์ค์ผ๋ก ๊ธฐ์กด transport์ ๋ผ์ฐํ
+- ์ธ์
์ข
๋ฃ ์ runtime cleanup helper๋ก server/transport ์ ๋ฆฌ๋ฅผ ํ ๊ณณ์์ ์ฒ๋ฆฌ
+
+## ์ฐธ๊ณ
+ํ์ฌ ๋ก์ปฌ ๊ฐ๋ฐ ๋จ๊ณ์์๋ URL/token์ด ๋ก๊ทธ์ ์ถ๋ ฅ๋ฉ๋๋ค. ์ด ๊ฐ์ ๋ก์ปฌ Bearer ์ธ์ฆ ์ ๋ณด์ด๋ฏ๋ก ์ธ๋ถ ๊ณต์ ๋ ํผํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
diff --git a/docs/mcp-demo.md b/docs/mcp-demo.md
new file mode 100644
index 00000000..1277ce44
--- /dev/null
+++ b/docs/mcp-demo.md
@@ -0,0 +1,57 @@
+# Auto Screen MCP Demo
+
+## ๋ชฉํ
+ํฐ๋ฏธ๋์์ ์คํํ Codex CLI / Claude Code๊ฐ Auto Screen ๋ฐ์คํฌํฑ ์ฑ์ ๋ก์ปฌ MCP ์๋ฒ์ ์ฐ๊ฒฐํด ๊ตฌ์กฐํ๋ ํธ์ง ๋ช
๋ น์ ํธ์ถํ๊ณ , ์ฑ UI์ ์ค์๊ฐ์ผ๋ก ๊ฒฐ๊ณผ๊ฐ ๋ฐ์๋๋๋ก ํ๋ค.
+
+## ํ์ฌ ๊ตฌ์กฐ
+- Auto Screen Electron main process๊ฐ localhost MCP ์๋ฒ๋ฅผ ์ฐ๋ค.
+- renderer(VideoEditor)๊ฐ ์ค์ ํธ์ง ์ํ์ source of truth๋ฅผ ๊ฐ์ง๋ค.
+- main โ renderer ๋ typed IPC command bridge๋ก ์ฐ๊ฒฐ๋๋ค.
+- ์ธ์ฆ์ Auto Screen์ด ๋ฐ๊ธํ ๋ก์ปฌ bearer token์ผ๋ก ํ๋ค.
+
+## ํ์ฌ ๊ตฌํ๋ MCP tools
+- `get_project_state`
+- `remove_background`
+- `set_background`
+- `apply_auto_edit`
+- `add_trim_region`
+- `add_speed_region`
+- `add_zoom_region`
+- `undo`
+- `redo`
+- `export_video`
+
+## ๋ฐํ์ ์ ๋ณด
+์ฑ ์์ ์ Electron main ๋ก๊ทธ์ ์๋๊ฐ ์ถ๋ ฅ๋๋ค.
+- `[Auto Screen MCP] http://127.0.0.1:/mcp`
+- `[Auto Screen MCP Token] `
+
+renderer์์๋ `window.electronAPI.getMcpConnectionInfo()` ๋ก๋ ์กฐํ ๊ฐ๋ฅํ๋ค.
+
+## Claude Code ์ฐ๊ฒฐ ์์
+MCP ์๋ฒ๋ฅผ Claude Code์ ๋ถ์ผ ๋๋ streamable HTTP ์๋ํฌ์ธํธ์ bearer ํ ํฐ์ ์ฌ์ฉํ๋ค.
+์ค์ ์ฐ๊ฒฐ ์ปค๋งจ๋๋ Claude Code ๋ฒ์ ์ ๋ฐ๋ผ ๋ค๋ฅผ ์ ์์ผ๋ฏ๋ก, ํ์ฌ ์ค์น ๋ฒ์ ์ `claude mcp add --help` ๊ธฐ์ค์ผ๋ก ๋ง์ถ๋ค.
+
+๊ฐ๋
์์:
+- URL: `http://127.0.0.1:/mcp`
+- Header: `Authorization: Bearer `
+
+## Codex ์ฐ๊ฒฐ ์์
+Codex๋ ๋์ผํ๊ฒ localhost MCP endpoint + bearer token์ ์ฌ์ฉํ๋ค.
+Codex ์ชฝ ์ค์ ๋ฑ๋ก ๋ช
๋ น์ ์ค์น ๋ฒ์ ์ `codex mcp --help` ๊ธฐ์ค์ผ๋ก ๋ง์ถ๋ค.
+
+## ํ
์คํธ ์๋๋ฆฌ์ค
+1. Auto Screen ์ฑ ์คํ
+2. ๊ธฐ์กด ๋
นํ๋ณธ ๋๋ ์์ ํ์ผ ๋ก๋
+3. MCP ํด๋ผ์ด์ธํธ ์ฐ๊ฒฐ
+4. `get_project_state` ํธ์ถ
+5. `set_background` ํธ์ถ
+6. `add_trim_region`, `add_zoom_region`, `add_speed_region` ํธ์ถ
+7. `undo`, `redo` ํธ์ถ
+8. ํ์ ์ `apply_auto_edit`, `export_video` ํธ์ถ
+
+## ์ค๊ณ ์์น
+- UI ํด๋ฆญ ์๋ํ ๊ธ์ง
+- ์ํ ๊ธฐ๋ฐ/๋ช
๋ น ๊ธฐ๋ฐ ํธ์ง๋ง ํ์ฉ
+- undo/redo ๊ฐ๋ฅํ ๋ชจ๋ ํธ์ง์ renderer ์ํ ์
๋ฐ์ดํธ๋ฅผ ๊ฑฐ์น๋ค.
+- ํฅํ CapCut/Premiere ์คํ์ผ ํ์ฅ์ ์ํด command surface๋ฅผ ๊ณ์ ๋๋ฆฐ๋ค.
diff --git a/docs/plans/2026-04-09-mcp-editor-control.md b/docs/plans/2026-04-09-mcp-editor-control.md
new file mode 100644
index 00000000..7a9e43e3
--- /dev/null
+++ b/docs/plans/2026-04-09-mcp-editor-control.md
@@ -0,0 +1,66 @@
+# Auto Screen MCP-Controlled Editor Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development style execution, but keep shared-file edits serialized.
+
+**Goal:** Turn Auto Screen into a locally controllable desktop editor where CLI agents can issue structured edit commands and see real-time updates in the app.
+
+**Architecture:** Electron main process hosts a localhost control server plus token auth. Renderer remains the source of truth for editor state. A typed IPC command bridge forwards structured commands from main to renderer, and renderer responds with typed results/snapshots. MCP integration can sit on top of the localhost control server.
+
+**Tech Stack:** Electron, Vite, React, TypeScript, existing editor history/controller/exporter stack.
+
+---
+
+## Phase 1: Shared command model
+- Add typed command/request/response envelopes in `src/editor/commands/types.ts`
+- Cover tools:
+ - `get_project_state`
+ - `remove_background`
+ - `set_background`
+ - `apply_auto_edit`
+ - `add_trim_region`
+ - `add_speed_region`
+ - `add_zoom_region`
+ - `undo`
+ - `redo`
+ - `export_video`
+
+## Phase 2: Renderer command execution
+- Add reusable command helpers in `src/editor/commands/regions.ts`
+- Expand `src/editor/useEditorController.ts` with:
+ - addTrimRegion
+ - addSpeedRegion
+ - addZoomRegion
+ - undo
+ - redo
+ - exportVideo (bridge to existing export callback)
+ - executeCommand(name, input)
+- Keep all mutations flowing through existing `pushState` / `undo` / `redo`
+
+## Phase 3: Main/renderer bridge
+- Expose preload APIs for:
+ - register inbound command listener
+ - send command result to main
+ - publish state snapshot to main
+- Add main-process request/response bridge with correlation IDs
+
+## Phase 4: Local control server
+- Add localhost HTTP server in Electron main, bound to `127.0.0.1`
+- Token auth model:
+ - app generates local token
+ - caller sends `Authorization: Bearer `
+- Endpoints:
+ - `GET /mcp/state`
+ - `POST /mcp/command`
+ - optional `GET /mcp/session`
+
+## Phase 5: Demo flow
+- Editor publishes current state to main
+- Main server accepts command JSON
+- Main forwards command to renderer
+- Renderer executes and returns structured result
+- App UI updates in real time
+
+## Notes
+- For this pass, HTTP control server is enough to prove the architecture. MCP stdio wrapper can be added on top afterward.
+- `export_video` should use current renderer export pipeline; do not migrate export logic to main.
+- Do not use UI automation; use structured editor commands only.
diff --git a/docs/plans/2026-04-auth-billing-desktop-architecture.md b/docs/plans/2026-04-auth-billing-desktop-architecture.md
new file mode 100644
index 00000000..cf5bad0a
--- /dev/null
+++ b/docs/plans/2026-04-auth-billing-desktop-architecture.md
@@ -0,0 +1,307 @@
+# Auto Screen Auth / Billing / Desktop Account Architecture Plan
+
+> **For Hermes:** ์ด ๋ฌธ์๋ Auto Screen์ ์ ๋ฃ SaaS + ๋ฐ์คํฌํฑ ์ฑ์ผ๋ก ํ๋งคํ๊ธฐ ์ํ ์ธ์ฆ/๊ฒฐ์ /๊ณ์ ์ฐ๋ ๊ธฐ์ค์์ด๋ค. ๊ตฌํ ์ ์ด ๋ฌธ์๋ฅผ source of truth๋ก ์ฌ์ฉํ๋ค.
+
+**Goal:** Auto Screen ๋ฐ์คํฌํฑ ์ฑ์ ํ์๊ฐ์
/๋ก๊ทธ์ธ, ์ 15,900์ ๊ตฌ๋
๊ฒฐ์ , ๊ณ์ ๊ธฐ๋ฐ ๊ถํ ์ ์ด๋ฅผ ๋ถ์ฌ ์ค์ ํ๋งค ๊ฐ๋ฅํ ๊ตฌ์กฐ๋ฅผ ๋ง๋ ๋ค.
+
+**Architecture:** Electron ์ฑ์ ํด๋ผ์ด์ธํธ์ผ ๋ฟ์ด๊ณ , ์ธ์ฆ/๊ฒฐ์ /๊ตฌ๋
์ํ์ ๊ธฐ์ค์ ๋ฐ๋์ ์๋ฒ๊ฐ ๊ฐ์ง๋ค. ๋ก๊ทธ์ธ๊ณผ ๊ฒฐ์ ๋ ์น/์ธ๋ถ ๋ธ๋ผ์ฐ์ ์ค์ฌ์ผ๋ก ์ฒ๋ฆฌํ๊ณ , ์ฑ์ ๋ก๊ทธ์ธ ์๋ฃ ํ ์๋ฒ์์ ์ธ์
๊ณผ entitlement(๊ถํ)๋ฅผ ๋ฐ์ ๊ธฐ๋ฅ์ ํด์ ํ๋ค.
+
+**Tech Stack (Recommended MVP):**
+- Electron + React (๊ธฐ์กด ์ฑ)
+- Auth: Supabase Auth
+- Social Login: Google ์ฐ์ , Kakao 2์ฐจ, Naver 3์ฐจ
+- Backend/API: Next.js API ๋๋ Express ์๋ฒ
+- DB: Supabase Postgres
+- Billing: Toss Payments ์ ๊ธฐ๊ฒฐ์
+- Desktop session: custom protocol deep link + server-issued token
+
+---
+
+## 1. ์ต์ข
๊ถ์ฅ ๋ฐฉํฅ
+
+### ์ถ์ฒ 1์
+**Supabase Auth + Backend(API) + Toss Payments ์ ๊ธฐ๊ฒฐ์ + Electron ์ธ๋ถ ๋ธ๋ผ์ฐ์ ๋ก๊ทธ์ธ**
+
+์ด ์กฐํฉ์ ์ถ์ฒํ๋ ์ด์ :
+1. ํ์ฌ ํ๋ก์ ํธ๋ **๋ฐ์คํฌํฑ ์ฑ๋ง ์๊ณ ์๋ฒ๊ฐ ์์**
+2. ์ ๋ฃ ํ๋งค ๊ตฌ์กฐ์์ ๊ฐ์ฅ ์ค์ํ ๊ฑด
+ - ๋ก๊ทธ์ธ UI๊ฐ ์๋๋ผ
+ - **์๋ฒ๊ฐ ๊ตฌ๋
์ํ๋ฅผ ๋จ์ผ ๊ธฐ์ค์ผ๋ก ๊ด๋ฆฌํ๋ ๊ฒ**
+3. ํ๊ตญ ์ฌ์ฉ์ ๋์ ์ ๊ตฌ๋
์ด๋ผ๋ฉด Stripe๋ณด๋ค **Toss Payments**๊ฐ ์ด์์ ์์ฐ์ค๋ฝ๋ค
+4. Electron ์ฑ์์ OAuth/๊ฒฐ์ ๋ฅผ ์ฑ ๋ด๋ถ ์น๋ทฐ๋ก ์ฒ๋ฆฌํ๋ฉด ๋ณด์/์ฌ์ฌ/์์ธ๊ฐ ๋ณต์กํด์ ธ์, **๊ธฐ๋ณธ ๋ธ๋ผ์ฐ์ ์ฒ๋ฆฌ**๊ฐ ๋ ์์ ํ๋ค
+
+### ๋์
+**Firebase Auth + Backend + PortOne**
+
+์ธ์ ๊ณ ๋ คํ๋:
+- ์นด๋ ์ธ ๊ฒฐ์ ์๋จ/PG ์ ํ์ด ๋นจ๋ฆฌ ํ์ํ ๋
+- ๊ฒฐ์ ์ฌ ์ ์ฐ์ฑ์ด ๋ ์ค์ํ ๋
+
+๋จ์ :
+- ์ธ์ฆ/๊ฒฐ์ /์๋ฒ ๊ตฌ์ฑ์ด ๋ ๋ถ์ฐ๋จ
+- MVP ์๋๋ ์ถ์ฒ์๋ณด๋ค ๋๋ฆด ๊ฐ๋ฅ์ฑ์ด ๋์
+
+---
+
+## 2. ๋ก๊ทธ์ธ ๋ฐฉ์ ์ฐ์ ์์
+
+### MVP 1์ฐจ
+๋ฐ๋์ ๋จผ์ ๋ฃ์ ๊ฒ:
+- ์ด๋ฉ์ผ ํ์๊ฐ์
/๋ก๊ทธ์ธ
+- Google ๋ก๊ทธ์ธ
+
+### 2์ฐจ
+- Kakao ๋ก๊ทธ์ธ
+
+### 3์ฐจ
+- Naver ๋ก๊ทธ์ธ
+
+### ์ ์ด๋ ๊ฒ ๊ฐ์ผ ํ๋
+- Google์ ๊ตฌํ/์ด์ ๋๋๊ฐ ์ ์ผ ๋ฎ๊ณ ๋ฒ์ฉ์ฑ์ด ๋์
+- Kakao๋ ํ๊ตญ ์ฌ์ฉ์์ ๊ฐ์น๊ฐ ํฌ์ง๋ง ์ด๋ฐ๋ถํฐ ๋ฃ์ผ๋ฉด ์์ธ ์ฒ๋ฆฌ๊ฐ ๋์ด๋จ
+- Naver๋ B2C ์ด๋ฐ ํ์๋ ์๋
+
+### ๊ฒฐ๋ก
+**์์
๋ก๊ทธ์ธ์ ๊ฐ๋ฅํ๋ค.**
+๋ค๋ง ์ฒ์๋ถํฐ Google/Kakao/Naver 3๊ฐ๋ฅผ ๋์์ ๋ฃ๋ ๊ฑด MVP ์๋๋ฅผ ๋ฆ์ถ๋ค.
+
+๋ฐ๋ผ์ ์ค์ ๊ถ์ฅ ์์:
+1. ์ด๋ฉ์ผ + Google
+2. Kakao
+3. Naver
+
+---
+
+## 3. ๊ฒฐ์ ๊ตฌ์กฐ
+
+### ์๊ธ์
+- Pro Monthly
+- ์ 15,900์
+- ๊ธฐ๋ณธ ํตํ: KRW
+- ์ํ ID ์์: `pro_monthly_15900_krw`
+
+### MVP ๊ฒฐ์ ๋ชจ๋ธ
+- ๋จ์ผ ์ ๋ฃ ํ๋ 1๊ฐ๋ง ์ด์
+- ์ฐ๊ฐ ์๊ธ์ ๋ ๋์ค
+- ์ฟ ํฐ/ํ๋ก๋ชจ์
/ํํ๋์ ๋์ค
+- ๋ฌด๋ฃ ์ฒดํ์ด ํ์ํ๋ฉด 7์ผ trial๋ง ๊ณ ๋ ค
+
+### ์ถ์ฒ ๊ฒฐ์ ๋ฐฉ์
+**Toss Payments ์ ๊ธฐ๊ฒฐ์ (๋น๋งํค ๊ธฐ๋ฐ)**
+
+์๋ฒ๊ฐ ํด์ผ ํ๋ ์ผ:
+- ๊ณ ๊ฐ ์์ฑ/๋งคํ
+- ๋น๋งํค ์ ์ฅ
+- ์ ์ ๊ธฐ ์ฒญ๊ตฌ ์์ฒญ
+- ๊ฒฐ์ ์ฑ๊ณต/์คํจ ์นํ
์ฒ๋ฆฌ
+- ๊ตฌ๋
์ํ ๋ณ๊ฒฝ ๋ฐ์
+
+### ๊ตฌ๋
์ํ ์์
+- `trialing`
+- `active`
+- `past_due`
+- `canceled`
+- `expired`
+- `refunded`
+
+---
+
+## 4. ์๋ฒ๊ฐ ๋ฐ๋์ ๊ฐ์ ธ์ผ ํ๋ ์ฑ
์
+
+์ด ๊ธฐ๋ฅ์ **์ฑ๋ง์ผ๋ก ๊ตฌํํ๋ฉด ์ ๋๋ค.**
+๋ฐ๋์ ์๋ฒ๊ฐ ํด์ผ ํ๋ ์ฑ
์:
+
+1. ์ฌ์ฉ์ ๊ณ์ ๊ด๋ฆฌ
+2. ์์
๋ก๊ทธ์ธ/OAuth ์ฝ๋ฐฑ ์ฒ๋ฆฌ
+3. ๊ฒฐ์ ๊ณ ๊ฐ๊ณผ ์ฑ ๊ณ์ ๋งคํ
+4. ๊ตฌ๋
์ํ ์ ์ฅ
+5. ๊ฒฐ์ ์นํ
์์ ๋ฐ ์ํ ๋ฐ์
+6. ์ฑ์ด ์ฌ์ฉํ ์ธ์
ํ ํฐ ๋ฐ๊ธ
+7. entitlement ํ์
+ - ์ด ๊ณ์ ์ด ํ์ฌ Pro์ธ๊ฐ?
+8. ๊ธฐ๊ธฐ ๋ฑ๋ก ๋ฐ ์ ํ
+ - ์: ๊ณ์ ๋น 2๋
+9. ํ๋ถ/ํด์ง ํ ๊ถํ ์ข
๋ฃ ์ฒ๋ฆฌ
+10. ๊ตฌ๋งค ๋ณต๊ตฌ ์ฒ๋ฆฌ
+
+### ์ ๋ ํด๋ผ์ด์ธํธ(Electron ์ฑ)์ ๋ฃ์ผ๋ฉด ์ ๋๋ ๊ฒ
+- OAuth client secret
+- ๊ฒฐ์ secret key
+- webhook secret
+- DB admin key
+- service role key
+- ๊ตฌ๋
์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์๋ ๊ถํ
+
+---
+
+## 5. ๋ฐ์คํฌํฑ ์ฑ + ๊ณ์ ์ฐ๋ ํ๋ฆ
+
+### ๋ก๊ทธ์ธ ํ๋ฆ
+1. ์ฑ์์ `๋ก๊ทธ์ธ` ํด๋ฆญ
+2. Electron์ด ๊ธฐ๋ณธ ๋ธ๋ผ์ฐ์ ๋ก `https://app.autoscreen.io/login?source=desktop` ์คํ
+3. ์ฌ์ฉ์๊ฐ ์น์์ ๋ก๊ทธ์ธ ์ํ
+4. ์๋ฒ๊ฐ ๋ก๊ทธ์ธ ์๋ฃ ํ `autoscreen://auth/callback?code=ONE_TIME_CODE` ๋ก ๋ฆฌ๋ค์ด๋ ํธ
+5. Electron main process๊ฐ deep link ์์
+6. ์ฑ์ด ์ด one-time code๋ฅผ ์๋ฒ API๋ก ๋ณด๋ด์
+ - desktop access token
+ - refresh token
+ - user summary
+ - entitlement
+ ๋ฅผ ๋ฐ์
+7. ์ฑ์ ํ ํฐ์ OS Keychain์ ์ ์ฅ
+8. ์ฑ์ entitlement ๊ธฐ์ค์ผ๋ก ๊ธฐ๋ฅ ํ์ฑํ
+
+### ๊ฒฐ์ ํ๋ฆ
+1. ์ฑ์์ `Pro ์
๊ทธ๋ ์ด๋` ํด๋ฆญ
+2. Electron์ด ๊ธฐ๋ณธ ๋ธ๋ผ์ฐ์ ๋ก `https://app.autoscreen.io/billing/checkout` ์คํ
+3. ์ฌ์ฉ์๊ฐ ๊ฒฐ์ ์๋ฃ
+4. ์๋ฒ๊ฐ ๊ฒฐ์ ์๋ฃ ๋ฐ webhook ํ์ธ ํ `subscription = active` ๋ฐ์
+5. ์ฑ์
+ - ์ฆ์ polling ๋๋
+ - ์ฌ๋ก๊ทธ์ธ ์์ด `Refresh subscription` ๋ฒํผ
+ ์ผ๋ก entitlement ๊ฐฑ์
+
+### ์ฑ ์คํ ์ ๊ฒ์ฆ ํ๋ฆ
+1. ์ ์ฅ๋ refresh token ํ์ธ
+2. ์๋ฒ์ ์ธ์
๊ฐฑ์ ์์ฒญ
+3. ์๋ฒ๊ฐ ํ์ฌ ๊ตฌ๋
์ํ ๋ฐํ
+4. ์ฑ์ด ๊ธฐ๋ฅ ์ ๊ธ/ํด์ ์ ์ฉ
+
+---
+
+## 6. ์ฑ ๊ถํ(Entitlement) ๋ชจ๋ธ
+
+### Free
+- ๊ธฐ๋ณธ ํธ์ง ๊ธฐ๋ฅ ์ ํ์ ์ฌ์ฉ
+- ์ํฐ๋งํฌ ๋๋ export ์ ํ ๊ฐ๋ฅ
+- ๊ณ ๊ธ ์๋ ํธ์ง/AI/MCP ์ฐ๋ ๊ธฐ๋ฅ ์ ํ ๊ฐ๋ฅ
+
+### Pro
+- ์ ์ฒด ํธ์ง ๊ธฐ๋ฅ
+- ๊ณ ๊ธ export
+- MCP/AI ํธ์ง ๊ธฐ๋ฅ
+- ํฅํ ํด๋ผ์ฐ๋ ๊ธฐ๋ฅ/ํ
ํ๋ฆฟ/๋๊ธฐํ ํ์ฅ ๊ฐ๋ฅ
+
+### ๊ถ์ฅ ๋ฐฉ์
+์ฑ์ ๋จ์ํ `isPro`๋ง ๋ณด์ง ๋ง๊ณ entitlement ๋ฐฐ์ด๋ก ๋ฐ๋ ๊ฒ ์ข๋ค.
+
+์์:
+```json
+{
+ "plan": "pro_monthly",
+ "status": "active",
+ "entitlements": [
+ "export_hd",
+ "mcp_editing",
+ "advanced_auto_edit",
+ "future_cloud_sync"
+ ]
+}
+```
+
+์ด๋ ๊ฒ ํ๋ฉด ๋์ค์ ํ๋์ด ๋์ด๋๋ ๊ตฌ์กฐ๊ฐ ๋ฒํด๋ค.
+
+---
+
+## 7. ๊ธฐ๊ธฐ ์ ํ ์ ์ฑ
+
+### MVP ๊ถ์ฅ
+- ๊ณ์ ๋น 2๋ ํ์ฉ
+- ์ ๊ธฐ๊ธฐ ๋ก๊ทธ์ธ ์ ์ค๋๋ ๊ธฐ๊ธฐ ๋ก๊ทธ์์ ๋๋ ์ฌ์ฉ์ ์ ํ
+
+### ์๋ฒ ํ
์ด๋ธ ์์
+- `devices`
+ - `id`
+ - `user_id`
+ - `device_id`
+ - `device_name`
+ - `platform`
+ - `last_seen_at`
+ - `revoked_at`
+
+### ์ ํ์ํ๊ฐ
+์ ๋ฃ ํ๋์ ์ ๊ตฌ๋
์ผ๋ก ํ ๋ ๊ณ์ ๊ณต์ ๋ฅผ ์์ ํ ๋ง๊ธฐ๋ ์ด๋ ต์ง๋ง,
+**๊ธฐ๊ธฐ ์ ํ + ์ธ์
๊ฐฑ์ + ์๋ฒ ๊ฒ์ฆ**๋ง ํด๋ ๋จ์ฉ์ ํฌ๊ฒ ์ค์ผ ์ ์๋ค.
+
+---
+
+## 8. ์์
๋ก๊ทธ์ธ ํ์ค ํ๋จ
+
+### Google
+- ๋ฐ๋ก ๋ฃ๊ธฐ ์ข์
+- MVP ํฌํจ ๊ถ์ฅ
+
+### Kakao
+- ๊ฐ๋ฅํจ
+- ๋ค๋ง ๊ณต๊ธ์ ๋ฑ๋ก/redirect/callback ์ฒ๋ฆฌ ์ถ๊ฐ ํ์
+- MVP ์งํ 2์ฐจ ๊ถ์ฅ
+
+### Naver
+- ๊ฐ๋ฅํจ
+- ํ์ง๋ง ์ฐ์ ์์๋ ๋ฎ์
+- ์ค์ ํ๊น์์ ํ์์ฑ์ด ํ์ธ๋๋ฉด ์ถ๊ฐ
+
+### ๊ฒฐ๋ก
+์ฌ์ฉ์ ์์ฒญ๋๋ก **Google/Kakao/Naver ์์
์ฐ๋์ ๊ฐ๋ฅํ๋ค.**
+ํ์ง๋ง **MVP๋ ์ด๋ฉ์ผ + Google**์ผ๋ก ๋จผ์ ํ๋ ๊ฒ ๊ฐ์ฅ ํ์ค์ ์ด๋ค.
+
+---
+
+## 9. ๊ตฌํ ์ฐ์ ์์ (MVP ์์)
+
+### Phase 1 โ ์๋ฒ ๋ผ๋
+- Auth ์๋ฒ/API ํ๋ก์ ํธ ์์ฑ
+- ์ฌ์ฉ์/๊ตฌ๋
/๊ฒฐ์ /๊ธฐ๊ธฐ ํ
์ด๋ธ ์์ฑ
+- ํ๊ฒฝ๋ณ์/๋น๋ฐํค ๋ถ๋ฆฌ
+
+### Phase 2 โ ๋ก๊ทธ์ธ
+- ์ด๋ฉ์ผ ํ์๊ฐ์
/๋ก๊ทธ์ธ
+- Google ๋ก๊ทธ์ธ
+- desktop deep link ๋ก๊ทธ์ธ ์๋ฃ
+
+### Phase 3 โ ๊ฒฐ์
+- ์ 15,900์ Pro ํ๋ ์์ฑ
+- Toss ์ ๊ธฐ๊ฒฐ์ checkout
+- webhook ์ฒ๋ฆฌ
+- entitlement API
+
+### Phase 4 โ ๋ฐ์คํฌํฑ ์ฐ๋
+- ์ฑ ๋ก๊ทธ์ธ ์ํ ์ ์ง
+- entitlement ๋๊ธฐํ
+- ์
๊ทธ๋ ์ด๋/๊ตฌ๋งค๋ณต๊ตฌ ๋ฒํผ
+
+### Phase 5 โ ํ๊ตญํ ํ์ฅ
+- Kakao ๋ก๊ทธ์ธ
+- Naver ๋ก๊ทธ์ธ
+- ๊ณ ๊ฐ์ผํฐ/ํ๋ถ/ํด์ง UX
+
+---
+
+## 10. ์ง๊ธ ๋ฐ๋ก ๊ฒฐ์ ํด์ผ ํ๋ ๊ฒ
+
+### ๋ฐ๋ก ๊ฒฐ์ ์ถ์ฒ
+1. **MVP ๋ก๊ทธ์ธ์ ์ด๋ฉ์ผ + Google๊น์ง๋ง ๋จผ์ ํ๋ค**
+2. **๊ฒฐ์ ๋ Toss ์ ๊ธฐ๊ฒฐ์ ๊ธฐ์ค์ผ๋ก ๊ฐ๋ค**
+3. **์ฑ ์ฒซ ์คํ ํ๋ฉด์ ๋ก๊ทธ์ธ/ํ์๊ฐ์
ํ๋ฉด์ผ๋ก ์์ํ๋ค**
+4. **์ฑ ๋ด๋ถ ์น๋ทฐ ๋ก๊ทธ์ธ ๋์ ์ธ๋ถ ๋ธ๋ผ์ฐ์ + deep link๋ก ๊ฐ๋ค**
+5. **ํ๋์ ์ 15,900์ 1๊ฐ๋ง ๋จผ์ ์ด์ํ๋ค**
+6. **๋๋ฉํ์ด์ง๊ฐ ๋ค์ด๋ก๋/๊ฐ๊ฒฉ/์ฌ์ฉ๋ฒ ํผ๋์ ์ค์ฌ์ด ๋๋ค**
+
+---
+
+## 11. ๋ค์ ๋ฌธ์๋ก ๋ฐ๋ก ์ด์ด์ง ๊ฒ
+
+1. `docs/plans/auth-backend-mvp.md`
+ - backend DB/API ๊ตฌ์กฐ
+2. `docs/plans/electron-auth-flow.md`
+ - deep link / token storage / session refresh / ์ฑ ์ฒซ ๋ก๊ทธ์ธ ํ๋ฉด ์ค๊ณ
+3. `docs/plans/billing-mvp.md`
+ - Toss webhook / ๊ตฌ๋
์ํ / entitlement ์ค๊ณ
+4. `docs/plans/landing-page-funnel.md`
+ - ๋๋ฉํ์ด์ง / ๋ค์ด๋ก๋ / ๊ฐ๊ฒฉ / ์ฌ์ฉ๋ฒ / ์ฑ ์ง์
ํผ๋ ์ค๊ณ
+
+---
+
+## ํ ์ค ๊ฒฐ๋ก
+
+**์ง๊ธ์ โ์ฑ ์์ ๋ก๊ทธ์ธ ๋ถ์ด๊ธฐโ๋ณด๋ค โ์๋ฒ๋ฅผ ์ธ์ฐ๊ณ , ์ด๋ฉ์ผ+Google ๋ก๊ทธ์ธ + ์น ๊ฒฐ์ + ์๋ฒ entitlement ๊ตฌ์กฐ๋ฅผ ๋จผ์ ๋ง๋ ๋คโ๊ฐ ์ ๋ต์ด๋ค.**
diff --git a/docs/plans/auth-backend-mvp.md b/docs/plans/auth-backend-mvp.md
new file mode 100644
index 00000000..827dbd26
--- /dev/null
+++ b/docs/plans/auth-backend-mvp.md
@@ -0,0 +1,422 @@
+# Auto Screen Auth Backend MVP Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
+
+**Goal:** Auto Screen ์ ๋ฃ ํ๋งค๋ฅผ ์ํ ์ต์ ๋ฐฑ์๋(Auth, user profile, device registration, entitlement ์กฐํ, desktop token ๊ตํ)๋ฅผ ๋ง๋ ๋ค.
+
+**Architecture:** Supabase Auth๋ฅผ ์ฌ์ฉ์ ์ธ์ฆ์ ๊ธฐ๋ฐ์ผ๋ก ๋๊ณ , ๋ณ๋ backend API๊ฐ desktop login code ๊ตํ, device ๋ฑ๋ก, entitlement ํ์ , billing ์ํ ์กฐํ๋ฅผ ๋ด๋นํ๋ค. ํด๋ผ์ด์ธํธ(Electron ์ฑ)๋ ์ง์ ๋น๋ฐํค๋ฅผ ๊ฐ์ง ์๊ณ , ์๋ฒ API๋ง ํธ์ถํ๋ค.
+
+**Tech Stack:** Next.js App Router ๋๋ Express, Supabase Auth, Supabase Postgres, Zod, Toss Payments webhook ์ฐ๋ ์์ .
+
+---
+
+## Scope (MVP)
+- ์ด๋ฉ์ผ ํ์๊ฐ์
/๋ก๊ทธ์ธ
+- Google ๋ก๊ทธ์ธ
+- desktop deep link์ฉ one-time code ๋ฐ๊ธ/๊ตํ
+- ์ฌ์ฉ์ ๊ธฐ๋ณธ profile ์ ์ฅ
+- device ๋ฑ๋ก/์ ํ(๊ณ์ ๋น 2๋)
+- entitlement ์กฐํ API
+- subscription ์ํ๋ backend DB ๊ธฐ์ค
+
+## Out of Scope (์ด๋ฒ ๋จ๊ณ ์ ์ธ)
+- Kakao/Naver ์ค์ provider ์ฐ๊ฒฐ
+- ํ ํ๋
+- ์ฟ ํฐ/ํ๋ก๋ชจ์
+- ์ฐ๊ฐ ๊ฒฐ์
+- in-app purchase
+
+---
+
+## Recommended Repo Layout
+
+```text
+backend/
+ src/
+ app.ts
+ config/env.ts
+ lib/supabase.ts
+ lib/errors.ts
+ middleware/auth.ts
+ middleware/requestId.ts
+ routes/health.ts
+ routes/auth.ts
+ routes/me.ts
+ routes/devices.ts
+ routes/entitlements.ts
+ routes/billing.ts
+ services/auth-service.ts
+ services/device-service.ts
+ services/entitlement-service.ts
+ services/desktop-session-service.ts
+ services/subscription-service.ts
+ db/types.ts
+ db/sql/
+ 001_users.sql
+ 002_devices.sql
+ 003_subscriptions.sql
+ 004_desktop_login_codes.sql
+ 005_refresh_sessions.sql
+ package.json
+ tsconfig.json
+ .env.example
+```
+
+---
+
+## Data Model
+
+### `profiles`
+- `id uuid primary key` โ Supabase auth user id์ ๋์ผ
+- `email text not null`
+- `display_name text null`
+- `avatar_url text null`
+- `default_locale text null`
+- `created_at timestamptz not null default now()`
+- `updated_at timestamptz not null default now()`
+
+### `devices`
+- `id uuid primary key`
+- `user_id uuid not null`
+- `device_id text not null`
+- `device_name text not null`
+- `platform text not null`
+- `app_version text null`
+- `last_seen_at timestamptz not null default now()`
+- `revoked_at timestamptz null`
+- unique `(user_id, device_id)`
+
+### `subscriptions`
+- `id uuid primary key`
+- `user_id uuid not null`
+- `provider text not null default 'toss'`
+- `plan_code text not null default 'pro_monthly_15900_krw'`
+- `status text not null`
+- `current_period_start timestamptz null`
+- `current_period_end timestamptz null`
+- `cancel_at timestamptz null`
+- `canceled_at timestamptz null`
+- `provider_customer_key text null`
+- `provider_billing_key text null`
+- `created_at timestamptz not null default now()`
+- `updated_at timestamptz not null default now()`
+
+### `desktop_login_codes`
+- `id uuid primary key`
+- `user_id uuid not null`
+- `code text not null unique`
+- `code_challenge text null`
+- `device_id text null`
+- `expires_at timestamptz not null`
+- `used_at timestamptz null`
+- `created_at timestamptz not null default now()`
+
+### `refresh_sessions`
+- `id uuid primary key`
+- `user_id uuid not null`
+- `device_id text not null`
+- `refresh_token_hash text not null`
+- `expires_at timestamptz not null`
+- `revoked_at timestamptz null`
+- `last_rotated_at timestamptz not null default now()`
+- `created_at timestamptz not null default now()`
+
+---
+
+## API Contract
+
+### `GET /api/health`
+์๋ต:
+```json
+{ "ok": true }
+```
+
+### `POST /api/auth/desktop/start`
+๋ธ๋ผ์ฐ์ ๋ก๊ทธ์ธ ์๋ฃ ํ ํธ์ถ. ์๋ฒ๊ฐ one-time code ์์ฑ.
+
+Request:
+```json
+{
+ "deviceId": "macos-uuid",
+ "codeChallenge": "optional-pkce-value"
+}
+```
+
+Response:
+```json
+{
+ "redirectUrl": "autoscreen://auth/callback?code=abc123"
+}
+```
+
+### `POST /api/auth/desktop/exchange`
+์ฑ์ด deep link์ code๋ฅผ ์ ๋ฌํด desktop ์ธ์
๋ฐ๊ธ.
+
+Request:
+```json
+{
+ "code": "abc123",
+ "device": {
+ "deviceId": "macos-uuid",
+ "deviceName": "MacBook Pro",
+ "platform": "darwin",
+ "appVersion": "1.3.0"
+ }
+}
+```
+
+Response:
+```json
+{
+ "accessToken": "jwt-or-random-token",
+ "refreshToken": "opaque-refresh-token",
+ "expiresIn": 3600,
+ "user": {
+ "id": "uuid",
+ "email": "user@example.com",
+ "displayName": "User"
+ },
+ "subscription": {
+ "plan": "pro_monthly_15900_krw",
+ "status": "active"
+ },
+ "entitlements": ["mcp_editing", "advanced_auto_edit", "export_hd"]
+}
+```
+
+### `POST /api/auth/refresh`
+Request:
+```json
+{
+ "refreshToken": "opaque-refresh-token",
+ "deviceId": "macos-uuid"
+}
+```
+
+### `POST /api/auth/logout`
+ํ์ฌ device ์ธ์
revoke.
+
+### `GET /api/me`
+ํ์ฌ ์ฌ์ฉ์ ์ ๋ณด ์กฐํ.
+
+### `GET /api/entitlements`
+์๋ต:
+```json
+{
+ "plan": "free",
+ "status": "inactive",
+ "entitlements": []
+}
+```
+๋๋
+```json
+{
+ "plan": "pro_monthly",
+ "status": "active",
+ "entitlements": ["mcp_editing", "advanced_auto_edit", "export_hd"]
+}
+```
+
+### `GET /api/devices`
+ํ์ฌ ๊ณ์ ์ ์ฐ๊ฒฐ๋ ๊ธฐ๊ธฐ ๋ชฉ๋ก.
+
+### `POST /api/devices/revoke`
+๋ค๋ฅธ ๊ธฐ๊ธฐ ์ธ์
๊ฐ์ ํด์ .
+
+---
+
+## Device Limit Policy
+- ๊ธฐ๋ณธ ํ์ฉ: ๊ณ์ ๋น ํ์ฑ ๊ธฐ๊ธฐ 2๋
+- 3๋ฒ์งธ ๊ธฐ๊ธฐ ๋ก๊ทธ์ธ ์ ์ ์ฑ
:
+ 1. ์ค๋๋ ๊ธฐ๊ธฐ ์๋ revoke, ๋๋
+ 2. API๊ฐ `DEVICE_LIMIT_REACHED` ๋ฐํ ํ ์ฌ์ฉ์๊ฐ ์น์์ ๊ด๋ฆฌ
+- MVP๋ **์ค๋๋ ๊ธฐ๊ธฐ ์๋ revoke**๊ฐ ๊ตฌํ ๋น์ฉ์ด ๊ฐ์ฅ ๋ฎ์
+
+์๋ฌ ์์:
+```json
+{
+ "error": {
+ "code": "DEVICE_LIMIT_REACHED",
+ "message": "Maximum active devices reached"
+ }
+}
+```
+
+---
+
+## Security Rules
+- Supabase service role key๋ backend ์๋ฒ์๋ง ์ ์ฅ
+- desktop access token TTL์ ์งง๊ฒ(์: 1์๊ฐ)
+- refresh token์ opaque token์ผ๋ก ๋ฐ๊ธํ๊ณ hash๋ง DB ์ ์ฅ
+- one-time code ๋ง๋ฃ๋ 60~120์ด
+- one-time code๋ ๋จ 1ํ ์ฌ์ฉ
+- access token๋ง์ผ๋ก subscription ์ํ ๋ณ๊ฒฝ ๋ถ๊ฐ
+- webhook ์ฒ๋ฆฌ๋ง์ด subscription ๋ณ๊ฒฝ ๊ฐ๋ฅ
+
+---
+
+## Task Breakdown
+
+### Task 1: Backend skeleton ์์ฑ
+**Objective:** ์ธ์ฆ/๊ณผ๊ธ ๋ฐฑ์๋ ์์
๋๋ ํฐ๋ฆฌ๋ฅผ ๋ง๋ ๋ค.
+
+**Files:**
+- Create: `backend/package.json`
+- Create: `backend/tsconfig.json`
+- Create: `backend/src/app.ts`
+- Create: `backend/src/config/env.ts`
+- Create: `backend/.env.example`
+
+**Implementation notes:**
+- ์๋ฒ ํ๋ ์์ํฌ๋ Express๋ก ์์
+- `zod`, `dotenv`, `cors`, `cookie-parser` ์ฌ์ฉ ๊ฐ๋ฅ
+- `/api/health` ํ๋๋ง ๋จผ์ ์ด๊ธฐ
+
+**Verify:**
+- Run: `cd backend && npm install`
+- Run: `npm run dev`
+- Expected: `GET /api/health` returns `{ ok: true }`
+
+### Task 2: Supabase ์ฐ๊ฒฐ ๊ณ์ธต ์ถ๊ฐ
+**Objective:** backend๊ฐ Supabase Auth/Admin API๋ฅผ ์์ ํ๊ฒ ์ฌ์ฉํ ์ ์๊ฒ ํ๋ค.
+
+**Files:**
+- Create: `backend/src/lib/supabase.ts`
+- Modify: `backend/src/config/env.ts`
+- Modify: `backend/.env.example`
+
+**Implementation notes:**
+- `SUPABASE_URL`
+- `SUPABASE_ANON_KEY`
+- `SUPABASE_SERVICE_ROLE_KEY`
+- env validation์ zod๋ก ๊ณ ์
+
+**Verify:**
+- ์๋ฒ ์์ ์ env validation pass
+- ์๋ชป๋ env๋ฉด ์ฆ์ fail-fast
+
+### Task 3: DB migration SQL ์์ฑ
+**Objective:** profiles/devices/subscriptions/desktop_login_codes/refresh_sessions ํ
์ด๋ธ์ ๋ง๋ ๋ค.
+
+**Files:**
+- Create: `backend/src/db/sql/001_users.sql`
+- Create: `backend/src/db/sql/002_devices.sql`
+- Create: `backend/src/db/sql/003_subscriptions.sql`
+- Create: `backend/src/db/sql/004_desktop_login_codes.sql`
+- Create: `backend/src/db/sql/005_refresh_sessions.sql`
+
+**Verify:**
+- Supabase SQL editor์์ ์คํ ๊ฐ๋ฅ
+- unique/index/foreign key ํ์ธ
+
+### Task 4: entitlement service ๊ตฌํ
+**Objective:** ๊ตฌ๋
์ํ๋ฅผ ์ฑ ๊ถํ ๋ฐฐ์ด๋ก ๋ณํํ๋ค.
+
+**Files:**
+- Create: `backend/src/services/entitlement-service.ts`
+- Create: `backend/src/routes/entitlements.ts`
+
+**Implementation notes:**
+- `free` โ `[]`
+- `active pro_monthly_15900_krw` โ `["mcp_editing", "advanced_auto_edit", "export_hd"]`
+- ์ดํ ํ๋ ์ถ๊ฐ ๋๋น ํจ์ ๋ถ๋ฆฌ
+
+**Verify:**
+- ๋จ์ ํ
์คํธ ๋๋ route smoke test
+
+### Task 5: desktop one-time code ๋ฐ๊ธ ๊ตฌํ
+**Objective:** ์น ๋ก๊ทธ์ธ ์๋ฃ ํ Electron ์ฑ์ผ๋ก ๋๊ธธ one-time code๋ฅผ ๋ฐ๊ธํ๋ค.
+
+**Files:**
+- Create: `backend/src/services/desktop-session-service.ts`
+- Create: `backend/src/routes/auth.ts`
+
+**Implementation notes:**
+- ์ธ์ฆ๋ ์น ์ฌ์ฉ์๋ง ํธ์ถ ๊ฐ๋ฅ
+- 60~120์ด ๋ง๋ฃ code ์์ฑ
+- ์ฌ์ฉ ํ ์ฌ์ฌ์ฉ ๊ธ์ง
+- ์๋ต์ `autoscreen://auth/callback?code=...`
+
+**Verify:**
+- ๊ฐ์ code ์ฌ์ฌ์ฉ ์ 400/409 ๋ฐํ
+
+### Task 6: desktop code exchange ๊ตฌํ
+**Objective:** Electron ์ฑ์ด code๋ฅผ access/refresh token์ผ๋ก ๊ตํํ๊ฒ ํ๋ค.
+
+**Files:**
+- Modify: `backend/src/routes/auth.ts`
+- Modify: `backend/src/services/desktop-session-service.ts`
+- Create: `backend/src/services/device-service.ts`
+
+**Implementation notes:**
+- device upsert
+- ๊ณ์ ๋น ๊ธฐ๊ธฐ 2๋ ์ ์ฑ
์ ์ฉ
+- access token + refresh token ๋ฐ๊ธ
+- entitlement payload ๋์ ๋ฐํ
+
+**Verify:**
+- ์ ์ code โ ์ธ์
๋ฐ๊ธ
+- ๋ง๋ฃ/์ฌ์ฉ ์๋ฃ code โ ์คํจ
+
+### Task 7: refresh / logout / me ๊ตฌํ
+**Objective:** ์ฑ ์ฌ์คํ ํ ์ธ์
๊ฐฑ์ ๊ณผ ๋ก๊ทธ์์์ด ๊ฐ๋ฅํ๊ฒ ํ๋ค.
+
+**Files:**
+- Modify: `backend/src/routes/auth.ts`
+- Create: `backend/src/routes/me.ts`
+- Create: `backend/src/middleware/auth.ts`
+
+**Implementation notes:**
+- refresh rotation ์ ์ฉ
+- logout ์ refresh session revoke
+- `GET /api/me`๋ ํ์ฌ user summary ๋ฐํ
+
+**Verify:**
+- refresh ํ ์ access token ๋ฐ๊ธ
+- logout ๋ค ๊ธฐ์กด refresh token ์ฌ์ฉ ๋ถ๊ฐ
+
+### Task 8: device ๊ด๋ฆฌ API ๊ตฌํ
+**Objective:** ์ฌ์ฉ์๊ฐ ์ฐ๊ฒฐ ๊ธฐ๊ธฐ๋ฅผ ํ์ธ/ํด์ ํ ์ ์๊ฒ ํ๋ค.
+
+**Files:**
+- Create: `backend/src/routes/devices.ts`
+- Modify: `backend/src/services/device-service.ts`
+
+**Verify:**
+- `GET /api/devices`
+- `POST /api/devices/revoke`
+
+### Task 9: API ๋ฌธ์/ํ๊ฒฝ๋ณ์ ๋ฌธ์ํ
+**Objective:** ํ๋ก ํธ/Electron์ด ๋ฐ๋ก ๋ถ์ ์ ์๋๋ก backend contract๋ฅผ ๊ณ ์ ํ๋ค.
+
+**Files:**
+- Create: `backend/README.md`
+- Modify: `docs/plans/2026-04-auth-billing-desktop-architecture.md`
+
+**Verify:**
+- ๊ตฌํ์ ์
์ฅ์์ API path์ env๋ฅผ ๋ฌธ์๋ง ๋ณด๊ณ ์ฌํ ๊ฐ๋ฅ
+
+---
+
+## Environment Variables
+
+```bash
+SUPABASE_URL=
+SUPABASE_ANON_KEY=
+SUPABASE_SERVICE_ROLE_KEY=
+JWT_SIGNING_SECRET=
+DESKTOP_REDIRECT_SCHEME=autoscreen
+APP_WEB_BASE_URL=https://app.autoscreen.io
+API_BASE_URL=https://api.autoscreen.io
+DEVICE_LIMIT=2
+```
+
+---
+
+## Acceptance Criteria
+- ์ด๋ฉ์ผ/Google ๋ก๊ทธ์ธ ์๋ฃ ์ฌ์ฉ์์ ๋ํด desktop login code ๋ฐ๊ธ ๊ฐ๋ฅ
+- Electron ์ฑ์ด code๋ฅผ ๊ตํํด ์ธ์
/entitlement ํ๋ ๊ฐ๋ฅ
+- ๊ตฌ๋
active ์ฌ์ฉ์๋ `mcp_editing` ๋ฑ Pro entitlement ์์
+- ๊ณ์ ๋น ๊ธฐ๊ธฐ 2๋ ์ ํ ์ ์ฉ
+- refresh/logout ๋์ ๊ฐ๋ฅ
+- backend๋ง์ด subscription ์ํ๋ฅผ ๊ธฐ์ค์ผ๋ก ์ผ์
diff --git a/docs/plans/billing-mvp.md b/docs/plans/billing-mvp.md
new file mode 100644
index 00000000..277ce906
--- /dev/null
+++ b/docs/plans/billing-mvp.md
@@ -0,0 +1,220 @@
+# Auto Screen Billing MVP Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
+
+**Goal:** Auto Screen์ ์ 15,900์ ๊ตฌ๋
์ ๋ก ํ๋งคํ ์ ์๋๋ก ๊ฒฐ์ , ๊ตฌ๋
์ํ, entitlement ๊ฐฑ์ ํ๋ฆ์ ์ค๊ณํ๋ค.
+
+**Architecture:** ๊ฒฐ์ ๋ ์ฑ ๋ด๋ถ๊ฐ ์๋๋ผ ๋๋ฉํ์ด์ง/์น ๊ฒฐ์ ํ๋ฉด์์ ์งํํ๋ค. backend๋ Toss Payments billing key์ webhook์ ๊ธฐ์ค์ผ๋ก ๊ตฌ๋
์ํ๋ฅผ ๋จ์ผ ์ง์ค ๊ณต๊ธ์์ผ๋ก ์ ์งํ๊ณ , Electron ์ฑ์ entitlement refresh๋ง ์ํํ๋ค.
+
+**Tech Stack:** Toss Payments, backend API, Supabase Postgres, Electron external browser flow.
+
+---
+
+## Product Decision
+- ํ๋: `Pro Monthly`
+- ๊ฐ๊ฒฉ: **์ 15,900์**
+- ํตํ: KRW
+- ์ด๊ธฐ ํ๋ ์: 1๊ฐ
+- ๊ตฌ๋งค ์์น: ๋๋ฉํ์ด์ง + ์ฑ ๋ด๋ถ ์
๊ทธ๋ ์ด๋ ๋ฒํผ
+- ๊ฒฐ์ ์๋ฃ ํ์ : **webhook ๊ธฐ์ค**
+
+---
+
+## Purchase Funnel
+1. ์ฌ์ฉ์๊ฐ ๋๋ฉํ์ด์ง ๋ฐฉ๋ฌธ
+2. ๊ฐ๊ฒฉํ์์ Pro ๊ธฐ๋ฅ ํ์ธ
+3. `๋ค์ด๋ก๋` ๋๋ `์ง๊ธ ์์` ํด๋ฆญ
+4. ์ฑ ์ค์น ํ ๋ก๊ทธ์ธ/ํ์๊ฐ์
+5. Free ์ํ๋ฉด ์ฑ ๋ด๋ถ `์
๊ทธ๋ ์ด๋` CTA ๋
ธ์ถ
+6. ๋๋ ์น ๊ฐ๊ฒฉ ํ์ด์ง์์ ๋ฐ๋ก ๊ฒฐ์ ์์
+7. Toss ๊ฒฐ์ ์ฑ๊ณต
+8. backend webhook ์์
+9. subscription = `active`
+10. ์ฑ์ด entitlement refresh
+11. Pro ๊ธฐ๋ฅ ์ฆ์ ํด์
+
+---
+
+## Tables
+
+### `subscriptions`
+ํต์ฌ ํ๋:
+- `user_id`
+- `provider = toss`
+- `plan_code = pro_monthly_15900_krw`
+- `status`
+- `provider_customer_key`
+- `provider_billing_key`
+- `current_period_start`
+- `current_period_end`
+- `cancel_at`
+- `canceled_at`
+
+### `billing_events`
+- `id uuid primary key`
+- `provider text not null`
+- `event_type text not null`
+- `event_id text null`
+- `user_id uuid null`
+- `payload jsonb not null`
+- `created_at timestamptz not null default now()`
+
+์ด ํ
์ด๋ธ์ webhook ๋๋ฒ๊น
์ฉ์ผ๋ก ์ค์ํ๋ค.
+
+---
+
+## Subscription Status Model
+
+ํ์ฉ ์ํ:
+- `inactive`
+- `trialing`
+- `active`
+- `past_due`
+- `canceled`
+- `expired`
+- `refunded`
+
+### Entitlement mapping
+- `active` โ Pro entitlements ๋ถ์ฌ
+- `trialing` โ Pro entitlements ๋ถ์ฌ
+- `past_due` โ ์งง์ grace period ํ์ฉ ์ฌ๋ถ ์ ํ
+- `canceled` + ๊ธฐ๊ฐ ๋จ์ โ ๋ง๋ฃ์ผ๊น์ง ์ ์ง
+- `expired` โ Free ์ ํ
+- `refunded` โ ์ ์ฑ
์ ๋ฐ๋ผ ์ฆ์ ์ข
๋ฃ ๋๋ ์ข
๋ฃ ์์ ์ฒ๋ฆฌ
+
+MVP์์๋ ๋ณต์ก๋๋ฅผ ๋ฎ์ถ๋ ค๋ฉด:
+- `active`, `trialing`๋ง Pro
+- ๋๋จธ์ง๋ Free
+
+---
+
+## API Contract
+
+### `POST /api/billing/checkout/session`
+๋ก๊ทธ์ธํ ์ฌ์ฉ์์๊ฒ ๊ฒฐ์ ์์ URL ๋ฐํ.
+
+Request:
+```json
+{
+ "planCode": "pro_monthly_15900_krw",
+ "successUrl": "https://app.autoscreen.io/billing/success",
+ "cancelUrl": "https://app.autoscreen.io/billing/cancel"
+}
+```
+
+Response:
+```json
+{
+ "checkoutUrl": "https://..."
+}
+```
+
+### `POST /api/billing/webhooks/toss`
+Toss webhook ์์ endpoint.
+
+### `GET /api/billing/subscription`
+ํ์ฌ ์ฌ์ฉ์์ ๊ตฌ๋
์ํ ์กฐํ.
+
+Response:
+```json
+{
+ "plan": "pro_monthly_15900_krw",
+ "status": "active",
+ "currentPeriodEnd": "2026-05-09T00:00:00.000Z"
+}
+```
+
+### `POST /api/billing/refresh-entitlements`
+์ฑ์์ ์๋ ์๋ก๊ณ ์นจ ์ ์ฌ์ฉ.
+
+---
+
+## Webhook Rules
+- webhook ์๋ช
๊ฒ์ฆ ํ์
+- ๋์ผ ์ด๋ฒคํธ ์ค๋ณต ์์ ๋๋น idempotency ํ์
+- subscription ์ํ ์
๋ฐ์ดํธ๋ webhook๋ง ์ํ
+- ํ๋ก ํธ redirect success ํ๋ฉด๋ง์ผ๋ก ํ์ฑํ ์ฒ๋ฆฌ ๊ธ์ง
+
+### ์ ์ฅํด์ผ ํ๋ ๊ฒ
+- raw payload
+- event type
+- received time
+- ์ฒ๋ฆฌ ๊ฒฐ๊ณผ
+
+---
+
+## App Flow
+
+### ์ฑ ๋ด๋ถ ์
๊ทธ๋ ์ด๋ ๋ฒํผ
+- renderer๊ฐ `openExternalUrl(pricing or checkout URL)` ํธ์ถ
+- ๊ฒฐ์ ๋ ๋ธ๋ผ์ฐ์ ์์ ์ํ
+- ์ฑ๊ณต ํ ์ฑ์ ๋ค์ ์ค ํ๋:
+ 1. `๊ตฌ๋
์๋ก๊ณ ์นจ` ๋ฒํผ
+ 2. ์ฃผ๊ธฐ์ polling
+ 3. `autoscreen://billing/success` deep link
+
+MVP ๊ถ์ฅ:
+- **์น ๊ฒฐ์ ์๋ฃ โ ์ฑ์์ `๊ตฌ๋
์๋ก๊ณ ์นจ` ๋ฒํผ**
+- deep link billing success๋ 2์ฐจ๋ก ์ถ๊ฐ ๊ฐ๋ฅ
+
+---
+
+## Landing Page Pricing Copy (MVP)
+
+### Free
+- ๊ธฐ๋ณธ ํธ์ง ๊ธฐ๋ฅ
+- ์ ํ๋ export
+- ๋ก๊ทธ์ธ ํ ์ฌ์ฉ ๊ฐ๋ฅ
+
+### Pro โ ์ 15,900์
+- ๊ณ ๊ธ ํธ์ง ๊ธฐ๋ฅ
+- MCP/AI ํธ์ง ๊ธฐ๋ฅ
+- ๊ณ ๊ธ export
+- ํฅํ ํ
ํ๋ฆฟ/ํด๋ผ์ฐ๋ ๊ธฐ๋ฅ ์ฐ์ ์ ๊ณต
+
+์ฃผ์:
+- ์์ง ์๋ ๊ธฐ๋ฅ์ ๊ณผ์ฅ ๊ธ์ง
+- ํ์ฌ ๋๋ ๊ธฐ๋ฅ ์์ฃผ๋ก ๋ฌธ๊ตฌ ์์ฑ
+
+---
+
+## Task Breakdown
+
+### Task 1: billing schema ํ์
+**Files:**
+- Create: `backend/src/db/sql/006_billing_events.sql`
+- Modify: `backend/src/db/sql/003_subscriptions.sql`
+
+### Task 2: checkout session endpoint ์ถ๊ฐ
+**Files:**
+- Create: `backend/src/routes/billing.ts`
+- Create: `backend/src/services/subscription-service.ts`
+
+### Task 3: Toss webhook ์ฒ๋ฆฌ ์ถ๊ฐ
+**Files:**
+- Modify: `backend/src/routes/billing.ts`
+- Modify: `backend/src/services/subscription-service.ts`
+
+### Task 4: entitlement refresh endpoint ์ฐ๊ฒฐ
+**Files:**
+- Modify: `backend/src/routes/entitlements.ts`
+- Modify: `backend/src/services/entitlement-service.ts`
+
+### Task 5: ์ฑ ๋ด๋ถ upgrade CTA ์ฐ๊ฒฐ
+**Files:**
+- Modify: `src/components/video-editor/VideoEditor.tsx`
+- Modify: `src/features/auth/AuthUpgradeBanner.tsx`
+
+### Task 6: pricing page / landing ์ฐ๋
+**Files:**
+- Future web app files
+- Doc first in `docs/plans/landing-page-funnel.md`
+
+---
+
+## Acceptance Criteria
+- ์ 15,900์ ๋จ์ผ ๊ตฌ๋
ํ๋์ด ๋ฌธ์/DB/API์ ์ผ๊ด๋๊ฒ ๋ฐ์๋๋ค
+- checkout์ ์น์์ ์์๋๋ค
+- webhook๋ง์ด ๊ตฌ๋
์ํ๋ฅผ active๋ก ๋ฐ๊พผ๋ค
+- ์ฑ์ entitlement refresh๋ก Pro ์ํ๋ฅผ ๋ฐ์ํ๋ค
+- Free/Pro ๋ฌธ๊ตฌ๊ฐ ๋๋ฉํ์ด์ง์ ์ฑ์์ ์ผ์นํ๋ค
diff --git a/docs/plans/electron-auth-flow.md b/docs/plans/electron-auth-flow.md
new file mode 100644
index 00000000..453f775f
--- /dev/null
+++ b/docs/plans/electron-auth-flow.md
@@ -0,0 +1,302 @@
+# Auto Screen Electron Auth Flow Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
+
+**Goal:** Auto Screen ๋ฐ์คํฌํฑ ์ฑ์ด ์ฒซ ์คํ ์ ๋ก๊ทธ์ธ/ํ์๊ฐ์
์ง์
ํ๋ฉด์ ๋ณด์ฌ์ฃผ๊ณ , ์ธ๋ถ ๋ธ๋ผ์ฐ์ + deep link ๋ฐฉ์์ผ๋ก ์์ ํ๊ฒ ๊ณ์ ์ฐ๋๋๋๋ก ๋ง๋ ๋ค.
+
+**Architecture:** Electron main process๊ฐ custom protocol(`autoscreen://`) deep link๋ฅผ ๋ฑ๋กํ๊ณ , renderer๋ ์ธ์ฆ ์ํ์ ๋ฐ๋ผ `auth gate`์ ์ค์ editor/app shell์ ๋ถ๊ธฐ ๋ ๋๋งํ๋ค. ๋ก๊ทธ์ธ/ํ์๊ฐ์
๊ณผ ๊ฒฐ์ ๋ ์น์์ ์ฒ๋ฆฌํ๊ณ , ์ฑ์ backend API๋ฅผ ํตํด desktop session๊ณผ entitlements๋ฅผ ๋ฐ๋๋ค.
+
+**Tech Stack:** Electron, React, TypeScript, existing `openExternalUrl` IPC, custom protocol deep link, OS secure storage.
+
+---
+
+## Product Flow (ํ์ )
+1. ์ฌ์ฉ์๊ฐ ๋๋ฉํ์ด์ง ๋ฐฉ๋ฌธ
+2. Mac / Windows / Linux ์ค ํ๋ ๋ค์ด๋ก๋
+3. ์ฑ ์ค์น ํ ์ฒซ ์คํ
+4. **์ฑ ์ฒซ ํ๋ฉด์ ๋ก๊ทธ์ธ/ํ์๊ฐ์
ํ๋ฉด**
+5. ์ฌ์ฉ์๊ฐ `๋ก๊ทธ์ธ`, `ํ์๊ฐ์
`, `๋์ค์ ๋๋ฌ๋ณด๊ธฐ(์ ํ)` ์ค ํ๋ ์ ํ
+6. ๋ก๊ทธ์ธ/ํ์๊ฐ์
์ ๊ธฐ๋ณธ ๋ธ๋ผ์ฐ์ ์์ ์งํ
+7. ์๋ฃ ํ `autoscreen://auth/callback?...` ๋ก ์ฑ ๋ณต๊ท
+8. ์ฑ์ด backend์์ ์ธ์
/entitlement ์์
+9. Free ๋๋ Pro ์ํ์ ๋ง๊ฒ ๊ธฐ๋ฅ ๋
ธ์ถ
+10. ์
๊ทธ๋ ์ด๋๋ ๋ค์ ์น ๊ฒฐ์ ๋ก ์ฐ๊ฒฐ
+
+---
+
+## UX Decision
+
+### ์ฒซ ํ๋ฉด
+์ฑ ์ฒซ ์คํ ์ ๊ธฐ๋ณธ ํ๋ฉด์ editor๊ฐ ์๋๋ผ **Auth Gate Screen** ์ด๋ค.
+
+### Auth Gate Screen ๊ตฌ์ฑ
+- ๋ก๊ณ
+- ์งง์ ์ ํ ์ค๋ช
+- `Google๋ก ๊ณ์ํ๊ธฐ`
+- `์ด๋ฉ์ผ๋ก ํ์๊ฐ์
`
+- `๋ก๊ทธ์ธ`
+- `๋์ค์ ๋๋ฌ๋ณด๊ธฐ`(์ ํ)
+- `Pro ์ 15,900์` ๋งํฌ ๋๋ ์์ ๋ฐฐ์ง
+- `์๊ธ์ ๋ณด๊ธฐ` ๋ฒํผ
+
+### ๋์ค์ ๋๋ฌ๋ณด๊ธฐ ํ์ฉ ์ฌ๋ถ
+๊ถ์ฅ:
+- **ํ์ฉ์ ํ๋ ์ ํ ๋ชจ๋(guest/free preview)** ๋ก ์ง์
+- export, cloud, MCP, ๊ณ ๊ธ ๊ธฐ๋ฅ์ ์ ๊ธ
+- ์ค์ ์ ์ฅ/๋๊ธฐํ/๋ณต๊ตฌ๋ ๋ก๊ทธ์ธ ์ ๋
+
+์ด์ :
+- ์ค์น ์งํ ์์ ์ฐจ๋จ๋ณด๋ค ์ ํ์จ์ด ๋์ ์ ์์
+- ํ์ง๋ง ํต์ฌ ๊ธฐ๋ฅ unlock์ ๊ณ์ ๊ธฐ์ค ์ ์ง ๊ฐ๋ฅ
+
+---
+
+## State Model
+
+### Renderer auth states
+```ts
+type AuthState =
+ | { status: "booting" }
+ | { status: "signed_out" }
+ | { status: "pending_browser_auth" }
+ | { status: "signed_in"; user: UserSummary; entitlements: string[] }
+ | { status: "guest" }
+ | { status: "session_expired" };
+```
+
+### Session payload
+```ts
+interface DesktopSessionPayload {
+ accessToken: string;
+ refreshToken: string;
+ expiresIn: number;
+ user: {
+ id: string;
+ email: string;
+ displayName?: string | null;
+ };
+ subscription: {
+ plan: string;
+ status: string;
+ };
+ entitlements: string[];
+}
+```
+
+---
+
+## Required Electron Changes
+
+### Main process
+**Files:**
+- Modify: `electron/main.ts`
+- Modify: `electron/preload.ts`
+- Modify: `electron/electron-env.d.ts`
+- Modify: `src/vite-env.d.ts`
+- Create: `electron/auth/protocol.ts`
+- Create: `electron/auth/sessionStore.ts`
+- Create: `electron/auth/client.ts`
+
+**Responsibilities:**
+- `autoscreen://` protocol ๋ฑ๋ก
+- macOS `open-url` ์ด๋ฒคํธ ์ฒ๋ฆฌ
+- Windows/Linux second-instance argv์์ deep link ์์
+- deep link์ `code` ํ์ฑ
+- backend `POST /api/auth/desktop/exchange` ํธ์ถ
+- ํ ํฐ์ secure storage์ ์ ์ฅ
+- renderer์ auth state broadcast
+
+### Renderer
+**Files:**
+- Create: `src/features/auth/AuthGate.tsx`
+- Create: `src/features/auth/AuthGate.module.css`
+- Create: `src/features/auth/useAuthSession.ts`
+- Create: `src/features/auth/authTypes.ts`
+- Create: `src/features/auth/AuthUpgradeBanner.tsx`
+- Modify: `src/App.tsx`
+- Modify: `src/components/video-editor/VideoEditor.tsx`
+
+**Responsibilities:**
+- ์ฑ ๋ถํ
์ auth session restore
+- signed_out ์ด๋ฉด Auth Gate ๋ ๋๋ง
+- signed_in ์ด๋ฉด editor/hud ๋ ๋๋ง
+- guest/free/pro ์ํ๋ณ CTA ๋ถ๊ธฐ
+
+---
+
+## Secure Storage Decision
+
+MVP ๊ถ์ฅ ์ ์ฅ ๋ฐฉ์:
+- macOS: Keychain (`safeStorage`๋ง์ผ๋ก ์ถฉ๋ถ์น ์์ผ๋ฉด `keytar` ๊ฒํ )
+- Windows: Credential Locker ๋๋ encrypted file fallback
+- Linux: Secret Service ์ฐ์ , ๋ถ๊ฐ ์ encrypted file fallback
+
+์ค๋ฌด์ ์ผ๋ก๋ 1์ฐจ ๊ตฌํ์์:
+1. refresh token๋ง ์ ์ฅ
+2. access token์ ๋ฉ๋ชจ๋ฆฌ ์ค์ฌ
+3. ์ฑ ์์ ์ refresh๋ก ์ฌ๋ฐ๊ธ
+
+---
+
+## Deep Link Handling Plan
+
+### macOS
+- `app.setAsDefaultProtocolClient("autoscreen")`
+- `app.on("open-url", ...)`
+
+### Windows/Linux
+- `app.requestSingleInstanceLock()`
+- ๋ ๋ฒ์งธ ์คํ ์ธ์์ `autoscreen://...` ํ์ฑ
+- ์ด๋ฏธ ์ด๋ฆฐ ์ฐฝ์ผ๋ก ์ด๋ฒคํธ ์ ๋ฌ
+
+### Deep link format
+```text
+autoscreen://auth/callback?code=ONE_TIME_CODE
+```
+
+์ถ๊ฐ ๊ฐ๋ฅ:
+```text
+autoscreen://billing/success
+autoscreen://billing/cancel
+```
+
+---
+
+## IPC Contract
+
+### Preload additions
+```ts
+openLoginUrl(url: string): Promise<{ success: boolean; error?: string }>;
+onAuthCallback(callback: (payload: { code: string }) => void): () => void;
+getStoredSession(): Promise;
+clearStoredSession(): Promise<{ success: boolean }>;
+refreshDesktopSession(): Promise;
+logoutDesktopSession(): Promise<{ success: boolean }>;
+```
+
+---
+
+## Screen Flow
+
+### 1. Cold start
+- app boot
+- ์ ์ฅ๋ refresh token ํ์ธ
+- ์์ผ๋ฉด `/api/auth/refresh` ํธ์ถ
+- ์ฑ๊ณตํ๋ฉด signed_in
+- ์คํจํ๋ฉด signed_out
+
+### 2. Signed out
+ํ๋ฉด:
+- ์ ํ ๋ก๊ณ
+- ํต์ฌ ๊ฐ์น 2~3์ค
+- `Google๋ก ์์`
+- `์ด๋ฉ์ผ ํ์๊ฐ์
`
+- `๋ก๊ทธ์ธ`
+- `์๊ธ์ ๋ณด๊ธฐ`
+- `๋์ค์ ๋๋ฌ๋ณด๊ธฐ`
+
+### 3. Browser auth in progress
+- spinner
+- `๋ธ๋ผ์ฐ์ ์์ ๋ก๊ทธ์ธ ์ค์
๋๋ค...`
+- `๋ธ๋ผ์ฐ์ ๊ฐ ์ด๋ฆฌ์ง ์์ผ๋ฉด ๋ค์ ์๋`
+
+### 4. Signed in free
+- editor ์ฌ์ฉ ๊ฐ๋ฅ
+- Pro ๊ธฐ๋ฅ ์์ญ์ lock badge
+- ์๋จ/์ค์ ์์ญ์ `Pro ์
๊ทธ๋ ์ด๋`
+
+### 5. Signed in pro
+- ์ ์ฒด ๊ธฐ๋ฅ ํ์ฑํ
+
+---
+
+## Task Breakdown
+
+### Task 1: auth domain ํ์
์ ์
+**Objective:** renderer/main ๊ณต์ฉ auth ํ์
์ ๋จผ์ ๊ณ ์ ํ๋ค.
+
+**Files:**
+- Create: `src/features/auth/authTypes.ts`
+- Modify: `electron/electron-env.d.ts`
+- Modify: `src/vite-env.d.ts`
+
+### Task 2: main process protocol ๋ฑ๋ก ์ถ๊ฐ
+**Objective:** ์ฑ์ด `autoscreen://` deep link๋ฅผ ๋ฐ์ ์ ์๊ฒ ํ๋ค.
+
+**Files:**
+- Create: `electron/auth/protocol.ts`
+- Modify: `electron/main.ts`
+
+**Implementation notes:**
+- ์ฑ ์์ ์ด๊ธฐ์ protocol ๋ฑ๋ก
+- single instance lock ์ถ๊ฐ
+- mac/win/linux ๋ถ๊ธฐ ์ฒ๋ฆฌ
+
+### Task 3: session store ์ถ๊ฐ
+**Objective:** refresh token๊ณผ user summary๋ฅผ OS secure storage์ ์ ์ฅํ๋ค.
+
+**Files:**
+- Create: `electron/auth/sessionStore.ts`
+- Modify: `electron/preload.ts`
+- Modify: `electron/electron-env.d.ts`
+
+### Task 4: backend auth client ์ถ๊ฐ
+**Objective:** main process์์ desktop exchange/refresh/logout API ํธ์ถ ๊ณ์ธต์ ๋ง๋ ๋ค.
+
+**Files:**
+- Create: `electron/auth/client.ts`
+- Modify: `electron/preload.ts`
+
+### Task 5: auth gate ํ๋ฉด ์ถ๊ฐ
+**Objective:** ์ฑ ์ฒซ ํ๋ฉด์ ๋ก๊ทธ์ธ/ํ์๊ฐ์
์ง์
ํ๋ฉด์ผ๋ก ๋ง๋ ๋ค.
+
+**Files:**
+- Create: `src/features/auth/AuthGate.tsx`
+- Create: `src/features/auth/AuthGate.module.css`
+- Modify: `src/App.tsx`
+
+**Implementation notes:**
+- signed_out์์๋ง ๋
ธ์ถ
+- ๋ฒํผ ํด๋ฆญ ์ ์น URL openExternal
+- guest ์ง์
ํ์ฉ ์ฌ๋ถ๋ feature flag๋ก ๋ ์ ์์
+
+### Task 6: ์ฑ ์ ์ฒด auth session hook ์ฐ๊ฒฐ
+**Objective:** signed_out / signed_in / guest ์ํ์ ๋ฐ๋ผ ์ฑ shell์ ๋ถ๊ธฐํ๋ค.
+
+**Files:**
+- Create: `src/features/auth/useAuthSession.ts`
+- Modify: `src/App.tsx`
+- Modify: `src/components/video-editor/VideoEditor.tsx`
+
+### Task 7: deep link callback ์ฒ๋ฆฌ ์๋ฃ
+**Objective:** ๋ธ๋ผ์ฐ์ ๋ก๊ทธ์ธ ์๋ฃ ํ ์ฑ์ด ์ค์ ๋ก signed_in ์ํ๊ฐ ๋๊ฒ ํ๋ค.
+
+**Files:**
+- Modify: `electron/main.ts`
+- Modify: `electron/preload.ts`
+- Modify: `src/features/auth/useAuthSession.ts`
+
+### Task 8: upgrade / pricing CTA ์ฐ๊ฒฐ
+**Objective:** Free ์ฌ์ฉ์๊ฐ ์ฑ ์์์ ์๊ธ์ /๊ฒฐ์ ๋ก ์์ฐ์ค๋ฝ๊ฒ ์ด๋ํ๊ฒ ํ๋ค.
+
+**Files:**
+- Create: `src/features/auth/AuthUpgradeBanner.tsx`
+- Modify: `src/components/video-editor/VideoEditor.tsx`
+
+### Task 9: session expiry / logout UX ์ถ๊ฐ
+**Objective:** ์ธ์
๋ง๋ฃ์ ๋ก๊ทธ์์ ์ UX๊ฐ ๊นจ์ง์ง ์๊ฒ ํ๋ค.
+
+**Files:**
+- Modify: `src/features/auth/useAuthSession.ts`
+- Modify: `src/features/auth/AuthGate.tsx`
+
+---
+
+## Acceptance Criteria
+- ์ฑ ์ฒซ ์คํ ์ ๋ก๊ทธ์ธ/ํ์๊ฐ์
ํ๋ฉด์ด ๋จผ์ ๋ณด์ธ๋ค
+- ๋ก๊ทธ์ธ/ํ์๊ฐ์
์ ์น ๋ธ๋ผ์ฐ์ ์์ ์ด๋ฆฐ๋ค
+- ๋ก๊ทธ์ธ ์๋ฃ ํ ์ฑ์ด deep link๋ก ๋ณต๊ทํ๋ค
+- ์ฑ์ด desktop session/entitlements๋ฅผ ์ ์ฅํ๋ค
+- Free/Pro์ ๋ฐ๋ผ UI๊ฐ ๋ค๋ฅด๊ฒ ๋ณด์ธ๋ค
+- ๋ก๊ทธ์์ ํ ๋ค์ Auth Gate๋ก ๋ณต๊ทํ๋ค
+- guest/free ๋ชจ๋๊ฐ ์๋๋ผ๋ Pro ๊ธฐ๋ฅ์ ์ ๊ฒจ ์๋ค
diff --git a/docs/plans/landing-page-funnel.md b/docs/plans/landing-page-funnel.md
new file mode 100644
index 00000000..019f5b40
--- /dev/null
+++ b/docs/plans/landing-page-funnel.md
@@ -0,0 +1,133 @@
+# Auto Screen Landing Page + App Conversion Funnel Plan
+
+> **For Hermes:** ์ด ๋ฌธ์๋ ๋๋ฉํ์ด์ง, ๋ค์ด๋ก๋, ์ฑ ์ฒซ ์คํ, ๋ก๊ทธ์ธ, ๊ฒฐ์ , ์ฌ์ฉ ์์๊น์ง์ ์ ์ฒด ์ ํ ํผ๋ ๊ธฐ์ค์์ด๋ค.
+
+**Goal:** ์ฌ์ฉ์๊ฐ ์น ๋๋ฉํ์ด์ง์์ ์ ํ์ ์ดํดํ๊ณ , OS๋ณ ์ฑ์ ๋ค์ด๋ก๋ํ๊ณ , ๋ก๊ทธ์ธ/ํ์๊ฐ์
ํ Free ๋๋ Pro๋ก ์์ฐ์ค๋ฝ๊ฒ ์ง์
ํ๋๋ก ํ๋ค.
+
+**Architecture:** ํผ๋์ ์ค์ฌ์ ์น ๋๋ฉํ์ด์ง๋ค. ๋๋ฉํ์ด์ง๋ ์ ํ ์ค๋ช
, ๊ธฐ๋ฅ, ๊ฐ๊ฒฉ, ์ฌ์ฉ ๋ฐฉ๋ฒ, ๋ค์ด๋ก๋๋ฅผ ๋ด๋นํ๊ณ , ๋ฐ์คํฌํฑ ์ฑ์ ์ค์น ํ ๋ก๊ทธ์ธ/ํ์๊ฐ์
๊ณผ ์ค์ ์ฌ์ฉ์ ๋ด๋นํ๋ค.
+
+---
+
+## ์ต์ข
์ฌ์ฉ์ ํ๋ฆ
+1. ๋๋ฉํ์ด์ง ์ ์
+2. ์ ํ ์ค๋ช
ํ์ธ
+3. ๊ฐ๊ฒฉ/๊ธฐ๋ฅ ๋น๊ต ํ์ธ
+4. Mac / Windows / Linux ๋ค์ด๋ก๋ ๋ฒํผ ํด๋ฆญ
+5. ์ฑ ์ค์น ๋ฐ ์คํ
+6. **์ฒซ ํ๋ฉด: ๋ก๊ทธ์ธ / ํ์๊ฐ์
**
+7. ๊ณ์ ์์ฑ ๋๋ ๋ก๊ทธ์ธ
+8. Free ๋๋ Pro ์ํ ํ์ธ
+9. ํ๋ก์ ํธ ์์ฑ/ํธ์ง ์์
+10. ํ์ ์ ์
๊ทธ๋ ์ด๋
+
+---
+
+## ๋๋ฉํ์ด์ง ์น์
๊ตฌ์กฐ
+
+### 1. Hero
+- ์ ํ๋ช
: Auto Screen
+- ํ ์ค ๊ฐ์น ์ ์
+- CTA:
+ - `Mac ๋ค์ด๋ก๋`
+ - `Windows ๋ค์ด๋ก๋`
+ - `Linux ๋ค์ด๋ก๋`
+ - `๊ฐ๊ฒฉ ๋ณด๊ธฐ`
+
+### 2. ์ฃผ์ ๊ธฐ๋ฅ
+- ํ๋ฉด ๋
นํ
+- ์ผ๋ฐ ์์ ํธ์ง
+- AI/MCP ๊ธฐ๋ฐ ํธ์ง ํ์ฅ
+- ์ค์๊ฐ ํธ์ง ๋ฐ์
+
+### 3. ๊ฐ๊ฒฉ ์น์
+- Free
+- Pro ์ 15,900์
+- ํฌํจ ๊ธฐ๋ฅ ๋น๊ต
+- `์ง๊ธ ์์` CTA
+
+### 4. ์ฌ์ฉ ๋ฐฉ๋ฒ ์น์
+์์ ๋จ๊ณ:
+1. ์ฑ ๋ค์ด๋ก๋
+2. ์ฑ ์ค์น ํ ์คํ
+3. ๋ก๊ทธ์ธ/ํ์๊ฐ์
+4. ์์ ๊ฐ์ ธ์ค๊ธฐ ๋๋ ๋
นํ ์์
+5. ํธ์ง
+6. ํ์ ์ Pro ์
๊ทธ๋ ์ด๋
+7. export
+
+### 5. FAQ
+- ๋ก๊ทธ์ธ ์์ด ์ธ ์ ์๋์?
+- Pro๋ ์ด๋ค ๊ธฐ๋ฅ์ด ์ด๋ฆฌ๋์?
+- ์ด๋ค OS๋ฅผ ์ง์ํ๋์?
+- ๊ตฌ๋
ํด์ง๋ ์ด๋ป๊ฒ ํ๋์?
+
+### 6. Footer
+- ์ ์ฑ
๋งํฌ
+- ๋ฌธ์
+- ๋ค์ด๋ก๋ ๋งํฌ
+
+---
+
+## ์ฑ ์ฒซ ์คํ ๊ธฐ์ค
+
+### ํ์
+์ฑ์ ์ฒ์ ์ด์์ ๋๋ editor๊ฐ ์๋๋ผ **๋ก๊ทธ์ธ/ํ์๊ฐ์
ํ๋ฉด**์ด ๋จผ์ ๋ณด์ฌ์ผ ํ๋ค.
+
+์ด์ :
+- ๊ณ์ ๊ธฐ๋ฐ entitlement ๊ตฌ์กฐ์ ๋ง์
+- ๊ฒฐ์ /๋ณต๊ตฌ/๊ธฐ๊ธฐ์ ํ/๊ตฌ๋
ํ์ ์ด ์ฌ์
+- ํฅํ ํด๋ผ์ฐ๋ ๊ธฐ๋ฅ ํ์ฅ์ ์ ๋ฆฌ
+- ๋๋ฉํ์ด์ง์ ์ฑ ํผ๋์ด ์ผ๊ด๋จ
+
+### ์์ธ
+- `๋์ค์ ๋๋ฌ๋ณด๊ธฐ` guest ๋ชจ๋ ํ์ฉ ๊ฐ๋ฅ
+- ํ์ง๋ง ํต์ฌ ์ ๋ฃ ๊ธฐ๋ฅ์ ์ ๊ธ ์ ์ง
+
+---
+
+## ๋ฒํผ ๊ตฌ์กฐ
+
+### ๋๋ฉํ์ด์ง ์๋จ ๋ฒํผ
+- `Mac ๋ค์ด๋ก๋`
+- `Windows ๋ค์ด๋ก๋`
+- `Linux ๋ค์ด๋ก๋`
+- `๊ฐ๊ฒฉ ๋ณด๊ธฐ`
+
+### ์ฑ ๋ก๊ทธ์ธ ํ๋ฉด ๋ฒํผ
+- `Google๋ก ๊ณ์ํ๊ธฐ`
+- `์ด๋ฉ์ผ๋ก ํ์๊ฐ์
`
+- `๋ก๊ทธ์ธ`
+- `์๊ธ์ ๋ณด๊ธฐ`
+- `๋์ค์ ๋๋ฌ๋ณด๊ธฐ`(์ ํ)
+
+### ์ฑ ๋ด๋ถ ๋ฒํผ
+- `Pro ์
๊ทธ๋ ์ด๋`
+- `๊ตฌ๋
์๋ก๊ณ ์นจ`
+- `๋ก๊ทธ์์`
+
+---
+
+## Copy Principle
+- ๋๋ฉํ์ด์ง๋ ํ๋งค/์ดํด ์ค์ฌ
+- ์ฑ ์ฒซ ํ๋ฉด์ ์ง์
/์ ํ ์ค์ฌ
+- ๊ธฐ๋ฅ ์ค๋ช
์ ๊ณผ์ฅ ์์ด ํ์ฌ ๊ธฐ๋ฅ ์์ฃผ
+- ์์ง ๋ฏธ๊ตฌํ ๊ธฐ๋ฅ์ `๊ณง ์ ๊ณต` ๋๋ `์์ `์ผ๋ก ํ์
+
+---
+
+## Download Delivery Model
+- ์น์์ OS ๊ฐ์ง ํ ํด๋น ๋ค์ด๋ก๋ ๋ฒํผ ๊ฐ์กฐ ๊ฐ๋ฅ
+- ํ์ง๋ง ํญ์ 3๊ฐ ๋ฒํผ ๋ชจ๋ ๋
ธ์ถ
+- ํ์ผ๋ช
์์:
+ - `Auto-Screen-mac.dmg`
+ - `Auto-Screen-win.exe`
+ - `Auto-Screen-linux.AppImage`
+
+---
+
+## Acceptance Criteria
+- ๋๋ฉํ์ด์ง์ OS๋ณ ๋ค์ด๋ก๋ ๋ฒํผ 3๊ฐ๊ฐ ์๋ค
+- ๊ฐ๊ฒฉ ์น์
์ ์ 15,900์ Pro ํ๋์ด ๋ช
ํํ ๋ณด์ธ๋ค
+- ์ฌ์ฉ ๋ฐฉ๋ฒ ์น์
์ด ํ๋จ์ ์๋ค
+- ์ฑ ์ฒซ ํ๋ฉด์ ๋ก๊ทธ์ธ/ํ์๊ฐ์
ํ๋ฉด์ด๋ค
+- ๋๋ฉํ์ด์ง์ ์ฑ์ ๊ฐ๊ฒฉ/๊ธฐ๋ฅ ๋ฌธ๊ตฌ๊ฐ ์ผ์นํ๋ค
diff --git a/docs/plans/signup-abuse-policy.md b/docs/plans/signup-abuse-policy.md
new file mode 100644
index 00000000..6344a0d9
--- /dev/null
+++ b/docs/plans/signup-abuse-policy.md
@@ -0,0 +1,60 @@
+# Auto Screen ๊ฐ์
๋ฐ ๋ฌด๋ฃ ํ๋ ์
์ฉ ๋ฐฉ์ง ์ ์ฑ
+
+## ๊ฐ์
ํ์ ํญ๋ชฉ
+- ์์ด๋
+- ๋น๋ฐ๋ฒํธ
+- ์ฑ
+- ์ด๋ฆ
+- ์ด๋ฉ์ผ
+- ํด๋ํฐ ๋ฒํธ
+- ๋ฌธ์ ์ธ์ฆ ์ฝ๋
+- ํ์ ์ฝ๊ด ๋์
+
+## ๊ฐ์
๋จ๊ณ
+1. ์์ด๋ ์
๋ ฅ
+2. ์ฑ / ์ด๋ฆ ์
๋ ฅ
+3. ์ด๋ฉ์ผ ์
๋ ฅ
+4. ํด๋ํฐ ๋ฒํธ ์
๋ ฅ
+5. ๋ฌธ์ ์ธ์ฆ ์์ฒญ
+6. ์ธ์ฆ ์ฝ๋ ํ์ธ
+7. ๋น๋ฐ๋ฒํธ ์
๋ ฅ
+8. ์ฝ๊ด ๋์
+9. ๊ณ์ ์์ฑ
+
+## ๋ฌด๋ฃ ํ๋ ์ง๊ธ ๊ธฐ์ค
+- ํด๋ํฐ ์ธ์ฆ ์๋ฃ ๊ณ์ ๋ง ๋ฌด๋ฃ ํ๋ ์์ ๊ฐ๋ฅ
+- ๊ธฐ๋ณธ๊ฐ์ ํด๋ํฐ ๋ฒํธ๋น 1ํ
+- ๋๋ฐ์ด์ค ์๋ณ์๋น 1ํ ์ ํ์ ์ถ๊ฐ ๊ถ์ฅ
+- ๊ฐ์ IP ๋์ญ ๋ฐ๋ณต ๊ฐ์
์ ๋ณ๋ ๋ชจ๋ํฐ๋ง ํ์
+
+## ์๋ฒ ์ ์ฅ ๊ถ์ฅ ํญ๋ชฉ
+- user_id
+- username
+- email
+- family_name
+- given_name
+- phone_number
+- phone_verified_at
+- accepted_terms_at
+- accepted_privacy_at
+- accepted_marketing_at
+- signup_ip
+- signup_device_id
+- first_free_trial_granted_at
+- free_trial_grant_count
+
+## ๋ฌธ์ ์ธ์ฆ ์ด์ ๋ฉ๋ชจ
+- ๊ตญ๋ด ํ๋งค ๊ธฐ์ค Solapi ์ฐ์ ๊ฒํ
+- ์ธ์ฆ๋ฒํธ๋ 3๋ถ ๋ง๋ฃ ๊ถ์ฅ
+- ์ฌ์์ฒญ 30์ด ์ ํ
+- ์ธ์ฆ ์คํจ 5ํ ์ด๊ณผ ์ ์ฌ๋ฐ์ก ํ์
+- ์ธ์ฆ ์๋ฃ ํ ํฐ์ ์งง์ TTL๋ก ์๋ฒ ์ ์ฅ
+
+## ์ง๊ธ ๊ตฌํ ์ํ
+- ๋ฐ์คํฌํฑ ์ฑ ๋ด๋ถ ํ์๊ฐ์
ํผ ํ์ฅ ์๋ฃ
+- ๋ก์ปฌ ๋ฌธ์ ์ธ์ฆ ์์ฒญ/ํ์ธ ์ค์ผ๋ ํค ์๋ฃ
+- ๋ฐฑ์๋ phone request/verify ์ค์ผ๋ ํค ์ถ๊ฐ ์๋ฃ
+- ๋๋ฐ์ด์ค ์๋ณ์ ์์ฑ ๋ฐ ๊ฐ์
payload ์ฐ๊ฒฐ ์๋ฃ
+- ์๋ฒํ signup/login API ์ค์ผ๋ ํค ์ถ๊ฐ ์๋ฃ
+- DB SQL ์ด์์ phone verification / signup audit / agreements / free trial grant ์ถ๊ฐ ์๋ฃ
+- ์ค์๋น์ค์ฉ SMS ๊ณต๊ธ์ ์ฐ๋์ ์์ง ๋ฏธ๊ตฌํ
diff --git a/docs/release-checklist.md b/docs/release-checklist.md
new file mode 100644
index 00000000..eee07657
--- /dev/null
+++ b/docs/release-checklist.md
@@ -0,0 +1,63 @@
+# Auto Screen release checklist
+
+## ๋ชฉํ ์ฐ์ถ๋ฌผ
+- macOS Apple Silicon: `Auto Screen--mac-arm64.dmg`
+- macOS Intel: `Auto Screen--mac-x64.dmg`
+- macOS updater metadata: `Auto Screen--mac-.zip`, `*.blockmap`, `latest-mac.yml`
+- Windows x64: `Auto Screen--win-x64.exe`
+- Windows updater metadata: `latest.yml`
+- Linux AppImage: `Auto Screen--linux-x64.AppImage`
+- Linux Debian/Ubuntu: `Auto Screen--linux-x64.deb`
+- Linux updater metadata: `latest-linux.yml`
+
+## ๊ถ์ฅ ๋น๋ ํธ์คํธ
+- macOS DMG: macOS
+- Windows EXE: Windows
+- Linux AppImage/DEB: macOS ๋๋ Linux
+
+## ํ์ฌ ๊ฒ์ฆ ์ํ
+- macOS x64 DMG: ๊ฒ์ฆ ์๋ฃ
+- macOS arm64 DMG: ๊ฒ์ฆ ์๋ฃ
+- Linux AppImage: ๊ฒ์ฆ ์๋ฃ
+- Linux DEB: ๊ฒ์ฆ ์๋ฃ
+- Windows EXE: ์ค์ ์๋ฃ, Windows ํธ์คํธ์์ ์ต์ข
๋น๋ ํ์
+
+## ๋น๋ ๋ช
๋ น
+```bash
+npm run build:mac:x64
+npm run build:mac:arm64
+npm run build:win:x64
+npm run build:linux:x64
+```
+
+## GitHub Actions ๋ฆด๋ฆฌ์ฆ ํ๋ก์ฐ
+- ์๋ ๊ฒ์ฆ ๋น๋: `Build Electron App` workflow๋ฅผ `workflow_dispatch`๋ก ์คํ
+- ์ ์ ๋ฆด๋ฆฌ์ฆ: `v*` ํ๊ทธ ํธ์ ์) `v1.3.0`
+- ํ๊ทธ ๋ฆด๋ฆฌ์ฆ ์ ํ์ธ: `package.json` ๋ฒ์ ๊ณผ ํ๊ทธ ๋ฒ์ ์ด ์ ํํ ์ผ์นํด์ผ ํจ
+
+์๋ ์คํ ์์๋ ๊ฐ OS ์ฐ์ถ๋ฌผ์ด workflow artifact๋ก ์
๋ก๋๋๊ณ , ํ๊ทธ ํธ์ ์์๋ GitHub Releases์ ์ค์น ํ์ผ๊ณผ ์
๋ฐ์ดํธ ๋ฉํ๋ฐ์ดํฐ๊ฐ ๊ฒ์๋๋ค.
+
+## ๋ฆด๋ฆฌ์ฆ ์ ๋ฆฌ
+๋น๋ ํ unpacked ๋๋ ํฐ๋ฆฌ์ ์ด์ ์ด๋ฆ ํ์ผ์ ์ ๊ฑฐํ๋ค.
+
+```bash
+npm run release:clean
+npm run release:artifacts
+```
+
+## ์
๋ก๋ ์ ํ์ธ
+- release// ์์ ์ต์ข
ํ์ผ๋ง ๋จ์ ์๋์ง ํ์ธ
+- macOS `.dmg` / `.zip` / `.blockmap` / `latest-mac.yml` ํ์ธ
+- Windows `.exe` / `latest.yml` ํ์ธ
+- Linux `.AppImage` / `.deb` / `latest-linux.yml` ํ์ธ
+- ํ
์คํธ ๊ณ์ /๊ฐ๋ฐ์ฉ ๋ฌธ๊ตฌ ๋
ธ์ถ ์๋์ง ํ์ธ
+- ๋ก๊ทธ์ธ/ํ์๊ฐ์
/๋ณต๊ตฌ ํ๋ก์ฐ ํ์ธ
+- MCP ์ค์ ํ์
์์ ํ ํฐ ๋ณต์ฌ/์ฌ๋ฐ๊ธ ๋์ ํ์ธ
+- macOS ๊ถํ ์๋ด ๋ฌธ๊ตฌ ํ์ธ
+- ๋ฐฐํฌ ์ฑ๋์ ์
๋ก๋ํ ํ์ผ๋ช
๊ณผ README ์ค๋ช
์ผ์น ํ์ธ
+
+## ์์ฉ ๋ฐฐํฌ ์ ๋จ์ ๊ฒ
+- Windows ์ฝ๋์ฌ์ธ ์ธ์ฆ์ ์ ์ฉ
+- macOS notarization ์ ์ฉ
+- GitHub Releases ๊ณต๊ฐ ์ํ ๋ฐ ์ฒจ๋ถ ํ์ผ ํ์ธ
+- ์๋ ์
๋ฐ์ดํธ ๋ฉํ๋ฐ์ดํฐ ์ฌ์ฉ ์ฌ๋ถ ์ต์ข
๊ฒฐ์
diff --git a/electron-builder.json5 b/electron-builder.json5
index 40fce0a4..ffbcfceb 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -1,64 +1,87 @@
-// @see - https://www.electron.build/configuration/configuration
-{
- "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
- "appId": "com.siddharthvaddem.openscreen",
- "asar": true,
- "productName": "Openscreen",
- "npmRebuild": true,
- "buildDependenciesFromSource": true,
- "compression": "normal",
- "directories": {
- "output": "release/${version}"
- },
- "files": [
- "dist",
- "dist-electron",
- "!*.png",
- "!preview*.png",
- "!*.md",
- "!README.md",
- "!CONTRIBUTING.md",
- "!LICENSE"
- ],
+// @see - https://www.electron.build/configuration/configuration
+{
+ "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
+ "appId": "com.autoscreen.desktop",
+ "asar": true,
+ "productName": "Auto Screen",
+ "npmRebuild": true,
+ "buildDependenciesFromSource": true,
+ "compression": "normal",
+ "directories": {
+ "output": "release/${version}"
+ },
+ "files": [
+ "dist",
+ "dist-electron",
+ "!*.png",
+ "!preview*.png",
+ "!*.md",
+ "!README.md",
+ "!CONTRIBUTING.md",
+ "!LICENSE"
+ ],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
- "publish": [{"provider": "github"}],
+ "publish": [
+ {
+ "provider": "github",
+ "owner": "siddharthvaddem",
+ "repo": "openscreen",
+ "releaseType": "release",
+ "tagNamePrefix": "v"
+ }
+ ],
"mac": {
- "hardenedRuntime": false,
- "target": [
- {
- "target": "dmg",
- "arch": ["x64", "arm64"]
- }
- ],
- "icon": "icons/icons/mac/icon.icns",
- "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
+ "hardenedRuntime": false,
+ "target": [
+ {
+ "target": "dmg",
+ "arch": ["x64", "arm64"]
+ },
+ {
+ "target": "zip",
+ "arch": ["x64", "arm64"]
+ }
+ ],
+ "icon": "icons/icons/mac/icon.icns",
+ "artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"extendInfo": {
- "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
- "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
- "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
+ "NSAudioCaptureUsageDescription": "Auto Screen needs audio capture permission to record system audio.",
+ "NSMicrophoneUsageDescription": "Auto Screen needs microphone access to record voice audio.",
+ "NSCameraUsageDescription": "Auto Screen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
- },
- "linux": {
- "target": [
- "AppImage"
- ],
- "icon": "icons/icons/png",
- "artifactName": "${productName}-Linux-${version}.${ext}",
- "category": "AudioVideo"
- },
+ },
+ "linux": {
+ "target": [
+ {
+ "target": "AppImage",
+ "arch": ["x64"]
+ },
+ {
+ "target": "deb",
+ "arch": ["x64"]
+ }
+ ],
+ "icon": "icons/icons/png",
+ "artifactName": "${productName}-${version}-linux-x64.${ext}",
+ "category": "AudioVideo"
+ },
"win": {
"target": [
- "nsis"
+ {
+ "target": "nsis",
+ "arch": ["x64"]
+ }
],
- "icon": "icons/icons/win/icon.ico"
+ "icon": "icons/icons/win/icon.ico",
+ "artifactName": "${productName}-${version}-win-x64.${ext}"
},
"nsis": {
"oneClick": false,
diff --git a/electron/auth/client.ts b/electron/auth/client.ts
new file mode 100644
index 00000000..f025ea52
--- /dev/null
+++ b/electron/auth/client.ts
@@ -0,0 +1,228 @@
+import os from "node:os";
+import type { DesktopAuthSession } from "./sessionStore";
+
+interface DesktopSessionApiResponse {
+ ok?: boolean;
+ session?: {
+ accessToken: string;
+ refreshToken: string;
+ expiresIn: number;
+ user: {
+ id: string;
+ email: string;
+ displayName: string;
+ };
+ subscription: {
+ plan: "free" | "pro";
+ status: string;
+ };
+ entitlements: string[];
+ };
+ error?: string;
+}
+
+interface PhoneVerificationApiResponse {
+ ok?: boolean;
+ requested?: boolean;
+ verified?: boolean;
+ verificationToken?: string;
+ previewCode?: string;
+ retryAfterSec?: number;
+ expiresInSec?: number;
+ expiresAt?: string;
+ message?: string;
+ error?: string;
+}
+
+function getAuthApiBaseUrl() {
+ const envUrl =
+ process.env.AUTO_SCREEN_AUTH_API_URL ||
+ process.env.AUTO_SCREEN_BACKEND_URL ||
+ process.env.VITE_APP_API_URL ||
+ "http://127.0.0.1:4242";
+
+ return envUrl.replace(/\/$/, "");
+}
+
+async function parseError(response: Response) {
+ const payload = (await response.json().catch(() => null)) as { error?: string } | null;
+ throw new Error(payload?.error || `AUTH_API_ERROR:${response.status}`);
+}
+
+function toDesktopAuthSession(response: DesktopSessionApiResponse["session"]): DesktopAuthSession {
+ if (!response) {
+ throw new Error("Desktop session payload is missing.");
+ }
+
+ return {
+ accessToken: response.accessToken,
+ refreshToken: response.refreshToken,
+ expiresAt: new Date(Date.now() + response.expiresIn * 1000).toISOString(),
+ user: response.user,
+ subscription: response.subscription,
+ entitlements: response.entitlements,
+ lastUpdatedAt: new Date().toISOString(),
+ };
+}
+
+export function getDesktopAuthStartUrl(route: "google" | "signup" | "login") {
+ const websiteOrigin = (process.env.VITE_APP_WEBSITE_URL || "https://autoscreen.app").replace(
+ /\/$/,
+ "",
+ );
+ const url = new URL(
+ route === "google" ? "/auth/google" : route === "signup" ? "/signup" : "/login",
+ websiteOrigin,
+ );
+ url.searchParams.set("source", "desktop");
+ url.searchParams.set("app", "autoscreen");
+ url.searchParams.set("platform", process.platform);
+ url.searchParams.set("desktop_redirect_uri", "autoscreen://auth/callback");
+ url.searchParams.set("desktop_callback_scheme", "autoscreen");
+ return url.toString();
+}
+
+export function getDesktopPricingUrl() {
+ const websiteOrigin = (process.env.VITE_APP_WEBSITE_URL || "https://autoscreen.app").replace(
+ /\/$/,
+ "",
+ );
+ return new URL("/pricing", websiteOrigin).toString();
+}
+
+export async function exchangeDesktopCode(code: string): Promise {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/desktop/exchange`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ deviceName: os.hostname(),
+ platform: process.platform,
+ }),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ const payload = (await response.json()) as DesktopSessionApiResponse;
+ return toDesktopAuthSession(payload.session);
+}
+
+export async function refreshDesktopSession(refreshToken: string): Promise {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/refresh`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refreshToken }),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ const payload = (await response.json()) as DesktopSessionApiResponse;
+ return toDesktopAuthSession(payload.session);
+}
+
+export async function logoutDesktopSession(refreshToken: string) {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/logout`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refreshToken }),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ return await response.json();
+}
+
+export async function requestPhoneVerificationViaApi(payload: {
+ phoneNumber: string;
+ purpose: "signup" | "login";
+ deviceId?: string;
+}) {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/phone/request`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ return (await response.json()) as PhoneVerificationApiResponse;
+}
+
+export async function verifyPhoneCodeViaApi(payload: {
+ phoneNumber: string;
+ code: string;
+ purpose: "signup" | "login";
+ deviceId?: string;
+}) {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/phone/verify`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ return (await response.json()) as PhoneVerificationApiResponse;
+}
+
+function buildInternalEmail(username: string) {
+ const normalized = username.trim().toLowerCase();
+ return `${normalized}@users.autoscreen.local`;
+}
+
+export async function signupWithEmailViaApi(payload: {
+ username: string;
+ email?: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId?: string;
+ agreements: { terms: boolean; privacy: boolean; marketing: boolean };
+}): Promise {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/signup`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...payload,
+ email: payload.email?.trim() || buildInternalEmail(payload.username),
+ }),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ const result = (await response.json()) as DesktopSessionApiResponse;
+ return toDesktopAuthSession(result.session);
+}
+
+export async function loginWithEmailViaApi(payload: {
+ identifier: string;
+ password: string;
+ deviceId?: string;
+}): Promise {
+ const response = await fetch(`${getAuthApiBaseUrl()}/api/auth/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ await parseError(response);
+ }
+
+ const result = (await response.json()) as DesktopSessionApiResponse;
+ return toDesktopAuthSession(result.session);
+}
diff --git a/electron/auth/deviceFingerprint.ts b/electron/auth/deviceFingerprint.ts
new file mode 100644
index 00000000..b7b1d122
--- /dev/null
+++ b/electron/auth/deviceFingerprint.ts
@@ -0,0 +1,43 @@
+import { createHash, randomUUID } from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { app } from "electron";
+
+interface DeviceFingerprintRecord {
+ deviceId: string;
+ createdAt: string;
+}
+
+function getDeviceFingerprintPath() {
+ return path.join(app.getPath("userData"), "auth", "device-fingerprint.json");
+}
+
+function buildStableSeed() {
+ return [os.hostname(), os.platform(), os.arch(), os.userInfo().username].join(":");
+}
+
+export async function getOrCreateDeviceFingerprint() {
+ try {
+ const raw = await fs.readFile(getDeviceFingerprintPath(), "utf8");
+ const parsed = JSON.parse(raw) as DeviceFingerprintRecord;
+ if (parsed?.deviceId) {
+ return parsed;
+ }
+ } catch {
+ // ignore
+ }
+
+ const seededId = createHash("sha256")
+ .update(`${buildStableSeed()}:${randomUUID()}`)
+ .digest("hex")
+ .slice(0, 32);
+ const record: DeviceFingerprintRecord = {
+ deviceId: `asdev_${seededId}`,
+ createdAt: new Date().toISOString(),
+ };
+ const filePath = getDeviceFingerprintPath();
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8");
+ return record;
+}
diff --git a/electron/auth/localAccountStore.ts b/electron/auth/localAccountStore.ts
new file mode 100644
index 00000000..37bca4d3
--- /dev/null
+++ b/electron/auth/localAccountStore.ts
@@ -0,0 +1,491 @@
+import { randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { app } from "electron";
+import type { DesktopAuthSession } from "./sessionStore";
+
+interface LocalDesktopAccountRecord {
+ id: string;
+ username: string;
+ email: string;
+ familyName: string;
+ givenName: string;
+ displayName: string;
+ phoneNumber: string;
+ phoneVerifiedAt: string;
+ acceptedTermsAt: string;
+ acceptedPrivacyAt: string;
+ acceptedMarketingAt?: string;
+ signupDeviceId: string;
+ passwordHash: string;
+ passwordSalt: string;
+ createdAt: string;
+ lastLoginAt: string;
+ freePlanGrantedAt: string;
+}
+
+interface LocalDesktopAccountsFile {
+ accounts: LocalDesktopAccountRecord[];
+}
+
+interface PhoneVerificationRecord {
+ phoneNumber: string;
+ purpose: "signup" | "recovery";
+ code: string;
+ expiresAt: string;
+ attemptCount: number;
+ verifiedAt?: string;
+ verificationToken?: string;
+ lastRequestedAt: string;
+}
+
+interface PhoneVerificationsFile {
+ verifications: PhoneVerificationRecord[];
+}
+
+interface RegisterLocalDesktopAccountInput {
+ username: string;
+ email?: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId: string;
+ agreements: {
+ terms: boolean;
+ privacy: boolean;
+ marketing: boolean;
+ };
+}
+
+interface LoginLocalDesktopAccountInput {
+ identifier: string;
+ password: string;
+}
+
+function getAccountsPath() {
+ return path.join(app.getPath("userData"), "auth", "local-accounts.json");
+}
+
+function getPhoneVerificationPath() {
+ return path.join(app.getPath("userData"), "auth", "phone-verifications.json");
+}
+
+function normalizeEmail(email: string) {
+ return email.trim().toLowerCase();
+}
+
+function buildInternalEmail(username: string) {
+ return `${normalizeUsername(username)}@users.autoscreen.local`;
+}
+
+function normalizeUsername(username: string) {
+ return username.trim().toLowerCase();
+}
+
+function normalizePhone(phoneNumber: string) {
+ return phoneNumber.replace(/\D/g, "");
+}
+
+function hashPassword(password: string, salt: string) {
+ return scryptSync(password, salt, 64).toString("hex");
+}
+
+function createToken() {
+ return randomBytes(24).toString("hex");
+}
+
+async function readAccounts(): Promise {
+ try {
+ const raw = await fs.readFile(getAccountsPath(), "utf8");
+ const parsed = JSON.parse(raw) as LocalDesktopAccountsFile;
+ return Array.isArray(parsed.accounts) ? parsed.accounts : [];
+ } catch {
+ return [];
+ }
+}
+
+async function writeAccounts(accounts: LocalDesktopAccountRecord[]) {
+ const filePath = getAccountsPath();
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, JSON.stringify({ accounts }, null, 2), "utf8");
+}
+
+async function readPhoneVerifications(): Promise {
+ try {
+ const raw = await fs.readFile(getPhoneVerificationPath(), "utf8");
+ const parsed = JSON.parse(raw) as PhoneVerificationsFile;
+ return Array.isArray(parsed.verifications) ? parsed.verifications : [];
+ } catch {
+ return [];
+ }
+}
+
+async function writePhoneVerifications(verifications: PhoneVerificationRecord[]) {
+ const filePath = getPhoneVerificationPath();
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, JSON.stringify({ verifications }, null, 2), "utf8");
+}
+
+function createDesktopSession(account: LocalDesktopAccountRecord): DesktopAuthSession {
+ const now = new Date().toISOString();
+ return {
+ accessToken: createToken(),
+ refreshToken: createToken(),
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14).toISOString(),
+ user: {
+ id: account.id,
+ email: account.email,
+ displayName: account.displayName,
+ },
+ subscription: {
+ plan: "free",
+ status: "trial",
+ },
+ entitlements: ["basic_recording", "basic_editing"],
+ lastUpdatedAt: now,
+ };
+}
+
+function validateSignupFields(input: RegisterLocalDesktopAccountInput) {
+ const username = normalizeUsername(input.username);
+ const email = input.email?.trim()
+ ? normalizeEmail(input.email)
+ : buildInternalEmail(input.username);
+ const familyName = input.familyName.trim();
+ const givenName = input.givenName.trim();
+ const phoneNumber = normalizePhone(input.phoneNumber);
+ const password = input.password.trim();
+
+ if (!/^[a-z0-9_]{4,20}$/.test(username)) {
+ return { error: "์์ด๋๋ ์๋ฌธ ์๋ฌธ์ ์ซ์ ๋ฐ์ค๋ก 4์ ์ด์ 20์ ์ดํ๋ง ๊ฐ๋ฅํฉ๋๋ค." };
+ }
+
+ if (!email || !email.includes("@")) {
+ return { error: "์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (familyName.length < 1) {
+ return { error: "์ฑ์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (givenName.length < 1) {
+ return { error: "์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (phoneNumber.length < 10 || phoneNumber.length > 11) {
+ return { error: "ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ํํ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (password.length < 8) {
+ return { error: "๋น๋ฐ๋ฒํธ๋ 8์ ์ด์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ if (!input.agreements.terms || !input.agreements.privacy) {
+ return { error: "์ด์ฉ์ฝ๊ด๊ณผ ๊ฐ์ธ์ ๋ณด ์์ง ๋์๊ฐ ํ์ํฉ๋๋ค." };
+ }
+
+ if (!input.deviceId || input.deviceId.trim().length < 8) {
+ return { error: "๋๋ฐ์ด์ค ์๋ณ์๋ฅผ ํ์ธํ์ง ๋ชปํ์ต๋๋ค." };
+ }
+
+ return {
+ username,
+ email,
+ familyName,
+ givenName,
+ phoneNumber,
+ password,
+ };
+}
+
+export async function requestLocalPhoneVerification(
+ phoneInput: string,
+ purpose: "signup" | "recovery" = "signup",
+): Promise<{ success: boolean; message?: string; previewCode?: string; error?: string }> {
+ const phoneNumber = normalizePhone(phoneInput);
+ if (phoneNumber.length < 10 || phoneNumber.length > 11) {
+ return { success: false, error: "ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ํํ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ const accounts = await readAccounts();
+ if (purpose === "signup" && accounts.some((account) => account.phoneNumber === phoneNumber)) {
+ return { success: false, error: "์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ํด๋ํฐ ๋ฒํธ์
๋๋ค." };
+ }
+ if (purpose === "recovery" && !accounts.some((account) => account.phoneNumber === phoneNumber)) {
+ return { success: false, error: "๊ฐ์
๋ ํด๋ํฐ ๋ฒํธ๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค." };
+ }
+
+ const verifications = await readPhoneVerifications();
+ const now = Date.now();
+ const existing = verifications.find(
+ (item) => item.phoneNumber === phoneNumber && item.purpose === purpose,
+ );
+ if (existing && now - new Date(existing.lastRequestedAt).getTime() < 30_000) {
+ return { success: false, error: "์ ์ ํ ๋ค์ ์์ฒญํด์ฃผ์ธ์." };
+ }
+
+ const code = String(randomInt(100000, 999999));
+ const record: PhoneVerificationRecord = {
+ phoneNumber,
+ purpose,
+ code,
+ expiresAt: new Date(now + 1000 * 60 * 3).toISOString(),
+ attemptCount: 0,
+ lastRequestedAt: new Date(now).toISOString(),
+ };
+
+ const next = verifications.filter(
+ (item) => !(item.phoneNumber === phoneNumber && item.purpose === purpose),
+ );
+ next.push(record);
+ await writePhoneVerifications(next);
+
+ return {
+ success: true,
+ message: app.isPackaged
+ ? "๋ฌธ์ ์ธ์ฆ ์ฝ๋๋ฅผ ๋ฐ์กํ์ต๋๋ค."
+ : "๊ฐ๋ฐ ๋ชจ๋์์๋ ํ๋ฉด์์ ์ธ์ฆ ์ฝ๋๋ฅผ ํ์ธํ ์ ์์ต๋๋ค.",
+ previewCode: app.isPackaged ? undefined : code,
+ };
+}
+
+export async function verifyLocalPhoneCode(
+ phoneInput: string,
+ codeInput: string,
+ purpose: "signup" | "recovery" = "signup",
+): Promise<{ success: boolean; verificationToken?: string; message?: string; error?: string }> {
+ const phoneNumber = normalizePhone(phoneInput);
+ const code = codeInput.trim();
+ const verifications = await readPhoneVerifications();
+ const record = verifications.find(
+ (item) => item.phoneNumber === phoneNumber && item.purpose === purpose,
+ );
+
+ if (!record) {
+ return { success: false, error: "๋จผ์ ๋ฌธ์ ์ธ์ฆ ์์ฒญ์ ํด์ฃผ์ธ์." };
+ }
+
+ if (new Date(record.expiresAt).getTime() < Date.now()) {
+ return { success: false, error: "์ธ์ฆ ์ฝ๋๊ฐ ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์์ฒญํด์ฃผ์ธ์." };
+ }
+
+ if (record.attemptCount >= 5) {
+ return { success: false, error: "์ธ์ฆ ์๋๊ฐ ๋๋ฌด ๋ง์ต๋๋ค. ๋ค์ ์์ฒญํด์ฃผ์ธ์." };
+ }
+
+ if (record.code !== code) {
+ const updated = verifications.map((item) =>
+ item.phoneNumber === phoneNumber && item.purpose === purpose
+ ? { ...item, attemptCount: item.attemptCount + 1 }
+ : item,
+ );
+ await writePhoneVerifications(updated);
+ return { success: false, error: "์ธ์ฆ ์ฝ๋๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค." };
+ }
+
+ const verificationToken = createToken();
+ const updated = verifications.map((item) =>
+ item.phoneNumber === phoneNumber && item.purpose === purpose
+ ? {
+ ...item,
+ attemptCount: item.attemptCount + 1,
+ verifiedAt: new Date().toISOString(),
+ verificationToken,
+ }
+ : item,
+ );
+ await writePhoneVerifications(updated);
+ return { success: true, verificationToken, message: "ํด๋ํฐ ์ธ์ฆ์ด ์๋ฃ๋์์ต๋๋ค." };
+}
+
+export async function findLocalUsername(input: {
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ verificationToken: string;
+}): Promise<{ success: boolean; username?: string; error?: string }> {
+ const familyName = input.familyName.trim();
+ const givenName = input.givenName.trim();
+ const phoneNumber = normalizePhone(input.phoneNumber);
+ if (!familyName || !givenName) {
+ return { success: false, error: "์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+ const verifications = await readPhoneVerifications();
+ const verification = verifications.find(
+ (item) => item.phoneNumber === phoneNumber && item.purpose === "recovery",
+ );
+ if (
+ !verification?.verificationToken ||
+ verification.verificationToken !== input.verificationToken
+ ) {
+ return { success: false, error: "ํด๋ํฐ ๋ณธ์ธ ํ์ธ์ด ํ์ํฉ๋๋ค." };
+ }
+ if (!verification.verifiedAt || new Date(verification.expiresAt).getTime() < Date.now()) {
+ return { success: false, error: "๋ณธ์ธ ํ์ธ์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์งํํด์ฃผ์ธ์." };
+ }
+ const accounts = await readAccounts();
+ const account = accounts.find(
+ (candidate) =>
+ candidate.phoneNumber === phoneNumber &&
+ candidate.familyName === familyName &&
+ candidate.givenName === givenName,
+ );
+ if (!account) {
+ return { success: false, error: "์ผ์นํ๋ ๊ณ์ ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค." };
+ }
+ return { success: true, username: account.username };
+}
+
+export async function resetLocalDesktopPassword(input: {
+ username: string;
+ phoneNumber: string;
+ verificationToken: string;
+ newPassword: string;
+}): Promise<{ success: boolean; error?: string }> {
+ const username = normalizeUsername(input.username);
+ const phoneNumber = normalizePhone(input.phoneNumber);
+ const newPassword = input.newPassword.trim();
+ if (!username) {
+ return { success: false, error: "์์ด๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+ if (newPassword.length < 8) {
+ return { success: false, error: "๋น๋ฐ๋ฒํธ๋ 8์ ์ด์ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+ const verifications = await readPhoneVerifications();
+ const verification = verifications.find(
+ (item) => item.phoneNumber === phoneNumber && item.purpose === "recovery",
+ );
+ if (
+ !verification?.verificationToken ||
+ verification.verificationToken !== input.verificationToken
+ ) {
+ return { success: false, error: "ํด๋ํฐ ๋ณธ์ธ ํ์ธ์ด ํ์ํฉ๋๋ค." };
+ }
+ if (!verification.verifiedAt || new Date(verification.expiresAt).getTime() < Date.now()) {
+ return { success: false, error: "๋ณธ์ธ ํ์ธ์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์งํํด์ฃผ์ธ์." };
+ }
+ const accounts = await readAccounts();
+ const account = accounts.find(
+ (candidate) => candidate.username === username && candidate.phoneNumber === phoneNumber,
+ );
+ if (!account) {
+ return { success: false, error: "์ผ์นํ๋ ๊ณ์ ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค." };
+ }
+ const salt = randomBytes(16).toString("hex");
+ const updatedAccount: LocalDesktopAccountRecord = {
+ ...account,
+ passwordSalt: salt,
+ passwordHash: hashPassword(newPassword, salt),
+ lastLoginAt: new Date().toISOString(),
+ };
+ await writeAccounts(
+ accounts.map((candidate) => (candidate.id === account.id ? updatedAccount : candidate)),
+ );
+ await writePhoneVerifications(
+ verifications.filter(
+ (item) => !(item.phoneNumber === phoneNumber && item.purpose === "recovery"),
+ ),
+ );
+ return { success: true };
+}
+
+export async function registerLocalDesktopAccount(
+ input: RegisterLocalDesktopAccountInput,
+): Promise<{ session?: DesktopAuthSession; error?: string }> {
+ const parsed = validateSignupFields(input);
+ if ("error" in parsed) {
+ return { error: parsed.error };
+ }
+
+ const accounts = await readAccounts();
+ if (accounts.some((account) => account.username === parsed.username)) {
+ return { error: "์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์์ด๋์
๋๋ค." };
+ }
+ if (accounts.some((account) => account.email === parsed.email)) {
+ return { error: "์ด๋ฏธ ๊ฐ์
๋ ์ด๋ฉ์ผ์
๋๋ค." };
+ }
+ if (accounts.some((account) => account.phoneNumber === parsed.phoneNumber)) {
+ return { error: "์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ํด๋ํฐ ๋ฒํธ์
๋๋ค." };
+ }
+ if (accounts.some((account) => account.signupDeviceId === input.deviceId.trim())) {
+ return { error: "์ด ๋๋ฐ์ด์ค์์๋ ์ด๋ฏธ ๋ฌด๋ฃ ํ๋์ด ์์ฑ๋์์ต๋๋ค." };
+ }
+
+ const verifications = await readPhoneVerifications();
+ const verification = verifications.find((item) => item.phoneNumber === parsed.phoneNumber);
+ if (
+ !verification?.verificationToken ||
+ verification.verificationToken !== input.verificationToken
+ ) {
+ return { error: "ํด๋ํฐ ์ธ์ฆ์ด ์๋ฃ๋์ง ์์์ต๋๋ค." };
+ }
+ if (!verification.verifiedAt || new Date(verification.expiresAt).getTime() < Date.now()) {
+ return { error: "ํด๋ํฐ ์ธ์ฆ์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ์งํํด์ฃผ์ธ์." };
+ }
+
+ const now = new Date().toISOString();
+ const salt = randomBytes(16).toString("hex");
+ const displayName = `${parsed.familyName}${parsed.givenName}`;
+ const account: LocalDesktopAccountRecord = {
+ id: `local:${parsed.username}`,
+ username: parsed.username,
+ email: parsed.email,
+ familyName: parsed.familyName,
+ givenName: parsed.givenName,
+ displayName,
+ phoneNumber: parsed.phoneNumber,
+ phoneVerifiedAt: verification.verifiedAt,
+ acceptedTermsAt: now,
+ acceptedPrivacyAt: now,
+ acceptedMarketingAt: input.agreements.marketing ? now : undefined,
+ signupDeviceId: input.deviceId.trim(),
+ passwordSalt: salt,
+ passwordHash: hashPassword(parsed.password, salt),
+ createdAt: now,
+ lastLoginAt: now,
+ freePlanGrantedAt: now,
+ };
+
+ accounts.push(account);
+ await writeAccounts(accounts);
+ await writePhoneVerifications(
+ verifications.filter((item) => item.phoneNumber !== parsed.phoneNumber),
+ );
+ return { session: createDesktopSession(account) };
+}
+
+export async function loginLocalDesktopAccount(
+ input: LoginLocalDesktopAccountInput,
+): Promise<{ session?: DesktopAuthSession; error?: string }> {
+ const identifier = input.identifier.trim().toLowerCase();
+ const password = input.password.trim();
+
+ if (!identifier || !password) {
+ return { error: "์์ด๋์ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์." };
+ }
+
+ const accounts = await readAccounts();
+ const account = accounts.find(
+ (candidate) => candidate.username === identifier || candidate.email === identifier,
+ );
+ if (!account) {
+ return { error: "๊ฐ์
๋ ๊ณ์ ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค." };
+ }
+
+ const expectedHash = Buffer.from(account.passwordHash, "hex");
+ const incomingHash = Buffer.from(hashPassword(password, account.passwordSalt), "hex");
+ if (expectedHash.length !== incomingHash.length || !timingSafeEqual(expectedHash, incomingHash)) {
+ return { error: "๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค." };
+ }
+
+ const updatedAccount: LocalDesktopAccountRecord = {
+ ...account,
+ lastLoginAt: new Date().toISOString(),
+ };
+ await writeAccounts(
+ accounts.map((candidate) => (candidate.id === account.id ? updatedAccount : candidate)),
+ );
+ return { session: createDesktopSession(updatedAccount) };
+}
diff --git a/electron/auth/protocol.ts b/electron/auth/protocol.ts
new file mode 100644
index 00000000..a61a8649
--- /dev/null
+++ b/electron/auth/protocol.ts
@@ -0,0 +1,139 @@
+import type { BrowserWindow } from "electron";
+import { app } from "electron";
+import { exchangeDesktopCode, getDesktopAuthStartUrl, getDesktopPricingUrl } from "./client";
+import {
+ clearDesktopAuthSession,
+ type DesktopAuthSession,
+ isDesktopAuthSessionExpired,
+ readDesktopAuthSession,
+ writeDesktopAuthSession,
+} from "./sessionStore";
+
+export interface AuthCallbackResult {
+ session: DesktopAuthSession | null;
+ error?: string;
+}
+
+function parseDesktopCallback(urlString: string) {
+ const incoming = new URL(urlString);
+ const host = incoming.hostname;
+ const pathname = incoming.pathname;
+ const isAuthCallback = host === "auth" && pathname === "/callback";
+ if (!isAuthCallback) {
+ return null;
+ }
+
+ return {
+ code: incoming.searchParams.get("code"),
+ error: incoming.searchParams.get("error") || incoming.searchParams.get("message"),
+ plan: incoming.searchParams.get("plan"),
+ email: incoming.searchParams.get("email"),
+ displayName: incoming.searchParams.get("displayName"),
+ accessToken: incoming.searchParams.get("accessToken"),
+ refreshToken: incoming.searchParams.get("refreshToken"),
+ expiresIn: incoming.searchParams.get("expiresIn"),
+ entitlements: incoming.searchParams.get("entitlements"),
+ };
+}
+
+function broadcastDesktopAuthSession(
+ targetWindow: BrowserWindow | null,
+ session: DesktopAuthSession | null,
+) {
+ if (!targetWindow || targetWindow.isDestroyed()) return;
+ targetWindow.webContents.send("auth-session-changed", session);
+}
+
+function coerceEntitlements(input: string | null) {
+ if (!input) return [] as string[];
+ return input
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function createSessionFromDirectCallback(
+ parsed: NonNullable>,
+): DesktopAuthSession | null {
+ if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) {
+ return null;
+ }
+
+ const expiresIn = Number(parsed.expiresIn || "3600");
+ return {
+ accessToken: parsed.accessToken,
+ refreshToken: parsed.refreshToken,
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
+ user: {
+ id: parsed.email,
+ email: parsed.email,
+ displayName: parsed.displayName || parsed.email.split("@")[0] || "Auto Screen User",
+ },
+ subscription: {
+ plan: parsed.plan === "pro" ? "pro" : "free",
+ status: parsed.plan === "pro" ? "active" : "inactive",
+ },
+ entitlements: coerceEntitlements(parsed.entitlements),
+ lastUpdatedAt: new Date().toISOString(),
+ };
+}
+
+export function setupDesktopProtocol() {
+ app.setAsDefaultProtocolClient("autoscreen");
+}
+
+export function getDesktopStartUrl(route: "google" | "signup" | "login") {
+ return getDesktopAuthStartUrl(route);
+}
+
+export function getDesktopPricingStartUrl() {
+ return getDesktopPricingUrl();
+}
+
+export async function handleDesktopProtocolUrl(
+ urlString: string,
+ targetWindow: BrowserWindow | null,
+): Promise {
+ const parsed = parseDesktopCallback(urlString);
+ if (!parsed) {
+ return { session: null, error: "Unsupported auth callback." };
+ }
+
+ if (parsed.error) {
+ broadcastDesktopAuthSession(targetWindow, null);
+ return { session: null, error: parsed.error };
+ }
+
+ let session = createSessionFromDirectCallback(parsed);
+ if (!session && parsed.code) {
+ session = await exchangeDesktopCode(parsed.code);
+ }
+
+ if (!session) {
+ broadcastDesktopAuthSession(targetWindow, null);
+ return { session: null, error: "Desktop auth callback payload is incomplete." };
+ }
+
+ await writeDesktopAuthSession(session);
+ broadcastDesktopAuthSession(targetWindow, session);
+ if (targetWindow && !targetWindow.isDestroyed()) {
+ if (targetWindow.isMinimized()) targetWindow.restore();
+ targetWindow.show();
+ targetWindow.focus();
+ }
+ return { session };
+}
+
+export async function restoreDesktopAuthSession() {
+ const stored = await readDesktopAuthSession();
+ if (!stored) return null;
+ if (isDesktopAuthSessionExpired(stored)) {
+ return stored;
+ }
+ return stored;
+}
+
+export async function clearStoredDesktopAuthSession(targetWindow: BrowserWindow | null) {
+ await clearDesktopAuthSession();
+ broadcastDesktopAuthSession(targetWindow, null);
+}
diff --git a/electron/auth/sessionStore.ts b/electron/auth/sessionStore.ts
new file mode 100644
index 00000000..3aee8fcd
--- /dev/null
+++ b/electron/auth/sessionStore.ts
@@ -0,0 +1,59 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { app } from "electron";
+
+export type DesktopPlan = "free" | "pro";
+
+export interface DesktopAuthSession {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: string;
+ user: {
+ id: string;
+ email: string;
+ displayName: string;
+ };
+ subscription: {
+ plan: DesktopPlan;
+ status: string;
+ };
+ entitlements: string[];
+ lastUpdatedAt: string;
+}
+
+function getSessionPath() {
+ return path.join(app.getPath("userData"), "auth", "desktop-session.json");
+}
+
+export async function readDesktopAuthSession(): Promise {
+ try {
+ const raw = await fs.readFile(getSessionPath(), "utf8");
+ const parsed = JSON.parse(raw) as DesktopAuthSession;
+ if (!parsed?.refreshToken || !parsed?.user?.email) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export async function writeDesktopAuthSession(session: DesktopAuthSession) {
+ const filePath = getSessionPath();
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, JSON.stringify(session, null, 2), "utf8");
+}
+
+export async function clearDesktopAuthSession() {
+ try {
+ await fs.rm(getSessionPath(), { force: true });
+ } catch {
+ // ignore
+ }
+}
+
+export function isDesktopAuthSessionExpired(session: DesktopAuthSession, skewMs = 30_000) {
+ const expiresAt = new Date(session.expiresAt).getTime();
+ if (Number.isNaN(expiresAt)) return true;
+ return expiresAt <= Date.now() + skewMs;
+}
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index b2a37205..feb788bb 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -70,8 +70,95 @@ interface Window {
message?: string;
error?: string;
}>;
+ getInteractionTelemetry: (videoPath?: string) => Promise<{
+ success: boolean;
+ clicks: Array<{ timeMs: number; cx: number; cy: number }>;
+ keys: Array<{ timeMs: number; key: string }>;
+ }>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
+ writeClipboardText: (text: string) => Promise<{ success: boolean }>;
+ getAuthSession: () => Promise<{
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: string;
+ user: { id: string; email: string; displayName: string };
+ subscription: { plan: "free" | "pro"; status: string };
+ entitlements: string[];
+ lastUpdatedAt: string;
+ } | null>;
+ logoutAuthSession: () => Promise<{ success: boolean }>;
+ clearAuthSession: () => Promise<{ success: boolean }>;
+ getDeviceFingerprint: () => Promise<{ deviceId: string; createdAt: string }>;
+ createLocalAuthAccount: (payload: {
+ username: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId: string;
+ agreements: {
+ terms: boolean;
+ privacy: boolean;
+ marketing: boolean;
+ };
+ }) => Promise<{ success: boolean; error?: string }>;
+ loginLocalAuthAccount: (payload: {
+ identifier: string;
+ password: string;
+ deviceId?: string;
+ }) => Promise<{ success: boolean; error?: string }>;
+ requestLocalPhoneVerification: (payload: {
+ phoneNumber: string;
+ deviceId?: string;
+ purpose?: "signup" | "recovery";
+ }) => Promise<{
+ success: boolean;
+ message?: string;
+ previewCode?: string;
+ retryAfterSec?: number;
+ expiresInSec?: number;
+ expiresAt?: string;
+ error?: string;
+ }>;
+ verifyLocalPhoneCode: (payload: {
+ phoneNumber: string;
+ code: string;
+ deviceId?: string;
+ purpose?: "signup" | "recovery";
+ }) => Promise<{
+ success: boolean;
+ verificationToken?: string;
+ expiresAt?: string;
+ message?: string;
+ error?: string;
+ }>;
+ findLocalAuthUsername: (payload: {
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ verificationToken: string;
+ }) => Promise<{ success: boolean; username?: string; error?: string }>;
+ resetLocalAuthPassword: (payload: {
+ username: string;
+ phoneNumber: string;
+ verificationToken: string;
+ newPassword: string;
+ }) => Promise<{ success: boolean; error?: string }>;
+ onAuthSessionChanged: (
+ callback: (
+ session: {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: string;
+ user: { id: string; email: string; displayName: string };
+ subscription: { plan: "free" | "pro"; status: string };
+ entitlements: string[];
+ lastUpdatedAt: string;
+ } | null,
+ ) => void,
+ ) => () => void;
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
@@ -139,6 +226,53 @@ interface Window {
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void;
setLocale: (locale: string) => Promise;
+ onEditorCommandRequest: (
+ callback: (
+ request: import("../src/editor/commands/types").EditorCommandRequestEnvelope,
+ ) => void | Promise,
+ ) => () => void;
+ sendEditorCommandResponse: (
+ response: import("../src/editor/commands/types").EditorCommandResponseEnvelope,
+ ) => void;
+ publishEditorState: (
+ snapshot: import("../src/editor/commands/types").ProjectStateSnapshot,
+ ) => void;
+ getMcpConnectionInfo: () => Promise<{
+ enabled: boolean;
+ url: string;
+ token: string;
+ }>;
+ resetMcpToken: () => Promise<{
+ success: boolean;
+ url?: string;
+ token?: string;
+ error?: string;
+ }>;
+ getAdminBackendStatus: () => Promise<{
+ success: boolean;
+ storageDriver?: string;
+ postgres?: {
+ configured: boolean;
+ driver: string;
+ connected: boolean;
+ message: string;
+ serverTime?: string;
+ };
+ auditSource?: string;
+ recentSignupAuditLogs?: Array<{
+ id: string;
+ username?: string;
+ email?: string;
+ phoneNumber?: string;
+ deviceId?: string;
+ signupIp?: string;
+ outcome: string;
+ reason?: string;
+ createdAt: string;
+ }>;
+ error?: string;
+ }>;
+ testMcpConnection: () => Promise<{ success: boolean; message?: string; error?: string }>;
};
}
diff --git a/electron/i18n.ts b/electron/i18n.ts
index b3850086..6a9055c4 100644
--- a/electron/i18n.ts
+++ b/electron/i18n.ts
@@ -5,15 +5,18 @@ import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
+import commonKo from "../src/i18n/locales/ko/common.json";
+import dialogsKo from "../src/i18n/locales/ko/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
-type Locale = "en" | "zh-CN" | "es";
+type Locale = "en" | "ko" | "zh-CN" | "es";
type Namespace = "common" | "dialogs";
type MessageMap = Record;
const messages: Record> = {
en: { common: commonEn, dialogs: dialogsEn },
+ ko: { common: commonKo, dialogs: dialogsKo },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
};
@@ -21,7 +24,7 @@ const messages: Record> = {
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
- if (locale === "en" || locale === "zh-CN" || locale === "es") {
+ if (locale === "en" || locale === "ko" || locale === "zh-CN" || locale === "es") {
currentLocale = locale;
}
}
diff --git a/electron/ipc/editorBridge.ts b/electron/ipc/editorBridge.ts
new file mode 100644
index 00000000..948b25cb
--- /dev/null
+++ b/electron/ipc/editorBridge.ts
@@ -0,0 +1,107 @@
+import { randomUUID } from "node:crypto";
+import { BrowserWindow, ipcMain } from "electron";
+import type {
+ EditorCommandInput,
+ EditorCommandName,
+ EditorCommandResponseEnvelope,
+ ProjectStateSnapshot,
+} from "../../src/editor/commands/types";
+
+interface PendingCommand {
+ resolve: (value: unknown) => void;
+ reject: (reason?: unknown) => void;
+ timer: NodeJS.Timeout;
+}
+
+const pendingCommands = new Map();
+let latestEditorState: ProjectStateSnapshot | null = null;
+
+function isEditorWindow(window: BrowserWindow) {
+ return window.webContents.getURL().includes("windowType=editor");
+}
+
+function getBestEditorWindow(getMainWindow: () => BrowserWindow | null) {
+ const focusedWindow = BrowserWindow.getFocusedWindow();
+ if (focusedWindow && !focusedWindow.isDestroyed() && isEditorWindow(focusedWindow)) {
+ return focusedWindow;
+ }
+
+ const discoveredEditorWindow = BrowserWindow.getAllWindows().find(
+ (window) => !window.isDestroyed() && isEditorWindow(window),
+ );
+ if (discoveredEditorWindow) {
+ return discoveredEditorWindow;
+ }
+
+ const mainWindow = getMainWindow();
+ if (mainWindow && !mainWindow.isDestroyed() && isEditorWindow(mainWindow)) {
+ return mainWindow;
+ }
+
+ return null;
+}
+
+export function registerEditorCommandBridge(getMainWindow: () => BrowserWindow | null) {
+ ipcMain.on("editor-command:response", (_event, response: EditorCommandResponseEnvelope) => {
+ const pending = pendingCommands.get(response.requestId);
+ if (!pending) {
+ return;
+ }
+ clearTimeout(pending.timer);
+ pendingCommands.delete(response.requestId);
+ if (response.success) {
+ pending.resolve(response.result);
+ return;
+ }
+ pending.reject(new Error(response.error));
+ });
+
+ ipcMain.on("editor-state:publish", (_event, snapshot: ProjectStateSnapshot) => {
+ latestEditorState = snapshot;
+ });
+
+ ipcMain.handle("get-mcp-connection-info", () => {
+ return {
+ enabled: false,
+ url: "",
+ token: "",
+ };
+ });
+
+ return {
+ async requestEditorCommand(
+ command: TName,
+ payload: EditorCommandInput,
+ timeoutMs: number = 20_000,
+ ) {
+ const editorWindow = getBestEditorWindow(getMainWindow);
+ if (!editorWindow) {
+ throw new Error("Editor window is not available");
+ }
+
+ const requestId = randomUUID();
+ const resultPromise = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingCommands.delete(requestId);
+ reject(new Error(`Editor command timed out: ${command}`));
+ }, timeoutMs);
+ pendingCommands.set(requestId, { resolve, reject, timer });
+ });
+
+ editorWindow.webContents.send("editor-command:request", {
+ requestId,
+ command,
+ payload,
+ });
+
+ return resultPromise;
+ },
+ getLatestEditorState() {
+ return latestEditorState;
+ },
+ setConnectionInfoGetter(getInfo: () => { enabled: boolean; url: string; token: string }) {
+ ipcMain.removeHandler("get-mcp-connection-info");
+ ipcMain.handle("get-mcp-connection-info", () => getInfo());
+ },
+ };
+}
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 4cb48756..db9f9c34 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -12,6 +12,7 @@ import {
systemPreferences,
} from "electron";
import {
+ type InteractionTelemetry,
normalizeProjectMedia,
normalizeRecordingSession,
type ProjectMedia,
@@ -21,7 +22,8 @@ import {
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";
-const PROJECT_FILE_EXTENSION = "openscreen";
+const PROJECT_FILE_EXTENSION = "autoscreen";
+const LEGACY_PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const RECORDING_SESSION_SUFFIX = ".session.json";
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
@@ -284,6 +286,15 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
}
pendingCursorSamples = [];
+ if (payload.interactions) {
+ const interactionTelemetryPath = `${screenVideoPath}.interaction.json`;
+ await fs.writeFile(
+ interactionTelemetryPath,
+ JSON.stringify(payload.interactions, null, 2),
+ "utf-8",
+ );
+ }
+
const sessionManifestPath = path.join(
RECORDINGS_DIR,
`${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`,
@@ -352,7 +363,6 @@ function sampleCursorPoint() {
export function registerIpcHandlers(
createEditorWindow: () => void,
createSourceSelectorWindow: () => BrowserWindow,
- getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
@@ -439,10 +449,6 @@ export function registerIpcHandlers(
});
ipcMain.handle("switch-to-editor", () => {
- const mainWin = getMainWindow();
- if (mainWin) {
- mainWin.close();
- }
createEditorWindow();
});
@@ -614,6 +620,27 @@ export function registerIpcHandlers(
}
});
+ ipcMain.handle("get-interaction-telemetry", async (_, videoPath?: string) => {
+ const targetVideoPath = normalizeVideoSourcePath(
+ videoPath ?? currentRecordingSession?.screenVideoPath,
+ );
+ if (!targetVideoPath || !isPathAllowed(targetVideoPath)) {
+ return { success: true, clicks: [], keys: [] };
+ }
+ const interactionTelemetryPath = `${targetVideoPath}.interaction.json`;
+ try {
+ const content = await fs.readFile(interactionTelemetryPath, "utf-8");
+ const parsed = JSON.parse(content) as InteractionTelemetry;
+ return {
+ success: true,
+ clicks: Array.isArray(parsed?.clicks) ? parsed.clicks : [],
+ keys: Array.isArray(parsed?.keys) ? parsed.keys : [],
+ };
+ } catch {
+ return { success: true, clicks: [], keys: [] };
+ }
+ });
+
ipcMain.handle("open-external-url", async (_, url: string) => {
try {
await shell.openExternal(url);
@@ -821,7 +848,7 @@ export function registerIpcHandlers(
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
+ extensions: [PROJECT_FILE_EXTENSION, LEGACY_PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
diff --git a/electron/main.ts b/electron/main.ts
index 0f06f9e3..2e8ca2b3 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
+ clipboard,
dialog,
ipcMain,
Menu,
@@ -12,8 +13,43 @@ import {
systemPreferences,
Tray,
} from "electron";
+
+declare global {
+ namespace Electron {
+ interface App {
+ isQuitting?: boolean;
+ }
+ }
+}
+
+import {
+ loginWithEmailViaApi,
+ logoutDesktopSession,
+ refreshDesktopSession,
+ requestPhoneVerificationViaApi,
+ signupWithEmailViaApi,
+ verifyPhoneCodeViaApi,
+} from "./auth/client";
+import { getOrCreateDeviceFingerprint } from "./auth/deviceFingerprint";
+import {
+ findLocalUsername,
+ loginLocalDesktopAccount,
+ registerLocalDesktopAccount,
+ requestLocalPhoneVerification,
+ resetLocalDesktopPassword,
+ verifyLocalPhoneCode,
+} from "./auth/localAccountStore";
+import {
+ clearStoredDesktopAuthSession,
+ handleDesktopProtocolUrl,
+ restoreDesktopAuthSession,
+ setupDesktopProtocol,
+} from "./auth/protocol";
+import { type DesktopAuthSession, writeDesktopAuthSession } from "./auth/sessionStore";
import { mainT, setMainLocale } from "./i18n";
+import { registerEditorCommandBridge } from "./ipc/editorBridge";
import { registerIpcHandlers } from "./ipc/handlers";
+import { startAutoScreenMcpServer } from "./mcp/server";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -27,6 +63,34 @@ if (process.platform === "darwin") {
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
+function createHiddenAdminSession(): DesktopAuthSession {
+ return {
+ accessToken: `admin-access-${Date.now()}`,
+ refreshToken: `admin-refresh-${Date.now()}`,
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
+ user: {
+ id: "admin:test1",
+ email: "admin@autoscreen.local",
+ displayName: "๊ด๋ฆฌ์",
+ },
+ subscription: { plan: "pro", status: "active" },
+ entitlements: ["basic_recording", "basic_editing", "pro_export", "mcp_editing"],
+ lastUpdatedAt: new Date().toISOString(),
+ };
+}
+
+function isProductionDesktopBuild() {
+ return app.isPackaged && process.env.ALLOW_DESKTOP_DEV_AUTH !== "1";
+}
+
+function canUseLocalAuthFallback() {
+ return !isProductionDesktopBuild();
+}
+
+function canUseHiddenTestAdmin() {
+ return process.env.ALLOW_TEST_ADMIN_LOGIN === "1" || !app.isPackaged;
+}
+
async function ensureRecordingsDir() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
@@ -58,30 +122,47 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
: RENDERER_DIST;
// Window references
-let mainWindow: BrowserWindow | null = null;
+let hudWindow: BrowserWindow | null = null;
+let editorWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let selectedSourceName = "";
+let mcpRuntime: Awaited> | null = null;
+let pendingProtocolUrl: string | null = null;
// Tray Icons
-const defaultTrayIcon = getTrayIcon("openscreen.png");
-const recordingTrayIcon = getTrayIcon("rec-button.png");
+const defaultTrayIcon = getTrayIcon("autoscreen.png");
+const recordingTrayIcon = getTrayIcon("autoscreen-recording.png");
function createWindow() {
- mainWindow = createHudOverlayWindow();
+ const shouldStartWithEditor =
+ process.env.AUTO_SCREEN_START_EDITOR === "true" ||
+ process.env.AUTO_SCREEN_AUTH_GATE !== "false";
+
+ if (shouldStartWithEditor) {
+ createEditorWindowWrapper();
+ return;
+ }
+
+ showHudWindow();
}
-function showMainWindow() {
- if (mainWindow && !mainWindow.isDestroyed()) {
- if (mainWindow.isMinimized()) {
- mainWindow.restore();
+function showHudWindow() {
+ if (hudWindow && !hudWindow.isDestroyed()) {
+ if (hudWindow.isMinimized()) {
+ hudWindow.restore();
}
- mainWindow.show();
- mainWindow.focus();
+ hudWindow.show();
+ hudWindow.focus();
return;
}
- createWindow();
+ hudWindow = createHudOverlayWindow();
+ hudWindow.on("closed", () => {
+ if (hudWindow && hudWindow.isDestroyed()) {
+ hudWindow = null;
+ }
+ });
}
function isEditorWindow(window: BrowserWindow) {
@@ -91,11 +172,11 @@ function isEditorWindow(window: BrowserWindow) {
function sendEditorMenuAction(
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
) {
- let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
+ let targetWindow = BrowserWindow.getFocusedWindow() ?? editorWindow;
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
createEditorWindowWrapper();
- targetWindow = mainWindow;
+ targetWindow = editorWindow;
if (!targetWindow || targetWindow.isDestroyed()) return;
targetWindow.webContents.once("did-finish-load", () => {
@@ -192,10 +273,10 @@ function setupApplicationMenu() {
function createTray() {
tray = new Tray(defaultTrayIcon);
tray.on("click", () => {
- showMainWindow();
+ showHudWindow();
});
tray.on("double-click", () => {
- showMainWindow();
+ showHudWindow();
});
}
@@ -212,14 +293,14 @@ function getTrayIcon(filename: string) {
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
- const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
+ const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Auto Screen";
const menuTemplate = recording
? [
{
label: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send("stop-recording-from-tray");
+ if (hudWindow && !hudWindow.isDestroyed()) {
+ hudWindow.webContents.send("stop-recording-from-tray");
}
},
},
@@ -228,7 +309,7 @@ function updateTrayMenu(recording: boolean = false) {
{
label: mainT("common", "actions.open") || "Open",
click: () => {
- showMainWindow();
+ showHudWindow();
},
},
{
@@ -245,6 +326,7 @@ function updateTrayMenu(recording: boolean = false) {
let editorHasUnsavedChanges = false;
let isForceClosing = false;
+let isSwitchingToHud = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
@@ -266,21 +348,38 @@ function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
}
function createEditorWindowWrapper() {
- if (mainWindow) {
- isForceClosing = true;
- mainWindow.close();
- isForceClosing = false;
- mainWindow = null;
+ if (editorWindow && !editorWindow.isDestroyed()) {
+ if (editorWindow.isMinimized()) {
+ editorWindow.restore();
+ }
+ editorWindow.show();
+ editorWindow.focus();
+ return editorWindow;
}
- mainWindow = createEditorWindow();
+
+ if (hudWindow && !hudWindow.isDestroyed()) {
+ hudWindow.hide();
+ }
+
+ editorWindow = createEditorWindow();
editorHasUnsavedChanges = false;
- mainWindow.on("close", (event) => {
+ editorWindow.on("closed", () => {
+ const shouldRevealHud =
+ isSwitchingToHud || (!app.isQuitting && (!hudWindow || hudWindow.isDestroyed()));
+ editorWindow = null;
+ if (shouldRevealHud) {
+ showHudWindow();
+ }
+ isSwitchingToHud = false;
+ });
+
+ editorWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
event.preventDefault();
- const choice = dialog.showMessageBoxSync(mainWindow!, {
+ const choice = dialog.showMessageBoxSync(editorWindow!, {
type: "warning",
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
@@ -294,22 +393,21 @@ function createEditorWindowWrapper() {
detail: mainT("dialogs", "unsavedChanges.detail"),
});
- const windowToClose = mainWindow;
+ const windowToClose = editorWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
- // Save & Close โ tell renderer to save, then close
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
- // Discard & Close
forceCloseEditorWindow(windowToClose);
}
- // choice === 2: Cancel โ do nothing, window stays open
});
+
+ return editorWindow;
}
function createSourceSelectorWindowWrapper() {
@@ -320,12 +418,36 @@ function createSourceSelectorWindowWrapper() {
return sourceSelectorWindow;
}
+const gotSingleInstanceLock = app.requestSingleInstanceLock();
+if (!gotSingleInstanceLock) {
+ app.quit();
+}
+
+app.on("second-instance", (_event, commandLine) => {
+ const deepLink = commandLine.find((arg) => arg.startsWith("autoscreen://"));
+ if (deepLink) {
+ pendingProtocolUrl = deepLink;
+ void handleDesktopProtocolUrl(deepLink, editorWindow);
+ }
+ createEditorWindowWrapper();
+});
+
+app.on("open-url", (event, url) => {
+ event.preventDefault();
+ pendingProtocolUrl = url;
+ void handleDesktopProtocolUrl(url, editorWindow);
+});
+
// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
// Keep app running (macOS behavior)
});
+app.on("before-quit", () => {
+ app.isQuitting = true;
+});
+
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
@@ -336,6 +458,8 @@ app.on("activate", () => {
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
+ setupDesktopProtocol();
+
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
@@ -359,6 +483,304 @@ app.whenReady().then(async () => {
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
+ ipcMain.handle("get-auth-session", async () => {
+ const currentSession = await restoreDesktopAuthSession();
+ if (!currentSession) {
+ return null;
+ }
+
+ if (new Date(currentSession.expiresAt).getTime() > Date.now() + 30_000) {
+ return currentSession;
+ }
+
+ try {
+ const refreshedSession = await refreshDesktopSession(currentSession.refreshToken);
+ await writeDesktopAuthSession(refreshedSession);
+ return refreshedSession;
+ } catch {
+ await clearStoredDesktopAuthSession(editorWindow);
+ return null;
+ }
+ });
+ ipcMain.handle("logout-auth-session", async () => {
+ const currentSession = await restoreDesktopAuthSession();
+ if (currentSession?.refreshToken) {
+ try {
+ await logoutDesktopSession(currentSession.refreshToken);
+ } catch {
+ // ignore logout network failures and still clear local session
+ }
+ }
+ await clearStoredDesktopAuthSession(editorWindow);
+ return { success: true };
+ });
+ ipcMain.handle("clear-auth-session", async () => {
+ await clearStoredDesktopAuthSession(editorWindow);
+ return { success: true };
+ });
+ ipcMain.handle("get-device-fingerprint", async () => {
+ return await getOrCreateDeviceFingerprint();
+ });
+ ipcMain.handle(
+ "create-local-auth-account",
+ async (
+ _event,
+ payload: {
+ username: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId: string;
+ agreements: {
+ terms: boolean;
+ privacy: boolean;
+ marketing: boolean;
+ };
+ },
+ ) => {
+ try {
+ const session = await signupWithEmailViaApi(payload);
+ await writeDesktopAuthSession(session);
+ editorWindow?.webContents.send("auth-session-changed", session);
+ return { success: true, session };
+ } catch (error) {
+ if (error instanceof Error && !error.message.startsWith("fetch")) {
+ return { success: false, error: error.message };
+ }
+ if (!canUseLocalAuthFallback()) {
+ return {
+ success: false,
+ error:
+ "ํ์๊ฐ์
์๋ฒ์ ์ฐ๊ฒฐํ์ง ๋ชปํ์ต๋๋ค. ์ด์ ํ๊ฒฝ์์๋ ๋ก์ปฌ ๊ณ์ ์ผ๋ก ๊ฐ์
ํ ์ ์์ต๋๋ค.",
+ };
+ }
+ const result = await registerLocalDesktopAccount(payload);
+ if (!result.session) {
+ return { success: false, error: result.error ?? "ํ์๊ฐ์
์ ์คํจํ์ต๋๋ค." };
+ }
+
+ await writeDesktopAuthSession(result.session);
+ editorWindow?.webContents.send("auth-session-changed", result.session);
+ return { success: true, session: result.session };
+ }
+ },
+ );
+ ipcMain.handle(
+ "login-local-auth-account",
+ async (_event, payload: { identifier: string; password: string; deviceId?: string }) => {
+ const identifier = payload.identifier.trim().toLowerCase();
+ const password = payload.password.trim();
+ if (canUseHiddenTestAdmin() && identifier === "test1" && password === "test1") {
+ const session = createHiddenAdminSession();
+ await writeDesktopAuthSession(session);
+ editorWindow?.webContents.send("auth-session-changed", session);
+ return { success: true, session };
+ }
+ try {
+ const session = await loginWithEmailViaApi(payload);
+ await writeDesktopAuthSession(session);
+ editorWindow?.webContents.send("auth-session-changed", session);
+ return { success: true, session };
+ } catch (error) {
+ if (error instanceof Error && !error.message.startsWith("fetch")) {
+ return { success: false, error: error.message };
+ }
+ if (!canUseLocalAuthFallback()) {
+ return {
+ success: false,
+ error:
+ "๋ก๊ทธ์ธ ์๋ฒ์ ์ฐ๊ฒฐํ์ง ๋ชปํ์ต๋๋ค. ์ด์ ํ๊ฒฝ์์๋ ๋ก์ปฌ ๋ก๊ทธ์ธ์ผ๋ก ํด๋ฐฑํ์ง ์์ต๋๋ค.",
+ };
+ }
+ const result = await loginLocalDesktopAccount(payload);
+ if (!result.session) {
+ return { success: false, error: result.error ?? "๋ก๊ทธ์ธ์ ์คํจํ์ต๋๋ค." };
+ }
+
+ await writeDesktopAuthSession(result.session);
+ editorWindow?.webContents.send("auth-session-changed", result.session);
+ return { success: true, session: result.session };
+ }
+ },
+ );
+ ipcMain.handle(
+ "request-local-phone-verification",
+ async (
+ _event,
+ payload: { phoneNumber: string; deviceId?: string; purpose?: "signup" | "recovery" },
+ ) => {
+ const purpose = payload.purpose ?? "signup";
+ try {
+ const result = await requestPhoneVerificationViaApi({
+ phoneNumber: payload.phoneNumber,
+ purpose: purpose === "recovery" ? "login" : "signup",
+ deviceId: payload.deviceId,
+ });
+ return {
+ success: true,
+ message: result.message,
+ previewCode: app.isPackaged ? undefined : result.previewCode,
+ retryAfterSec: result.retryAfterSec,
+ expiresInSec: result.expiresInSec,
+ expiresAt: result.expiresAt,
+ };
+ } catch (error) {
+ if (error instanceof Error && !error.message.startsWith("fetch")) {
+ return { success: false, error: error.message };
+ }
+ if (!canUseLocalAuthFallback()) {
+ return {
+ success: false,
+ error:
+ "๋ฌธ์ ์ธ์ฆ ์๋ฒ์ ์ฐ๊ฒฐํ์ง ๋ชปํ์ต๋๋ค. ์ด์ ํ๊ฒฝ์์๋ ๋ก์ปฌ ์ธ์ฆ์ผ๋ก ํด๋ฐฑํ์ง ์์ต๋๋ค.",
+ };
+ }
+ return await requestLocalPhoneVerification(payload.phoneNumber, purpose);
+ }
+ },
+ );
+ ipcMain.handle(
+ "verify-local-phone-code",
+ async (
+ _event,
+ payload: {
+ phoneNumber: string;
+ code: string;
+ deviceId?: string;
+ purpose?: "signup" | "recovery";
+ },
+ ) => {
+ const purpose = payload.purpose ?? "signup";
+ try {
+ const result = await verifyPhoneCodeViaApi({
+ phoneNumber: payload.phoneNumber,
+ code: payload.code,
+ purpose: purpose === "recovery" ? "login" : "signup",
+ deviceId: payload.deviceId,
+ });
+ return {
+ success: Boolean(result.verified),
+ verificationToken: result.verificationToken,
+ expiresAt: result.expiresAt,
+ message: result.message,
+ error: result.error,
+ };
+ } catch (error) {
+ if (error instanceof Error && !error.message.startsWith("fetch")) {
+ return { success: false, error: error.message };
+ }
+ return await verifyLocalPhoneCode(payload.phoneNumber, payload.code, purpose);
+ }
+ },
+ );
+ ipcMain.handle(
+ "find-local-auth-username",
+ async (
+ _event,
+ payload: {
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ verificationToken: string;
+ },
+ ) => {
+ return await findLocalUsername(payload);
+ },
+ );
+ ipcMain.handle(
+ "reset-local-auth-password",
+ async (
+ _event,
+ payload: {
+ username: string;
+ phoneNumber: string;
+ verificationToken: string;
+ newPassword: string;
+ },
+ ) => {
+ return await resetLocalDesktopPassword(payload);
+ },
+ );
+ ipcMain.handle("write-clipboard-text", async (_event, text: string) => {
+ clipboard.writeText(text || "");
+ return { success: true };
+ });
+ ipcMain.handle("reset-mcp-token", async () => {
+ if (!mcpRuntime?.enabled) {
+ return { success: false, error: "MCP ๋ฐํ์์ด ์์ง ์ค๋น๋์ง ์์์ต๋๋ค." };
+ }
+ try {
+ const token = await mcpRuntime.resetToken();
+ return { success: true, url: mcpRuntime.url, token };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "ํ ํฐ ์ฌ๋ฐ๊ธ์ ์คํจํ์ต๋๋ค.",
+ };
+ }
+ });
+ ipcMain.handle("get-admin-backend-status", async () => {
+ const baseUrl =
+ process.env.AUTO_SCREEN_AUTH_API_URL ||
+ process.env.AUTO_SCREEN_BACKEND_URL ||
+ process.env.VITE_APP_API_URL ||
+ "http://127.0.0.1:4242";
+ const normalized = baseUrl.replace(/\/$/, "");
+ try {
+ const [statusResponse, auditResponse] = await Promise.all([
+ fetch(`${normalized}/api/admin/storage/status`),
+ fetch(`${normalized}/api/admin/signup-audit?limit=3`),
+ ]);
+ if (!statusResponse.ok) {
+ return { success: false, error: `๊ด๋ฆฌ์ ์ํ ํ์ธ ์คํจ (${statusResponse.status})` };
+ }
+ const statusPayload = await statusResponse.json();
+ const auditPayload = auditResponse.ok ? await auditResponse.json() : { logs: [] };
+ return {
+ success: true,
+ storageDriver: statusPayload.storageDriver,
+ postgres: statusPayload.postgres,
+ auditSource: auditPayload.source,
+ recentSignupAuditLogs: Array.isArray(auditPayload.logs) ? auditPayload.logs : [],
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "๊ด๋ฆฌ์ ์ํ๋ฅผ ๊ฐ์ ธ์ค์ง ๋ชปํ์ต๋๋ค.",
+ };
+ }
+ });
+ ipcMain.handle("test-mcp-connection", async () => {
+ if (!mcpRuntime?.enabled || !mcpRuntime.url || !mcpRuntime.token) {
+ return { success: false, error: "MCP ๋ฐํ์์ด ์์ง ์ค๋น๋์ง ์์์ต๋๋ค." };
+ }
+
+ try {
+ const response = await fetch(`${mcpRuntime.url}/session`, {
+ headers: {
+ Authorization: `Bearer ${mcpRuntime.token}`,
+ },
+ });
+ if (!response.ok) {
+ return { success: false, error: `MCP ์ํ ํ์ธ ์คํจ (${response.status})` };
+ }
+
+ const payload = await response.json();
+ return {
+ success: true,
+ message: payload?.state
+ ? "ํธ์ง๊ธฐ์ MCP ์ฐ๊ฒฐ์ด ์ ์์
๋๋ค."
+ : "MCP ์๋ฒ๋ ์ด์ ์์ง๋ง ์์ง ์ด๋ฆฐ ํธ์ง๊ธฐ ์ํ๊ฐ ์์ต๋๋ค.",
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "MCP ์ํ ํ์ธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.",
+ };
+ }
+ });
ipcMain.handle("set-locale", (_, locale: string) => {
setMainLocale(locale);
setupApplicationMenu();
@@ -372,29 +794,43 @@ app.whenReady().then(async () => {
await ensureRecordingsDir();
function switchToHudWrapper() {
- if (mainWindow) {
+ if (editorWindow && !editorWindow.isDestroyed()) {
+ isSwitchingToHud = true;
isForceClosing = true;
- mainWindow.close();
+ editorWindow.close();
isForceClosing = false;
- mainWindow = null;
+ return;
}
- showMainWindow();
+ showHudWindow();
}
+ const editorBridge = registerEditorCommandBridge(() => editorWindow);
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
- () => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
- showMainWindow();
+ showHudWindow();
}
},
switchToHudWrapper,
);
+ mcpRuntime = await startAutoScreenMcpServer(editorBridge);
+ editorBridge.setConnectionInfoGetter(() => ({
+ enabled: mcpRuntime?.enabled ?? false,
+ url: mcpRuntime?.url ?? "",
+ token: mcpRuntime?.token ?? "",
+ }));
+ console.log(`[Auto Screen MCP] ${mcpRuntime.url}`);
+ console.log("[Auto Screen MCP] bearer token is available in-app via MCP settings.");
createWindow();
+
+ if (pendingProtocolUrl) {
+ await handleDesktopProtocolUrl(pendingProtocolUrl, editorWindow);
+ pendingProtocolUrl = null;
+ }
});
diff --git a/electron/mcp/auth.ts b/electron/mcp/auth.ts
new file mode 100644
index 00000000..7eaee20f
--- /dev/null
+++ b/electron/mcp/auth.ts
@@ -0,0 +1,27 @@
+import { randomBytes } from "node:crypto";
+
+export interface LocalMcpAuthState {
+ token: string;
+}
+
+export function createLocalMcpAuthState(): LocalMcpAuthState {
+ return {
+ token: randomBytes(24).toString("hex"),
+ };
+}
+
+export function rotateLocalMcpToken(authState: LocalMcpAuthState) {
+ authState.token = randomBytes(24).toString("hex");
+ return authState.token;
+}
+
+export function isValidBearerToken(
+ authState: LocalMcpAuthState,
+ authorizationHeader?: string | null,
+) {
+ if (!authorizationHeader) {
+ return false;
+ }
+ const [scheme, token] = authorizationHeader.split(" ");
+ return scheme === "Bearer" && token === authState.token;
+}
diff --git a/electron/mcp/server.ts b/electron/mcp/server.ts
new file mode 100644
index 00000000..67f3622a
--- /dev/null
+++ b/electron/mcp/server.ts
@@ -0,0 +1,404 @@
+import { randomUUID } from "node:crypto";
+import type { Server as HttpServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
+import express, { type NextFunction, type Request, type Response } from "express";
+import { z } from "zod";
+import type {
+ AddSpeedRegionInput,
+ AddTrimRegionInput,
+ AutoEditOptions,
+ EditorCommandName,
+ ExportVideoCommandInput,
+ ProjectStateSnapshot,
+} from "../../src/editor/commands/types";
+import type { LocalMcpAuthState } from "./auth";
+import { createLocalMcpAuthState, isValidBearerToken, rotateLocalMcpToken } from "./auth";
+
+interface ElectronCommandBridge {
+ requestEditorCommand(
+ command: TName,
+ payload: unknown,
+ timeoutMs?: number,
+ ): Promise;
+ getLatestEditorState(): ProjectStateSnapshot | null;
+}
+
+export interface AutoScreenMcpRuntime {
+ enabled: boolean;
+ url: string;
+ token: string;
+ resetToken: () => Promise;
+ close: () => Promise;
+}
+
+function asJsonText(value: unknown) {
+ return JSON.stringify(value, null, 2);
+}
+
+function textResult(value: unknown) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: asJsonText(value),
+ },
+ ],
+ };
+}
+
+export async function startAutoScreenMcpServer(
+ bridge: ElectronCommandBridge,
+): Promise {
+ const authState: LocalMcpAuthState = createLocalMcpAuthState();
+ const runtimes = new Map<
+ string,
+ {
+ server: McpServer;
+ transport: StreamableHTTPServerTransport;
+ }
+ >();
+
+ const autoEditSchema = z.object({
+ style: z.enum(["calm", "balanced", "emphasis"]),
+ focusStrategy: z.enum(["cursor", "activity"]),
+ pauseMode: z.enum(["off", "light", "balanced", "aggressive"]),
+ backgroundMode: z.enum(["keep", "remove"]),
+ });
+
+ const trimSchema = z.object({
+ startMs: z.number().nonnegative(),
+ endMs: z.number().nonnegative(),
+ });
+
+ const speedSchema = trimSchema.extend({
+ speed: z.number().positive().optional(),
+ });
+
+ const zoomSchema = trimSchema.extend({
+ depth: z.number().int().min(1).max(6).optional(),
+ focus: z
+ .object({
+ cx: z.number(),
+ cy: z.number(),
+ })
+ .optional(),
+ focusMode: z.enum(["manual", "auto"]).optional(),
+ });
+
+ const exportSchema = z.object({
+ settings: z.object({
+ format: z.enum(["mp4", "gif"]),
+ quality: z.enum(["medium", "good", "source"]).optional(),
+ gifConfig: z.any().optional(),
+ }),
+ });
+
+ function createServerRuntime() {
+ const mcpServer = new McpServer({
+ name: "auto-screen-local",
+ version: "0.1.0",
+ });
+
+ async function runCommand(command: TName, payload: unknown) {
+ const result = await bridge.requestEditorCommand(
+ command,
+ payload,
+ command === "export_video" ? 120_000 : 20_000,
+ );
+ return textResult(result);
+ }
+
+ async function getProjectStateResult() {
+ try {
+ const result = await bridge.requestEditorCommand("get_project_state", undefined, 20_000);
+ return textResult(result);
+ } catch (error) {
+ const latestState = bridge.getLatestEditorState();
+ if (latestState) {
+ return textResult({
+ state: latestState,
+ source: "cached",
+ warning: "Editor window is not available. Returning the latest published snapshot.",
+ });
+ }
+ return textResult({
+ state: null,
+ source: "unavailable",
+ error: error instanceof Error ? error.message : String(error),
+ warning:
+ "Editor window is not available yet. Open the editor once to publish project state.",
+ });
+ }
+ }
+
+ mcpServer.registerTool(
+ "get_project_state",
+ {
+ description: "Return the current Auto Screen project state snapshot.",
+ },
+ async () => getProjectStateResult(),
+ );
+
+ mcpServer.registerTool(
+ "remove_background",
+ {
+ description: "Apply the backgroundless preset to the current project.",
+ },
+ async () => runCommand("remove_background", undefined),
+ );
+
+ mcpServer.registerTool(
+ "set_background",
+ {
+ description:
+ "Set the current project background wallpaper. Use 'none' for transparent/backgroundless.",
+ inputSchema: {
+ wallpaper: z.string(),
+ },
+ },
+ async (input) => runCommand("set_background", input),
+ );
+
+ mcpServer.registerTool(
+ "apply_auto_edit",
+ {
+ description: "Run the built-in auto edit routine over the current project.",
+ inputSchema: autoEditSchema,
+ },
+ async (input: AutoEditOptions) => runCommand("apply_auto_edit", input),
+ );
+
+ mcpServer.registerTool(
+ "add_trim_region",
+ {
+ description: "Add a trim/exclusion region to the current project timeline.",
+ inputSchema: trimSchema,
+ },
+ async (input: AddTrimRegionInput) => runCommand("add_trim_region", input),
+ );
+
+ mcpServer.registerTool(
+ "add_speed_region",
+ {
+ description: "Add a speed region to the current project timeline.",
+ inputSchema: speedSchema,
+ },
+ async (input: AddSpeedRegionInput) => runCommand("add_speed_region", input),
+ );
+
+ mcpServer.registerTool(
+ "add_zoom_region",
+ {
+ description: "Add a zoom region to the current project timeline.",
+ inputSchema: zoomSchema,
+ },
+ async (input) => runCommand("add_zoom_region", input),
+ );
+
+ mcpServer.registerTool(
+ "undo",
+ {
+ description: "Undo the last project edit.",
+ },
+ async () => runCommand("undo", undefined),
+ );
+
+ mcpServer.registerTool(
+ "redo",
+ {
+ description: "Redo the last undone project edit.",
+ },
+ async () => runCommand("redo", undefined),
+ );
+
+ mcpServer.registerTool(
+ "export_video",
+ {
+ description: "Export the current project using the current renderer-side export pipeline.",
+ inputSchema: exportSchema,
+ },
+ async (input: ExportVideoCommandInput) => runCommand("export_video", input),
+ );
+
+ return mcpServer;
+ }
+
+ function getSessionIdFromRequest(req: Request) {
+ const sessionHeader = req.header("mcp-session-id");
+ return sessionHeader && sessionHeader.length > 0 ? sessionHeader : null;
+ }
+
+ async function cleanupRuntime(sessionId: string, options?: { closeTransport?: boolean }) {
+ const runtime = runtimes.get(sessionId);
+ if (!runtime) {
+ return;
+ }
+ runtimes.delete(sessionId);
+ await runtime.server.close();
+ if (options?.closeTransport !== false) {
+ await runtime.transport.close();
+ }
+ }
+
+ async function createSessionRuntime(req: Request, res: Response) {
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (sessionId) => {
+ console.log(`[Auto Screen MCP] session initialized: ${sessionId}`);
+ runtimes.set(sessionId, { server, transport });
+ },
+ });
+ const server = createServerRuntime();
+ transport.onclose = () => {
+ const sessionId = transport.sessionId;
+ if (sessionId) {
+ console.log(`[Auto Screen MCP] session closed: ${sessionId}`);
+ void cleanupRuntime(sessionId, { closeTransport: false });
+ }
+ };
+ await server.connect(transport);
+ try {
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ const sessionId = transport.sessionId;
+ if (sessionId) {
+ await cleanupRuntime(sessionId);
+ }
+ throw error;
+ }
+ }
+
+ const app = express();
+ app.use(express.json({ limit: "10mb" }));
+
+ app.use("/mcp", (req: Request, res: Response, next: NextFunction) => {
+ if (!isValidBearerToken(authState, req.header("authorization"))) {
+ res.status(401).json({ error: "Unauthorized" });
+ return;
+ }
+ next();
+ });
+
+ app.get("/mcp/session", (req: Request, res: Response) => {
+ if (!isValidBearerToken(authState, req.header("authorization"))) {
+ res.status(401).json({ error: "Unauthorized" });
+ return;
+ }
+ res.json({
+ ok: true,
+ state: bridge.getLatestEditorState(),
+ });
+ });
+
+ app.get("/mcp", async (req: Request, res: Response) => {
+ const sessionId = getSessionIdFromRequest(req);
+ if (!sessionId) {
+ res.status(400).send("Missing MCP session ID");
+ return;
+ }
+ const runtime = runtimes.get(sessionId);
+ if (!runtime) {
+ res.status(404).send("Unknown MCP session ID");
+ return;
+ }
+ try {
+ await runtime.transport.handleRequest(req, res);
+ } catch (error) {
+ console.error("[Auto Screen MCP] transport error", error);
+ if (!res.headersSent) {
+ res.status(500).send(error instanceof Error ? error.message : String(error));
+ }
+ }
+ });
+
+ app.post("/mcp", async (req: Request, res: Response) => {
+ const sessionId = getSessionIdFromRequest(req);
+ try {
+ if (sessionId) {
+ const runtime = runtimes.get(sessionId);
+ if (!runtime) {
+ console.warn(`[Auto Screen MCP] unknown session on POST: ${sessionId}`);
+ res.status(404).json({ error: "Unknown MCP session ID" });
+ return;
+ }
+ console.log(`[Auto Screen MCP] reusing session: ${sessionId}`);
+ await runtime.transport.handleRequest(req, res, req.body);
+ return;
+ }
+
+ if (!isInitializeRequest(req.body)) {
+ res.status(400).json({ error: "Initialization request required before using MCP session" });
+ return;
+ }
+
+ await createSessionRuntime(req, res);
+ } catch (error) {
+ console.error("[Auto Screen MCP] transport error", error);
+ if (!res.headersSent) {
+ res.status(500).send(error instanceof Error ? error.message : String(error));
+ }
+ }
+ });
+
+ app.delete("/mcp", async (req: Request, res: Response) => {
+ const sessionId = getSessionIdFromRequest(req);
+ if (!sessionId) {
+ res.status(400).send("Missing MCP session ID");
+ return;
+ }
+ const runtime = runtimes.get(sessionId);
+ if (!runtime) {
+ console.warn(`[Auto Screen MCP] unknown session on DELETE: ${sessionId}`);
+ res.status(404).send("Unknown MCP session ID");
+ return;
+ }
+ try {
+ console.log(`[Auto Screen MCP] deleting session: ${sessionId}`);
+ await runtime.transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error("[Auto Screen MCP] transport error", error);
+ if (!res.headersSent) {
+ res.status(500).send(error instanceof Error ? error.message : String(error));
+ }
+ }
+ });
+
+ const httpServer = await new Promise((resolve) => {
+ const server = app.listen(0, "127.0.0.1", () => resolve(server));
+ });
+
+ const address = httpServer.address() as AddressInfo;
+ const url = `http://127.0.0.1:${address.port}/mcp`;
+
+ const runtime: AutoScreenMcpRuntime = {
+ enabled: true,
+ url,
+ token: authState.token,
+ resetToken: async () => {
+ const nextToken = rotateLocalMcpToken(authState);
+ runtime.token = nextToken;
+ for (const sessionId of [...runtimes.keys()]) {
+ await cleanupRuntime(sessionId);
+ }
+ return nextToken;
+ },
+ close: async () => {
+ for (const sessionId of [...runtimes.keys()]) {
+ await cleanupRuntime(sessionId);
+ }
+ await new Promise((resolve, reject) => {
+ httpServer.close((error: Error | undefined) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+ },
+ };
+ return runtime;
+}
diff --git a/electron/preload.ts b/electron/preload.ts
index eeca25cd..ea7188b5 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -1,4 +1,9 @@
import { contextBridge, ipcRenderer } from "electron";
+import type {
+ EditorCommandRequestEnvelope,
+ EditorCommandResponseEnvelope,
+ ProjectStateSnapshot,
+} from "../src/editor/commands/types";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
contextBridge.exposeInMainWorld("electronAPI", {
@@ -53,6 +58,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
+ getInteractionTelemetry: (videoPath?: string) => {
+ return ipcRenderer.invoke("get-interaction-telemetry", videoPath);
+ },
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
@@ -61,6 +69,68 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
+ writeClipboardText: (text: string) => {
+ return ipcRenderer.invoke("write-clipboard-text", text);
+ },
+ getAuthSession: () => {
+ return ipcRenderer.invoke("get-auth-session");
+ },
+ logoutAuthSession: () => {
+ return ipcRenderer.invoke("logout-auth-session");
+ },
+ clearAuthSession: () => {
+ return ipcRenderer.invoke("clear-auth-session");
+ },
+ getDeviceFingerprint: () => {
+ return ipcRenderer.invoke("get-device-fingerprint");
+ },
+ createLocalAuthAccount: (payload: {
+ username: string;
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ password: string;
+ verificationToken: string;
+ deviceId: string;
+ agreements: {
+ terms: boolean;
+ privacy: boolean;
+ marketing: boolean;
+ };
+ }) => {
+ return ipcRenderer.invoke("create-local-auth-account", payload);
+ },
+ loginLocalAuthAccount: (payload: { identifier: string; password: string; deviceId?: string }) => {
+ return ipcRenderer.invoke("login-local-auth-account", payload);
+ },
+ requestLocalPhoneVerification: (payload: { phoneNumber: string; deviceId?: string }) => {
+ return ipcRenderer.invoke("request-local-phone-verification", payload);
+ },
+ verifyLocalPhoneCode: (payload: { phoneNumber: string; code: string; deviceId?: string }) => {
+ return ipcRenderer.invoke("verify-local-phone-code", payload);
+ },
+ findLocalAuthUsername: (payload: {
+ familyName: string;
+ givenName: string;
+ phoneNumber: string;
+ verificationToken: string;
+ }) => {
+ return ipcRenderer.invoke("find-local-auth-username", payload);
+ },
+ resetLocalAuthPassword: (payload: {
+ username: string;
+ phoneNumber: string;
+ verificationToken: string;
+ newPassword: string;
+ }) => {
+ return ipcRenderer.invoke("reset-local-auth-password", payload);
+ },
+ onAuthSessionChanged: (callback: (session: unknown | null) => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, session: unknown | null) =>
+ callback(session);
+ ipcRenderer.on("auth-session-changed", listener);
+ return () => ipcRenderer.removeListener("auth-session-changed", listener);
+ },
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
},
@@ -142,4 +212,31 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
+ onEditorCommandRequest: (
+ callback: (request: EditorCommandRequestEnvelope) => void | Promise,
+ ) => {
+ const listener = (_event: Electron.IpcRendererEvent, request: EditorCommandRequestEnvelope) => {
+ void callback(request);
+ };
+ ipcRenderer.on("editor-command:request", listener);
+ return () => ipcRenderer.removeListener("editor-command:request", listener);
+ },
+ sendEditorCommandResponse: (response: EditorCommandResponseEnvelope) => {
+ ipcRenderer.send("editor-command:response", response);
+ },
+ publishEditorState: (snapshot: ProjectStateSnapshot) => {
+ ipcRenderer.send("editor-state:publish", snapshot);
+ },
+ getMcpConnectionInfo: () => {
+ return ipcRenderer.invoke("get-mcp-connection-info");
+ },
+ resetMcpToken: () => {
+ return ipcRenderer.invoke("reset-mcp-token");
+ },
+ getAdminBackendStatus: () => {
+ return ipcRenderer.invoke("get-admin-backend-status");
+ },
+ testMcpConnection: () => {
+ return ipcRenderer.invoke("test-mcp-connection");
+ },
});
diff --git a/electron/windows.ts b/electron/windows.ts
index fb9a6553..d94202be 100644
--- a/electron/windows.ts
+++ b/electron/windows.ts
@@ -90,7 +90,7 @@ export function createEditorWindow(): BrowserWindow {
resizable: true,
alwaysOnTop: false,
skipTaskbar: false,
- title: "OpenScreen",
+ title: "Auto Screen",
backgroundColor: "#000000",
show: !HEADLESS,
webPreferences: {
diff --git a/icons/icons/1024x1024.png b/icons/icons/1024x1024.png
new file mode 100644
index 00000000..b34d0500
Binary files /dev/null and b/icons/icons/1024x1024.png differ
diff --git a/icons/icons/128x128.png b/icons/icons/128x128.png
new file mode 100644
index 00000000..08105126
Binary files /dev/null and b/icons/icons/128x128.png differ
diff --git a/icons/icons/16x16.png b/icons/icons/16x16.png
new file mode 100644
index 00000000..8f34512e
Binary files /dev/null and b/icons/icons/16x16.png differ
diff --git a/icons/icons/24x24.png b/icons/icons/24x24.png
new file mode 100644
index 00000000..21c7562d
Binary files /dev/null and b/icons/icons/24x24.png differ
diff --git a/icons/icons/256x256.png b/icons/icons/256x256.png
new file mode 100644
index 00000000..ed472f9a
Binary files /dev/null and b/icons/icons/256x256.png differ
diff --git a/icons/icons/32x32.png b/icons/icons/32x32.png
new file mode 100644
index 00000000..edb0e808
Binary files /dev/null and b/icons/icons/32x32.png differ
diff --git a/icons/icons/48x48.png b/icons/icons/48x48.png
new file mode 100644
index 00000000..4a7b386f
Binary files /dev/null and b/icons/icons/48x48.png differ
diff --git a/icons/icons/512x512.png b/icons/icons/512x512.png
new file mode 100644
index 00000000..8190b170
Binary files /dev/null and b/icons/icons/512x512.png differ
diff --git a/icons/icons/64x64.png b/icons/icons/64x64.png
new file mode 100644
index 00000000..ed7348c2
Binary files /dev/null and b/icons/icons/64x64.png differ
diff --git a/icons/icons/icon.icns b/icons/icons/icon.icns
new file mode 100644
index 00000000..3e54a607
Binary files /dev/null and b/icons/icons/icon.icns differ
diff --git a/icons/icons/icon.ico b/icons/icons/icon.ico
new file mode 100644
index 00000000..a53757ff
Binary files /dev/null and b/icons/icons/icon.ico differ
diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns
index 7d5a493c..3e54a607 100644
Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ
diff --git a/icons/icons/win/icon.ico b/icons/icons/win/icon.ico
index 57df136d..a53757ff 100644
Binary files a/icons/icons/win/icon.ico and b/icons/icons/win/icon.ico differ
diff --git a/icons/source/autoscreen-base.png b/icons/source/autoscreen-base.png
new file mode 100644
index 00000000..8aade616
Binary files /dev/null and b/icons/source/autoscreen-base.png differ
diff --git a/index.html b/index.html
index ce1c274a..0d01d939 100644
--- a/index.html
+++ b/index.html
@@ -1,13 +1,13 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index fdbd6b92..e347c9f6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,15 @@
{
- "name": "openscreen",
- "version": "1.3.0",
+ "name": "autoscreen",
+ "version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "openscreen",
- "version": "1.3.0",
+ "name": "autoscreen",
+ "version": "1.3.1",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
+ "@modelcontextprotocol/sdk": "^1.29.0",
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -29,6 +30,7 @@
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
+ "express": "^5.2.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
@@ -47,7 +49,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
- "web-demuxer": "^4.0.0"
+ "web-demuxer": "^4.0.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
@@ -55,6 +58,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
+ "@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@@ -2132,6 +2136,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@hono/node-server": {
+ "version": "1.19.13",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
+ "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2892,6 +2908,68 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4647,6 +4725,17 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@@ -4671,6 +4760,16 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/css-font-loading-module": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
@@ -4728,6 +4827,31 @@
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
},
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
"node_modules/@types/fs-extra": {
"version": "9.0.13",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
@@ -4760,6 +4884,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
@@ -4806,6 +4937,20 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
@@ -4837,6 +4982,27 @@
"@types/node": "*"
}
},
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -5072,6 +5238,53 @@
"node": ">=6.5"
}
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -5142,6 +5355,45 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ajv-formats/node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
@@ -5921,6 +6173,46 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -6142,6 +6434,15 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/cacache": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz",
@@ -6283,7 +6584,6 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -6702,6 +7002,28 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -6709,6 +7031,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -6716,6 +7056,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
@@ -6821,7 +7178,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -6982,6 +7338,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -7249,6 +7614,12 @@
"safer-buffer": "^2.1.0"
}
},
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -7585,6 +7956,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
@@ -7776,6 +8156,12 @@
"node": ">=6"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -7800,6 +8186,15 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -7826,6 +8221,27 @@
"node": ">=0.8.x"
}
},
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
@@ -7849,25 +8265,111 @@
"dev": true,
"license": "Apache-2.0"
},
- "node_modules/extend": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
- "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/extract-zip": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
- "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "debug": "^4.1.1",
- "get-stream": "^5.1.0",
- "yauzl": "^2.10.0"
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
},
- "bin": {
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
+ "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
"extract-zip": "cli.js"
},
"engines": {
@@ -7915,7 +8417,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -7953,6 +8454,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -8035,6 +8552,27 @@
"node": ">=8"
}
},
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/fix-webm-duration": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz",
@@ -8111,6 +8649,15 @@
"node": ">= 6"
}
},
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -8152,6 +8699,15 @@
}
}
},
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -8677,6 +9233,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/hono": {
+ "version": "4.12.12",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
+ "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -8730,6 +9295,26 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -8978,7 +9563,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/invert-kv": {
@@ -8992,15 +9576,23 @@
}
},
"node_modules/ip-address": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
- "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
- "dev": true,
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -9125,6 +9717,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
@@ -9257,6 +9855,15 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/jose": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
@@ -9394,6 +10001,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -10202,6 +10815,15 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/mediabunny": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.1.tgz",
@@ -10225,6 +10847,18 @@
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
"license": "MIT"
},
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -10651,7 +11285,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -10907,7 +11540,6 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -10944,11 +11576,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -11126,6 +11769,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -11173,6 +11825,16 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -11471,6 +12133,15 @@
"url": "https://opencollective.com/pixijs"
}
},
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
@@ -11859,6 +12530,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -11911,11 +12595,10 @@
"license": "MIT"
},
"node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -11959,6 +12642,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
@@ -12435,7 +13158,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12638,6 +13360,22 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -12686,7 +13424,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "dev": true,
"license": "MIT"
},
"node_modules/sanitize-filename": {
@@ -12749,6 +13486,57 @@
"license": "MIT",
"optional": true
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/send/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/send/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
@@ -12780,6 +13568,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -12787,6 +13594,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -12813,7 +13626,6 @@
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -12833,7 +13645,6 @@
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
@@ -12850,7 +13661,6 @@
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -12869,7 +13679,6 @@
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -13143,6 +13952,15 @@
"node": ">= 6"
}
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -13923,6 +14741,15 @@
"node": ">=8.0"
}
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
@@ -14010,6 +14837,45 @@
"dev": true,
"license": "Unlicense"
},
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -14084,6 +14950,15 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -14236,6 +15111,15 @@
"spdx-expression-parse": "^3.0.0"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
@@ -15134,7 +16018,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/xhr": {
@@ -15303,6 +16186,24 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
}
}
}
diff --git a/package.json b/package.json
index 8817372e..3119cfd5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "openscreen",
+ "name": "autoscreen",
"private": true,
- "version": "1.3.0",
+ "version": "1.3.1",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
@@ -14,23 +14,31 @@
},
"scripts": {
"dev": "vite",
- "build": "tsc && vite build && electron-builder",
+ "build": "npm run build-vite && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
- "build:mac": "tsc && vite build && electron-builder --mac",
- "build:win": "tsc && vite build && electron-builder --win",
- "build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
+ "build:mac": "npm run build-vite && electron-builder --mac",
+ "build:mac:arm64": "npm run build-vite && electron-builder --mac dmg --arm64",
+ "build:mac:x64": "npm run build-vite && electron-builder --mac dmg --x64",
+ "build:win": "npm run build-vite && electron-builder --win",
+ "build:win:x64": "npm run build-vite && electron-builder --win nsis --x64",
+ "build:linux": "npm run build-vite && electron-builder --linux",
+ "build:linux:x64": "npm run build-vite && electron-builder --linux AppImage deb --x64",
+ "release:artifacts": "node scripts/release-artifacts.mjs",
+ "release:clean": "node scripts/release-clean.mjs",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:e2e": "playwright test",
+ "test:e2e:record-flow": "npm run build-vite && playwright test tests/e2e/record-flow.spec.ts",
"prepare": "husky"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
+ "@modelcontextprotocol/sdk": "^1.29.0",
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -51,6 +59,7 @@
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
+ "express": "^5.2.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
@@ -69,7 +78,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
- "web-demuxer": "^4.0.0"
+ "web-demuxer": "^4.0.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
@@ -77,6 +87,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
+ "@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
diff --git a/playwright.config.ts b/playwright.config.ts
index d268975b..ca06af4e 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
- timeout: 120_000, // GIF encoding is CPU-bound; give it room
+ timeout: 120_000,
retries: 0,
reporter: "list",
});
diff --git a/public/autoscreen-recording.png b/public/autoscreen-recording.png
new file mode 100644
index 00000000..b96ba0ea
Binary files /dev/null and b/public/autoscreen-recording.png differ
diff --git a/public/autoscreen.png b/public/autoscreen.png
new file mode 100644
index 00000000..8aade616
Binary files /dev/null and b/public/autoscreen.png differ
diff --git a/scripts/mcp-preflight.mjs b/scripts/mcp-preflight.mjs
new file mode 100644
index 00000000..5f03a342
--- /dev/null
+++ b/scripts/mcp-preflight.mjs
@@ -0,0 +1,44 @@
+async function readResponseText(response) {
+ try {
+ return await response.text();
+ } catch {
+ return "";
+ }
+}
+
+export async function runPreflight(urlString, token) {
+ if (!urlString || !token) {
+ throw new Error("Usage: node