diff --git a/.github/workflows/ui-deploy.yml b/.github/workflows/ui-deploy.yml new file mode 100644 index 0000000..5c49a51 --- /dev/null +++ b/.github/workflows/ui-deploy.yml @@ -0,0 +1,100 @@ +name: UI Deploy + +on: + push: + branches: + - main + paths: + - ui/app/** + +permissions: + contents: read + deployments: write + +concurrency: + group: ui-deploy-main + cancel-in-progress: true + +jobs: + deploy-production: + name: test, build and push UI + runs-on: ubuntu-24.04-arm + environment: + name: Production + url: https://developers.kr-filter.com + env: + REGISTRY_HOST: docker-registry.bottle-note.com + IMAGE_NAME: profanity-ui + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ui/app/package-lock.json + + - name: Install dependencies + working-directory: ui/app + run: npm ci + + - name: Run UI CI + working-directory: ui/app + run: npm run typecheck && npm run build + + - name: Prepare image tag + id: image + shell: bash + run: | + set -euo pipefail + tag="v0.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "image=${REGISTRY_HOST}/${IMAGE_NAME}:${tag}" >> "$GITHUB_OUTPUT" + + - name: Ensure registry secrets are configured + shell: bash + env: + ZOT_REGISTRY_USERNAME: ${{ secrets.ZOT_REGISTRY_USERNAME }} + ZOT_REGISTRY_PASSWORD: ${{ secrets.ZOT_REGISTRY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$ZOT_REGISTRY_USERNAME" || -z "$ZOT_REGISTRY_PASSWORD" ]]; then + echo "Production environment secrets ZOT_REGISTRY_USERNAME and ZOT_REGISTRY_PASSWORD are required." >&2 + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_HOST }} + username: ${{ secrets.ZOT_REGISTRY_USERNAME }} + password: ${{ secrets.ZOT_REGISTRY_PASSWORD }} + + - name: Build and push UI image + uses: docker/build-push-action@v6 + with: + context: ui/app + platforms: linux/arm64 + push: true + tags: ${{ steps.image.outputs.image }} + + - name: Write deployment summary + shell: bash + run: | + set -euo pipefail + { + echo "## UI deployment" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Public URL | https://developers.kr-filter.com |" + echo "| Image | ${{ steps.image.outputs.image }} |" + echo "| Commit | $GITHUB_SHA |" + echo "| Rollout | ArgoCD Image Updater |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index ec05b0c..472d739 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ application-prod.yml .omc/ /.env .DS_Store +/.playwright-mcp/ diff --git a/README.md b/README.md index e8d5467..e6b191c 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,167 @@ -image - -# 한국어 비속어 필터 API 서비스 - -> API 인증 키 발급 후 사용 가능합니다. 문서 링크를 참조해 주세요 -> -> [OpenAPI JSON](https://api.kr-filter.com/openapi.json) -> -> [Overview](https://api.kr-filter.com/overview.md) -> -> [테스트 페이지](https://api.kr-filter.com/) -> -> ~~레거시 API 주소: https://api.profanity.kr-filter.com~~ (지원 종료 예정) -> -> 헬스 체크 -> - https://api.kr-filter.com/api/v1/ping -> - https://api.kr-filter.com/api/v1/health - -- 기존 `x-api-key` 신규 발급은 중단 예정이며, 이미 발급된 키는 호환성 유지를 위해 유지합니다. -- Google/GitHub SSO 로그인용 JWT access token과 rotating refresh token을 지원합니다. -- OAuth2 Client Credentials 기반 외부 API Bearer 인증은 아직 미구현이며, 향후 대시보드의 API 클라이언트 발급 기능과 함께 제공할 예정입니다. - -## Overview - -이 서비스는 한국어 비속어를 모두 검출하고 필터링할 수 있는 무료 API입니다. - -### 주요 특징 - -- **경량 필터링 엔진**: 정규식과 비속어 데이터베이스를 활용한 효율적인 필터링 -- **고성능 검사**: `아호코라식 알고리즘`을 사용하여 빠르고, 정확한 비속어 검출 -- **다양한 필터링 모드**: 빠른 검사(QUICK), 일반 검사(NORMAL), 대체 검사(FILTER) 지원 -- **KISO 호환성**: [KISO 이용자 보호 시스템 API](https://www.safekiso.com/)와 유사한 스펙으로 구현 - -### 이용 대상 - -이 API는 주로 다음과 같은 사용자를 위해 설계되었습니다: - -- 포트폴리오나 취미 프로젝트를 개발하는 학생 및 개발자 -- 비영리 서비스를 운영하는 소규모 단체 -- 비용은 최소화하면서 기본적인 비속어 필터링이 필요한 웹사이트/앱 - -예산 제약 없이 상업적 서비스에 활용하실 경우에는 [KISO 이용자 보호 시스템 API](https://www.safekiso.com/)를 권장합니다(월 약 7만원). - -~~이 서비스는 개인 서버로 운영되므로 가용성은 보장되지 않지만,~~ 2025-05 기준 OCP 환경으로 이관하였습니다. - -기본적인 비속어 필터링 기능을 무료로 제공하는 데 의의가 있습니다. - -## API Guide - -### 인증 전환 방향 - -현재 외부 API 호출은 기존 `x-api-key` 헤더를 사용합니다. 사람의 대시보드 로그인은 Google/GitHub SSO 완료 후 발급되는 `LOGIN_JWT`와 rotating refresh token을 사용하며, 외부 API 인증과 분리되어 있습니다. - -OAuth2 Client Credentials의 `/oauth2/token`, `client_id/client_secret`, 외부 API용 Bearer access token은 다음 단계의 범위입니다. 현재 외부 API에 제출된 Bearer token은 지원되지 않는 `OAUTH2_ACCESS_TOKEN` 경계에서 fail-closed 처리하며, 기존 API Key 동작은 유지합니다. 상세 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)을 참고하세요. - -- [ADR 0005. SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md) -- [ADR 0006. OAuth2 Client Credentials 기반 API 인증 전환](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md) - -### 현재 API 호출 방식 - -- 요청 URL: `POST https://api.kr-filter.com/api/v1/filter` -- headers - - `Content-Type: application/json` or `application/x-www-form-urlencoded` - - `accept: application/json` - - `x-api-key: {API_KEY}` - - API_KEY는 제공되는 API_KEY를 사용해주세요. -- parameters: - - *`text`: 검증할 문장 (예: "나쁜말") - - *`mode`: `QUICK`,`NORMAL`,`FILTER` 중 하나 선택 - - `QUICK`: 빠른 검사에 적합합니다. - - `NORMAL`: 일반적인 검사에 적합합니다. - - `FILTER`: 일반적인 검사후 비속어를 `*`로 대체합니다. - - `callbackUrl`: 비동기 처리시 결과를 받을 URL - - \* 기호가 붙은 파라미터는 필수 입력값입니다. - -### Response Code - -- 요청에 대한 HTTP Status Code는 대부분 200으로 응답됩니다. -- 응답 객체에서는 요청에 따라 변동적인 `status.code`에 응답 코드가 포함되어 있습니다. -- `Status Code`는 KISO 이용자 보호 시스템 API 서비스의 응답 코드를 참조하여 작성되었습니다. - -| Status Code | Description | Description | -|-------------|-----------------------|--------------------------------------------------------------| -| 2000 | OK | 요청이 정상적으로 처리된 상태를 의미합니다. | -| 2020 | Accepted | 비동기 요청이 정상적으로 접수된 상태를 의미합니다. | -| 2021 | Processing | 요청 처리가 진행 중인 상태를 의미합니다. | -| 4000 | Bad Request | 요청이 비정상적인 경우 입니다. 파라미터 누락,타입 오류등이 있습니다, 상세 내용을 참고하세요. | -| 4001 | Invalid Callback URL | 콜백 URL 형식이 올바르지 않은 경우 발생합니다. | -| 4002 | Invalid Tracking ID | Tracking ID가 유효하지 않은 경우 발생합니다. | -| 4003 | Not Fount Tracking ID | Tracking ID를 찾을 수 없는 경우 발생합니다. | -| 4004 | Ambiguous Credentials | 다중 또는 중복 인증 정보를 제출한 경우 발생합니다. | -| 4010 | Unauthorized | 요청을 인증할 API 키 값이 없는 경우 발생하는 오류 입니다. | -| 4011 | OAuth2 Login Failed | Google/GitHub SSO 로그인에 실패한 경우 발생합니다. | -| 4012 | Login Code Invalid | 로그인 교환 코드가 잘못됐거나 만료 또는 재사용된 경우 발생합니다. | -| 4013 | Login Token Invalid | 로그인 access token 검증에 실패한 경우 발생합니다. | -| 4014 | Login Token Expired | 로그인 access token이 만료된 경우 발생합니다. | -| 4015 | Refresh Token Invalid | refresh token 또는 session이 잘못됐거나 만료·폐기된 경우 발생합니다. | -| 4016 | Refresh Token Reused | 이미 소비된 refresh token이 다시 제출된 경우 발생합니다. | -| 4017 | OAuth2 Token Unsupported | 외부 API용 OAuth2 access token이 아직 지원되지 않는 경우 발생합니다. | -| 4030 | Forbidden | 서버에서 요청에 API 키값을 인식하였으나 해당 키가 적절한 권한을 가지지 않았다고 판정한 경우 발생합니다. | -| 4031 | Not Found Client | API Key에 해당하는 클라이언트 정보를 찾을 수 없는 경우 발생합니다. | -| 4032 | Invalid API Key | API Key가 유효하지 않은 경우 발생합니다. | -| 4033 | User Inactive | 로그인 사용자가 비활성 상태인 경우 발생합니다. | -| 4290 | Too Many Requests | 특정 클라이언트가 너무 많은 요청을 단위 시간 안에 보낸 경우에 이 응답이 리턴됩니다. | -| 5000 | Internal Server Error | 서버 측의 문제로 요청에 대한 처리가 불가능한 경우 오류가 발생하였음을 알리기 위해 본 코드를 사용합니다. | -| 5030 | Service Unavailable | 서비스 점검 또는 일시 사용 불가 상태를 의미합니다. | - -### Usage Guide - -#### **응답 예** +# 한국어 비속어 필터 API -```json -{ - "trackingId": "bee20667-aa5a-4d39-94f5-0f2dcbd51cac", - "status": { - "code": 2000, - "message": "Ok", - "description": "정상적으로 처리 되었습니다.", - "DetailDescription": "" - }, - "detected": [ - { - "length": 1, - "filteredWord": "나" - }, - { - "length": 2, - "filteredWord": "나쁜" - }, - { - "length": 3, - "filteredWord": "나쁜말" - }, - { - "length": 2, - "filteredWord": "냐쁀" - } - ], - "filtered": "*** 이런 개 ** 짓을 왜 하냐?, **, *", - "elapsed": "0.00007676 s / 0.07676 ms / 76.758 µs" -} +Aho-Corasick 알고리즘을 기반으로 한국어 비속어를 검출하고 마스킹하는 REST API입니다. `QUICK`, `NORMAL`, `FILTER` 모드를 지원하며 API Key 인증으로 사용할 수 있습니다. + +## 바로가기 + +| 구분 | URL | +|---|---| +| 개발자 포털 | [https://developers.kr-filter.com](https://developers.kr-filter.com) | +| API 기준 주소 | `https://api.kr-filter.com` | +| API 문서 | [https://developers.kr-filter.com/docs](https://developers.kr-filter.com/docs) | +| OpenAPI JSON | [https://api.kr-filter.com/openapi.json](https://api.kr-filter.com/openapi.json) | +| API 개요 | [https://api.kr-filter.com/overview.md](https://api.kr-filter.com/overview.md) | +| LLM 문서 | [https://api.kr-filter.com/llms.txt](https://api.kr-filter.com/llms.txt) | +| 상태 확인 | [health](https://api.kr-filter.com/api/v1/health) · [ping](https://api.kr-filter.com/api/v1/ping) | + +`api.profanity.kr-filter.com`은 레거시 주소이며 신규 연동에서는 사용하지 않습니다. + +## 인증 모델 + +사람의 개발자 포털 로그인과 외부 API 호출 인증은 서로 다른 자격 증명을 사용합니다. + +| 용도 | 방식 | 상태 | +|---|---|---| +| 개발자 포털 로그인 | Google/GitHub SSO 및 `LOGIN_JWT` | 제공 중 | +| 외부 API 호출 | `x-api-key: {API_KEY}` | 제공 중 | +| 외부 API 호출 | OAuth2 Client Credentials Bearer token | 준비 중, 현재 비활성화 | + +API Key와 OAuth2 Client Credentials는 모두 SSO 로그인 후 발급하는 정책으로 전환합니다. 다만 현재 운영 API에는 기존 공개 발급 경로가 남아 있으므로, 백엔드 전환이 완료되기 전까지 아래 API 목록의 현재 상태를 기준으로 사용해야 합니다. + +외부 API에 아직 지원하지 않는 Bearer token을 보내면 HTTP `401`, business code `4017`로 거부됩니다. 로그인 JWT를 외부 API Key 대신 사용할 수도 없습니다. + +자세한 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)과 다음 ADR을 참고하세요. + +- [ADR 0005: SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md) +- [ADR 0006: 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md) + +## 빠른 시작 + +```bash +curl --request POST 'https://api.kr-filter.com/api/v1/filter' \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json' \ + --header 'x-api-key: YOUR_API_KEY' \ + --data '{ + "text": "검사할 문장", + "mode": "FILTER" + }' ``` -## Examples +필터링 모드는 다음과 같습니다. + +| 모드 | 동작 | +|---|---| +| `QUICK` | 첫 번째 매칭을 빠르게 확인합니다. | +| `NORMAL` | 매칭된 비속어를 모두 반환합니다. | +| `FILTER` | 매칭된 비속어를 `*`로 마스킹합니다. | + +`text`와 `mode`는 필수이며, 비동기 결과가 필요하면 `callbackUrl`을 추가할 수 있습니다. 동일한 `/api/v1/filter` 경로에서 JSON과 `application/x-www-form-urlencoded` 요청을 모두 지원합니다. -- [cURL Guide](examples/curl.md) -- [Java Guide](examples/java.md) -- [JavaScript Guide](examples/javascript.md) +## API 목록 -## 주의사항 +아래 목록은 현재 운영 OpenAPI와 서버 보안 정책을 기준으로 합니다. 요청·응답 스키마와 실행 가능한 예제는 [개발자 포털 API 문서](https://developers.kr-filter.com/docs)에서 확인할 수 있습니다. -### 서비스 이용 제한 +### 공개 및 문서 -- **사용 목적**: 이 서비스는 포트폴리오, 학습용 프로젝트, 비영리 서비스를 위해 제공됩니다. 상업적/영리 목적으로 활용하시려면 [KISO 이용자 보호 시스템 API 서비스](https://www.safekiso.com/)를 이용해 주세요. +| Method | Path | 설명 | +|---|---|---| +| `GET` | `/openapi.json` | OpenAPI 문서 | +| `GET` | `/overview.md` | API 개요, 인증 및 오류 모델 | +| `GET` | `/llms.txt` | LLM용 문서 인덱스 | +| `GET` | `/api/v1/health` | 애플리케이션 상태 확인 | +| `GET` | `/api/v1/ping` | 연결 확인 | -- **가용성**: 개인 서버로 운영되는 무료 서비스이므로 100% 가용성을 보장하지 않습니다. 서버 장애, 하드웨어 문제, 네트워크 이슈 등으로 일시적 중단이 발생할 수 있습니다. +### SSO 로그인 -- **성능 제한**: 과도한 API 호출 시 서비스 품질 유지를 위해 요청 제한(rate limiting)이 적용될 수 있습니다. +| Method | Path | 설명 | 인증 | +|---|---|---|---| +| `GET` | `/oauth2/authorization/{google\|github}` | 소셜 로그인 시작 | 공개 | +| `POST` | `/api/v1/auth/exchange` | 일회용 로그인 코드를 access token으로 교환 | 공개 코드 | +| `GET` | `/api/v1/auth/csrf` | refresh 요청용 CSRF token 조회 | refresh cookie | +| `POST` | `/api/v1/auth/refresh` | 로그인 access token 갱신 | refresh cookie와 CSRF | +| `GET` | `/api/v1/auth/me` | 로그인 사용자 조회 | `LOGIN_JWT` | -### 기타 고려사항 +로그인 access token은 15분, refresh token은 14일이며 refresh session의 절대 수명은 30일입니다. 브라우저 로그인 흐름은 개발자 포털이 처리하므로 일반 API 사용자가 직접 구현할 필요는 없습니다. -- **API 변경**: API 스펙은 개선을 위해 변경될 수 있습니다. 변경 시에는 이 문서를 통해 사전 공지할 예정입니다. +### 비속어 필터 -- **개인정보**: API를 통해 전송되는 텍스트는 비속어 필터링 목적으로만 사용되며, 별도로 저장하지 않습니다. 단, 서비스 개선을 위한 기본적인 사용 통계는 수집될 수 있습니다. +| Method | Path | 설명 | 인증 | +|---|---|---|---| +| `POST` | `/api/v1/filter` | 비속어 필터링 요청 | API Key | +| `POST` | `/api/v1/filter/advanced` | 단일 단어 기반 고급 마스킹 | API Key | -- **책임 제한**: 이 API의 결과에 의존하여 발생한 문제(필터링 실패, 잘못된 검출 등)에 대해 개발자는 법적 책임을 지지 않습니다. +### 클라이언트 및 API Key -이 서비스는 최대한 KISO 이용자 보호 시스템 API 서비스와 유사한 형태로 발전해 나갈 예정이지만, 무료 서비스로서의 한계가 있음을 양해해 주시기 바랍니다. +| Method | Path | 설명 | 현재 인증 | +|---|---|---|---| +| `GET` | `/api/v1/clients` | 클라이언트 정보 조회 | API Key | +| `DELETE` | `/api/v1/clients` | 클라이언트 폐기 | API Key | +| `POST` | `/api/v1/clients/update` | 클라이언트 정보 변경 | API Key | +| `POST` | `/api/v1/clients/reissue` | API Key 재발급 | API Key | +| `POST` | `/api/v1/clients/register` | 레거시 신규 클라이언트 등록 | 공개, 로그인 기반 발급으로 전환 예정 | +| `GET` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 발송 | 공개 | +| `PUT` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 검증 | 공개 | + +### 관리 API + +| Method | Path | 설명 | 인증 | +|---|---|---|---| +| `POST` | `/api/v1/word/request` | 비속어 단어 변경 요청 | API Key | +| `POST` | `/api/v1/word/accept/{requestId}` | 단어 변경 요청 승인 | WRITE 권한 API Key | +| `GET` | `/api/v1/sync` | 비속어 데이터 수동 동기화 | 관리자 API Key 및 password | + +## 응답과 오류 + +대부분의 비즈니스 결과는 HTTP status와 함께 응답 본문의 `status.code`로도 전달됩니다. 전체 code와 오류 조건은 [Overview의 Error Model](https://api.kr-filter.com/overview.md)에서 관리합니다. + +```json +{ + "trackingId": "bee20667-aa5a-4d39-94f5-0f2dcbd51cac", + "status": { + "code": 2000, + "message": "Ok" + }, + "detected": [], + "filtered": "검사 결과", + "elapsed": "0.07676 ms" +} +``` + +## 로컬 개발 + +요구 환경은 Java 21, Node.js 22, Docker입니다. + +```bash +# 백엔드 검증 +./gradlew staticCheck unitTest supportTest apiE2eTest + +# 백엔드 실행 +./gradlew :profanity-api:bootRun + +# 프론트엔드 실행 +npm --prefix ui/app ci +npm --prefix ui/app run dev + +# 프론트엔드 검증 +npm --prefix ui/app run typecheck +npm --prefix ui/app run build +``` -#### 문의사항은 Issue로 등록하거나 이메일로 문의바랍니다. +기본 로컬 주소는 API `http://localhost:8080`, UI `http://localhost:5173`입니다. -[ Post. 비속어 검증 API 서비스 만들기 ](https://deadwhale.me/posts/profanity-filter-api/) +## 배포 -## 문의 및 연락처 +- 백엔드는 GitHub Release 발행 시 ARM64 이미지를 빌드해 `docker-registry.bottle-note.com/profanity-api`로 push합니다. +- 프론트엔드는 `main`의 `ui/app/**` 변경 시 CI를 통과한 뒤 ARM64 이미지를 `docker-registry.bottle-note.com/profanity-ui`로 push합니다. +- ArgoCD Image Updater가 새 semver 이미지를 감지하고 ArgoCD가 K3s 운영 환경에 자동으로 롤아웃합니다. +- 운영 manifest는 [deploy/overlays/production](deploy/overlays/production)에서 관리합니다. -문의사항은 Issue로 등록하거나 이메일로 문의바랍니다. +## 이용 시 주의사항 +- 무료 개인 운영 서비스이므로 100% 가용성을 보장하지 않습니다. +- 과도한 호출에는 rate limiting이 적용될 수 있습니다. +- 필터 결과가 모든 문맥과 표현을 완벽하게 판별한다고 보장하지 않습니다. +- 상업적 서비스에는 운영 요구사항과 결과 정확도를 별도로 검토해야 합니다. - - - - -
- - - -
- 📧 rlagusrl928@gmail.com -
-
+문의와 오류 제보는 GitHub Issue를 이용해 주세요. diff --git a/deploy/overlays/production/kustomization.yaml b/deploy/overlays/production/kustomization.yaml index 85b91ee..86717e0 100644 --- a/deploy/overlays/production/kustomization.yaml +++ b/deploy/overlays/production/kustomization.yaml @@ -9,7 +9,12 @@ resources: - external-secret.yaml - http-route.yaml - redis.yaml + - ui-deployment.yaml + - ui-service.yaml + - ui-http-route.yaml images: - name: docker-registry.bottle-note.com/profanity-api newTag: v0.0.1 + - name: docker-registry.bottle-note.com/profanity-ui + newTag: v0.0.0 diff --git a/deploy/overlays/production/ui-deployment.yaml b/deploy/overlays/production/ui-deployment.yaml new file mode 100644 index 0000000..d1e68ae --- /dev/null +++ b/deploy/overlays/production/ui-deployment.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: profanity-ui + labels: + app: profanity-ui + environment: production +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: profanity-ui + template: + metadata: + labels: + app: profanity-ui + environment: production + spec: + nodeSelector: + kubernetes.io/arch: arm64 + imagePullSecrets: + - name: zot-registry-secret + containers: + - name: profanity-ui + image: docker-registry.bottle-note.com/profanity-ui + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + protocol: TCP + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "250m" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/deploy/overlays/production/ui-http-route.yaml b/deploy/overlays/production/ui-http-route.yaml new file mode 100644 index 0000000..c8c711a --- /dev/null +++ b/deploy/overlays/production/ui-http-route.yaml @@ -0,0 +1,16 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: profanity-ui-route +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: main-gateway + namespace: envoy-gateway-system + hostnames: + - developers.kr-filter.com + rules: + - backendRefs: + - name: profanity-ui + port: 80 diff --git a/deploy/overlays/production/ui-service.yaml b/deploy/overlays/production/ui-service.yaml new file mode 100644 index 0000000..14da2ae --- /dev/null +++ b/deploy/overlays/production/ui-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: profanity-ui + labels: + app: profanity-ui + environment: production +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + selector: + app: profanity-ui diff --git "a/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" "b/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" index 00942d8..40cb902 100644 --- "a/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" +++ "b/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" @@ -1,32 +1,35 @@ -# 6. OAuth2 Client Credentials 기반 API 인증 전환 +# 6. 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입 ## Status -제안 (2026.06.30) +채택 (2026.07.17) ## Context 현재 외부 API 호출 인증은 `x-api-key` 헤더 중심이다. 이 방식은 단순하지만 API Key 원문이 매 요청마다 전달되고, 표준 OAuth2 기반 OpenAPI 문서화나 외부 개발자 경험 측면에서 한계가 있다. -앞으로는 SSO 기반 대시보드에서 사용자가 API 호출용 자격증명을 발급하고, 실제 API 호출은 표준적인 Bearer 토큰 기반으로 전환해야 한다. 동시에 기존에 발급된 `x-api-key`는 호환성 때문에 즉시 중단할 수 없다. +앞으로는 SSO 기반 대시보드에서 인증된 사용자가 API 호출용 자격증명을 발급해야 한다. API Key는 빠르고 단순한 연동에 유용하고, OAuth2 Client Credentials는 짧은 수명의 Bearer token과 표준 인증 흐름이 필요한 운영 환경에 적합하다. 따라서 인증 경계는 다음처럼 분리되어야 한다. - 대시보드 인증: Google/GitHub SSO 세션 -- 신규 API 인증: OAuth2 Client Credentials 방식의 Bearer access token -- 기존 API 인증: 이미 발급된 legacy `x-api-key` +- 간편 API 인증: 로그인 후 발급한 `x-api-key` +- 표준 API 인증: OAuth2 Client Credentials 방식의 Bearer access token +- 기존 API 인증: 이미 발급된 `x-api-key` ## Decision -신규 API 호출 인증은 OAuth2 Client Credentials 기반으로 제공하고, 기존 `x-api-key` 인증은 legacy 호환 경로로 유지한다. +API Key와 OAuth2 Client Credentials를 모두 제공하되, 두 자격 증명은 Google 또는 GitHub SSO 로그인 후 대시보드에서만 발급한다. -- SSO 로그인 사용자는 대시보드에서 API 클라이언트를 생성한다. +- 비로그인 공개 `POST /api/v1/clients/register`를 통한 API Key 발급은 종료한다. +- SSO 로그인 사용자는 대시보드에서 API Key 또는 OAuth2 Client Credentials를 선택해 발급한다. +- API Key는 간단한 연동을 위한 정식 자격 증명으로 계속 제공하며 `x-api-key` 헤더로 호출한다. +- 기존 API Key도 동일한 인증 경로에서 계속 검증한다. - API 클라이언트 생성 시 `client_id`와 `client_secret`을 발급한다. - `client_secret`은 최초 1회만 노출하고, 서버에는 원문이 아닌 hash를 저장한다. - 토큰 발급 엔드포인트는 `POST /oauth2/token`으로 둔다. - 1차 구현 범위는 `grant_type=client_credentials`만 허용한다. - `/oauth2/token`은 `client_id`와 `client_secret`을 검증해 짧은 수명의 access token을 발급한다. - 신규 API 호출은 `Authorization: Bearer {access_token}`을 사용한다. -- 기존 `x-api-key`는 신규 발급을 중단하되, 기존 발급분은 legacy 인증 필터에서 계속 검증한다. -- 외부 API 엔드포인트는 전환 기간 동안 Bearer token과 legacy `x-api-key`를 모두 허용한다. -- 대시보드 엔드포인트는 SSO 세션만 허용하고, legacy `x-api-key`로 접근할 수 없게 한다. +- 외부 API 엔드포인트는 Bearer token과 `x-api-key`를 모두 허용한다. +- 대시보드 엔드포인트와 자격 증명 발급 API는 SSO 로그인 token만 허용하고, `x-api-key`로 접근할 수 없게 한다. ## Implementation Status @@ -38,16 +41,18 @@ - 구현된 Bearer 인증은 사람의 대시보드 접근을 위한 `LOGIN_JWT`이며, 본 ADR의 외부 API access token과 다른 credential이다. ## Consequences -- 신규 API 인증은 OAuth2 표준 형식을 따르므로 OpenAPI 문서, SDK, 외부 개발자 경험을 개선할 수 있다. +- 사용자는 연동 복잡도와 운영 요구에 따라 API Key와 Client Credentials 중 하나를 선택할 수 있다. +- OAuth2 인증은 표준 형식을 따르므로 OpenAPI 문서, SDK, 외부 개발자 경험을 개선할 수 있다. - `client_id/client_secret`은 토큰 발급에만 사용하고, 실제 API 호출에는 짧은 수명의 access token을 사용한다. - access token 검증 후 SecurityContext에는 API 호출 주체를 나타내는 principal을 세팅한다. -- legacy `x-api-key`와 신규 Bearer token은 인증 수단은 다르지만, 내부 권한 판단에는 유사한 principal 모델을 제공해야 한다. -- rate limit은 인증 방식이 아니라 내부 식별자 기준으로 적용한다. 신규 방식은 API client id, legacy 방식은 기존 key id 또는 legacy client id를 기준으로 한다. -- 운영 기간 동안 두 인증 방식을 혼용하므로 SecurityFilterChain, 인증 실패 응답, rate limit 헤더, 관측 로그에서 인증 타입을 명확히 구분해야 한다. -- 기존 키 폐기 일정은 별도 운영 정책으로 결정한다. 이 ADR은 기존 키의 신규 발급 중단과 호환 유지까지만 결정한다. +- `x-api-key`와 Bearer token은 인증 수단은 다르지만, 내부 권한 판단에는 유사한 principal 모델을 제공해야 한다. +- rate limit은 인증 방식이 아니라 내부 식별자 기준으로 적용한다. OAuth2 방식은 API client id, API Key 방식은 key id 또는 API client id를 기준으로 한다. +- 두 인증 방식을 함께 제공하므로 SecurityFilterChain, 인증 실패 응답, rate limit 헤더, 관측 로그에서 인증 타입을 명확히 구분해야 한다. +- API Key도 로그인 사용자와 소유 관계를 가져야 하며, 발급·재발급·폐기 작업은 대시보드 인증 경계 안에서 수행한다. ## Alternatives -- 기존 `x-api-key` 방식 유지: 구현은 가장 단순하지만 신규 대시보드와 표준 인증 모델에 맞지 않아 제외한다. +- API Key만 제공: 구현은 가장 단순하지만 표준 인증과 짧은 수명 token이 필요한 운영 환경을 지원하지 못해 제외한다. +- OAuth2 Client Credentials만 제공: 인증 모델은 단순해지지만 간단한 연동의 진입 장벽이 커져 제외한다. - 기존 `x-api-key`를 즉시 폐기: 보안 모델은 단순해지지만 기존 사용자의 API 호출을 깨뜨리므로 제외한다. - OAuth2 Authorization Code를 API 호출 인증에 사용: 브라우저 사용자의 위임 인증에는 적합하지만 서버 간 API 호출용으로는 과하므로 제외한다. - refresh token 제공: Client Credentials 1차 범위에서는 필요하지 않다. 만료 시 `client_id/client_secret`으로 access token을 다시 발급하면 된다. diff --git a/module.platform b/module.platform index e6aa579..8156ac5 160000 --- a/module.platform +++ b/module.platform @@ -1 +1 @@ -Subproject commit e6aa579c09827b6308ed89ebb7fec1727d826d1c +Subproject commit 8156ac5da60ee7e4bfed2e9d3f54ffd3677afde8 diff --git a/profanity-api/src/main/resources/application-sso.yaml b/profanity-api/src/main/resources/application-sso.yaml index c603511..f0fea9e 100644 --- a/profanity-api/src/main/resources/application-sso.yaml +++ b/profanity-api/src/main/resources/application-sso.yaml @@ -6,12 +6,14 @@ spring: github: client-id: ${GITHUB_OAUTH_CLIENT_ID:stub-github-client} client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:stub-github-secret} + redirect-uri: ${API_SERVER_URL:http://localhost:8080}/login/oauth2/code/{registrationId} scope: - read:user - user:email google: client-id: ${GOOGLE_OAUTH_CLIENT_ID:stub-google-client} client-secret: ${GOOGLE_OAUTH_CLIENT_SECRET:stub-google-secret} + redirect-uri: ${API_SERVER_URL:http://localhost:8080}/login/oauth2/code/{registrationId} scope: - openid - profile diff --git a/ui/.gitignore b/ui/.gitignore index 80271b1..7cc89a7 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -15,6 +15,12 @@ coverage/ .nuxt/ .svelte-kit/ +# Local product-design review evidence +app/design-audit/ +app/design-audit.md +app/design-qa-evidence/ +app/design-qa.md + # TypeScript/Vite generated files *.tsbuildinfo vite.config.js diff --git a/ui/API_AND_USAGE_FLOWS.md b/ui/API_AND_USAGE_FLOWS.md new file mode 100644 index 0000000..63e77a2 --- /dev/null +++ b/ui/API_AND_USAGE_FLOWS.md @@ -0,0 +1,126 @@ +# API와 사용 플로우 + +기준: 2026-07-17 운영 `https://api.kr-filter.com/openapi.json`과 `main` 브랜치의 인증 정책. + +## 1. 제공 API + +인증 경계는 다음 세 가지로 분리된다. + +| 구분 | Credential | 사용 범위 | +| --- | --- | --- | +| Public | 없음 | SSO 시작·교환·갱신, 클라이언트 등록·복구, 문서, 상태 확인 | +| Login JWT | `Authorization: Bearer {accessToken}` | 로그인 사용자 조회와 향후 `/api/v1/dashboard/**` | +| API Key | `x-api-key: {apiKey}` | 필터, 클라이언트 관리, 단어 관리, 동기화 | + +Login JWT를 API Key 대신 사용할 수 없으며, 현재 외부 API용 OAuth2 access token은 지원하지 않는다. + +### SSO 진입 + +| Method | Path | 역할 | +| --- | --- | --- | +| GET | `/oauth2/authorization/github` | GitHub SSO 시작 | +| GET | `/oauth2/authorization/google` | Google SSO 시작 | +| GET | `/login/oauth2/code/{provider}` | 서버가 처리하는 OAuth callback | + +### 로그인 인증 + +| Method | Path | 인증 | 역할 | +| --- | --- | --- | --- | +| POST | `/api/v1/auth/exchange` | Public | 일회용 로그인 코드를 access token과 refresh cookie로 교환 | +| GET | `/api/v1/auth/csrf` | Public | refresh 요청에 사용할 CSRF token 조회 | +| POST | `/api/v1/auth/refresh` | Refresh cookie + CSRF | access token 갱신과 refresh token rotation | +| GET | `/api/v1/auth/me` | Login JWT | 현재 로그인 사용자 조회 | + +현재 `/api/v1/dashboard/**`는 Login JWT 보호 경로만 예약되어 있고 실제 endpoint는 없다. 로그아웃 endpoint도 없다. + +### API Key 발급과 관리 + +| Method | Path | 인증 | 역할 | +| --- | --- | --- | --- | +| POST | `/api/v1/clients/register` | Public | `name`, `email`, `issuerInfo`, `note`로 신규 API Key 발급 | +| GET | `/api/v1/clients/send-email` | Public | `email` query의 기존 등록 이메일로 인증 코드 발송 | +| PUT | `/api/v1/clients/send-email` | Public | 이메일과 인증 코드를 검증하고 기존 API Key 반환 | +| GET | `/api/v1/clients` | API Key | 현재 클라이언트 정보 조회 | +| POST | `/api/v1/clients/update` | API Key | 발급자 정보와 메모 수정 | +| POST | `/api/v1/clients/reissue` | API Key | API Key 재발급 | +| DELETE | `/api/v1/clients` | API Key | 클라이언트 폐기 | + +`send-email` 두 API는 신규 등록 전 이메일 인증이 아니라 이미 등록된 이메일의 API Key 복구 흐름이다. 현재 SSO 사용자와 API Key 클라이언트를 연결하는 API는 없다. + +### 비속어 필터 + +| Method | Path | 인증 | 역할 | +| --- | --- | --- | --- | +| POST | `/api/v1/filter` | API Key | `QUICK`, `NORMAL`, `FILTER` 모드의 문장 필터링 | +| POST | `/api/v1/filter/advanced` | API Key | `word` query 기반 단일 필터링 | + +`/api/v1/filter`에 `callbackUrl`이 없으면 동기 결과를 반환하고, 값이 있으면 접수 결과를 반환한 뒤 같은 `trackingId` 기준으로 callback을 시도한다. + +### 단어 관리 + +| Method | Path | 인증 | 역할 | +| --- | --- | --- | --- | +| POST | `/api/v1/word/request` | API Key | 단어 추가·제거·수정 요청 | +| POST | `/api/v1/word/accept/{requestId}` | API Key + WRITE 권한 | 단어 변경 요청 승인 | + +### 운영 + +| Method | Path | 인증 | 역할 | +| --- | --- | --- | --- | +| GET | `/api/v1/sync` | API Key + `password` query | 비속어 데이터 수동 동기화 | +| GET | `/api/v1/health` | Public | 상태 확인 | +| GET | `/api/v1/ping` | Public | 연결 확인 | +| GET | `/openapi.json` | Public | 운영 OpenAPI 문서 | +| GET | `/overview.md` | Public | API 개요 문서 | +| GET | `/llms.txt`, `/llm.txt` | Public | LLM용 문서 | + +## 2. 사용 플로우 + +### 사람 로그인 + +1. 사용자가 GitHub 또는 Google 로그인을 선택한다. +2. 브라우저를 `/oauth2/authorization/{provider}`로 이동한다. +3. 서버가 OAuth callback을 처리하고 frontend 로그인 경로의 URL fragment에 60초 수명의 일회용 `code`를 전달한다. +4. UI가 `POST /api/v1/auth/exchange`로 코드를 한 번 교환한다. +5. access token은 응답 body에서 받아 메모리에만 보관하고, refresh token은 `HttpOnly` cookie로 유지한다. +6. UI가 access token으로 `GET /api/v1/auth/me`를 호출해 사용자 정보를 확인한다. +7. access token 갱신이 필요하면 `GET /api/v1/auth/csrf` 후 반환된 header 이름과 token으로 `POST /api/v1/auth/refresh`를 호출한다. +8. 갱신 응답의 새 access token으로 메모리 상태를 교체한다. + +### API Key 신규 발급 + +1. 사용자가 이름 또는 조직명, 이메일, 발급자 정보와 메모를 입력한다. +2. UI가 `POST /api/v1/clients/register`를 호출한다. +3. 반환된 API Key를 한 번 명확하게 노출하고 사용자가 안전하게 복사하도록 한다. +4. 이후 외부 API 호출에는 `x-api-key` 헤더를 사용한다. + +신규 발급 전에 `send-email` 인증을 거치지 않는다. + +### 기존 API Key 복구 + +1. 사용자가 기존 등록 이메일을 입력한다. +2. UI가 `GET /api/v1/clients/send-email?email=...`로 인증 코드를 요청한다. +3. 사용자가 이메일로 받은 코드를 입력한다. +4. UI가 이메일과 코드를 `PUT /api/v1/clients/send-email`로 검증한다. +5. 성공 응답의 기존 API Key를 노출하고 복사할 수 있게 한다. + +### 필터 사용 + +1. 사용자가 API Key, 검사 문장, 처리 모드를 입력한다. +2. UI가 `x-api-key` 헤더와 함께 `POST /api/v1/filter`를 호출한다. +3. `QUICK`은 첫 감지, `NORMAL`은 감지 목록, `FILTER`는 마스킹된 문장을 보여준다. +4. HTTP 상태와 별도로 응답 body의 `status.code`를 성공·실패 판단 기준으로 사용한다. +5. `trackingId`, 감지 목록, 필터링 결과와 오류 상세를 구분해 보여준다. +6. 비동기 모드에서는 `callbackUrl`을 전달하고 접수 상태와 최종 callback 결과를 별도 단계로 취급한다. + +### API Key 관리 + +1. API Key로 `GET /api/v1/clients`를 호출해 현재 정보를 확인한다. +2. 정보 수정은 `/update`, 키 교체는 `/reissue`, 폐기는 `DELETE /api/v1/clients`로 각각 분리한다. +3. 재발급과 폐기는 기존 credential에 영향을 주는 작업이므로 실행 전 확인 단계를 둔다. + +### 단어 변경 요청 + +1. 사용자가 단어, 사유, 심각도와 요청 타입을 입력한다. +2. UI가 `POST /api/v1/word/request`로 요청한다. +3. WRITE 권한을 가진 클라이언트만 request ID를 사용해 `/accept/{requestId}`로 승인한다. diff --git a/ui/INFORMATION_ARCHITECTURE.md b/ui/INFORMATION_ARCHITECTURE.md new file mode 100644 index 0000000..6ea8c5f --- /dev/null +++ b/ui/INFORMATION_ARCHITECTURE.md @@ -0,0 +1,206 @@ +# UI Information Architecture + +기준일: 2026-07-17 + +이 문서는 UI의 메뉴, route, 접근 권한과 초기 사용 흐름을 정의한다. 시각 디자인과 컴포넌트 구현은 이 구조를 변경하지 않는다. + +근거 문서: + +- [ADR 0005. SSO 기반 사용자 계정 모델 도입](../docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md) +- [ADR 0006. 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입](../docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md) +- [API와 사용 플로우](./API_AND_USAGE_FLOWS.md) + +## 제품 원칙 + +- 사람 로그인과 외부 API 호출 인증을 분리한다. +- 사람 로그인은 Google 또는 GitHub SSO만 제공한다. +- API Key를 먼저 제공하고 OAuth2 Client Credentials는 후속 업데이트로 제공한다. +- 두 자격 증명은 Google 또는 GitHub SSO 로그인 후에만 발급한다. +- 비로그인 공개 API Key 발급은 제공하지 않는다. +- API가 준비되기 전에는 자격 증명 발급을 동작하는 UI처럼 표현하지 않는다. +- 초기 UI가 호출하는 제품 API는 로그인 완료와 세션 유지에 필요한 인증 API로 제한한다. + +## 메뉴 트리 + +```text +Public +├── 소개 +├── API 문서 +└── 로그인 + +Signed-in utility +└── 현재 사용자 + └── 내 계정 +``` + +소개는 제품 설명, 로그인 후 시작 안내, 자격 증명 비교를 포함하는 단일 개요 페이지다. 별도의 Signed-in 메뉴 행은 두지 않는다. 로그인 후에도 `소개`와 `API 문서`는 전역 링크로 접근하고, `로그인`은 현재 사용자 진입점으로 대체한다. + +## Route 정의 + +| 영역 | 메뉴 | Route | 접근 | 초기 역할 | +| --- | --- | --- | --- | --- | +| Public | 소개 | `/` | 누구나 | 서비스 목적, 인증 전환 방향과 지원 범위를 설명 | +| Public | API 문서 | `/docs` | 누구나 | 운영 OpenAPI와 사용 문서를 읽기 전용으로 제공 | +| Public | 로그인 | `/login` | 비로그인 | Google·GitHub SSO 진입과 로그인 상태 표시 | +| Signed-in | 내 계정 | `/app/account` | Login JWT | 현재 사용자 기본 정보 읽기 전용 표시 | + +기존 `/app`, `/app/credentials` 경로는 `/`로 이동하는 호환 경로로만 유지한다. + +### 접근 규칙 + +- 비로그인 사용자가 `/app` 또는 `/app/account`에 접근하면 `/login`으로 이동한다. +- 로그인 사용자가 `/login`에 접근하면 `/app`으로 이동한다. +- 알 수 없는 route는 별도 기능으로 추정하지 않고 Not Found 상태를 표시한다. +- 권한이나 API가 없는 메뉴는 disabled 상태로 미리 노출하지 않는다. 단, 자격 증명 비교와 API 문서에서는 후속 OAuth2 방식을 `추후 제공` 상태로 명확히 구분해 미리 볼 수 있다. + +## 메뉴 표현 규칙 + +### 테마와 타이포그래피 + +- 모든 Public 및 Signed-in 화면은 light mode와 dark mode를 동등하게 지원한다. +- 초기 진입은 운영체제의 `prefers-color-scheme`을 따르고, 사용자가 선택한 테마는 브라우저에 저장한다. +- 테마 전환은 전역 사용자 영역에서 언제든 접근할 수 있어야 한다. +- 테마 전환 컨트롤은 아이콘과 switch 상태만으로 표현하고 `다크`, `라이트` 문자를 반복 노출하지 않는다. +- light와 dark는 단순 색상 반전이 아니라 동일한 정보 위계, 대비와 상태 의미를 유지한다. +- 본문과 UI는 현대적인 한국어 sans-serif를 사용하고, 장식적인 serif나 손글씨 계열을 핵심 UI 글꼴로 사용하지 않는다. +- 색상만으로 active, warning, recommended 상태를 전달하지 않는다. + +### 비로그인 상태 + +- 전역 메뉴에는 `소개`, `API 문서`, `로그인`만 표시한다. +- 비로그인 상태에서는 신규 발급, 신청하기, API Key 만들기 같은 CTA를 표시하지 않는다. +- 로그인 CTA는 Google 또는 GitHub SSO 선택으로 이어진다. + +### 로그인 상태 + +- 전역 메뉴의 `로그인` 자리는 현재 사용자 진입점으로 바뀐다. +- 별도의 Signed-in 메뉴 행은 표시하지 않는다. +- `소개` 안에서 시작 안내와 자격 증명 비교를 연속된 섹션으로 제공한다. +- `내 계정`은 우측 현재 사용자 버튼으로만 진입한다. +- `Playground`, `단어 관리`, `운영` 메뉴는 초기 범위에 포함하지 않는다. + +## 화면별 정보 구조 + +### 소개 및 개요 + +1. 한국어 비속어 필터 API라는 제품 정체성 +2. 신규 사용자는 SSO 로그인 후 API Key 또는 OAuth2 Client Credentials를 발급한다는 안내 +3. 로그인 상태에 맞춘 시작 안내 +4. API Key와 OAuth2 Client Credentials 비교 및 생성 진입 +5. 현재 문서 확인과 로그인으로 이어지는 행동 +6. 비로그인 공개 API Key 발급 종료 안내 + +소개 화면은 API endpoint 목록이나 가짜 대시보드 데이터를 보여주지 않는다. + +### API 문서 + +1. `/overview.md`의 `#` 제목을 그대로 사용하는 문서 앵커 메뉴 +2. 문서 메뉴와 API 그룹 메뉴 사이의 구분선 +3. `/openapi.json`의 tag 순서를 따르는 API 그룹 메뉴 +4. 기본적으로 접힌 API 그룹과, 펼쳤을 때 summary를 표시하는 operation 앵커 +5. Scalar 기반 OpenAPI reference +6. API Key와 OAuth2 Client Credentials 사용 방식의 구분 및 OAuth2의 `추후 제공` 상태 + +문서 화면은 읽기 전용이다. API 실행 버튼과 credential 입력 기능을 제공하지 않는다. +문서 내용은 운영 `/overview.md`와 `/openapi.json`을 애플리케이션 진입 시 선조회하고 같은 session에서 재사용한다. 두 요청 외의 제품 API를 호출하지 않는다. +문서 앵커를 선택하면 Markdown의 해당 `#` 제목으로 이동한다. API 그룹은 클릭으로 펼치거나 접고, operation summary를 선택하면 같은 `/docs` route의 hash만 변경하여 Scalar의 해당 operation으로 이동한다. + +### 로그인 + +1. Google SSO +2. GitHub SSO +3. checking, anonymous, exchanging, authenticated, failed 상태 +4. 로그인 이후 `/app`으로 이동 + +### 자격 증명 + +1. API Key: 빠르고 단순한 연동 +2. OAuth2 Client Credentials: 운영·서버 간 연동 권장, 현재는 `추후 제공` 상태 +3. 각 방식의 발급 순서와 환경 변수 기반 요청 예시 +4. 자격 증명 목록에서는 API Key, Client Secret, access token 원문을 표시하지 않음 + +소개 페이지의 하위 섹션에서 두 방식을 같은 높이와 같은 정보 순서로 비교한다. OAuth2 Client Credentials는 내용을 읽을 수 있게 유지하되 팻말 형태의 `추후 제공` 표시와 disabled 상태로 생성·복사 상호작용을 차단한다. Client Secret은 후속 구현에서도 별도의 발급 완료 단계에서 최초 1회만 제공한다. + +### 내 계정 + +1. 표시 이름 +2. primary email +3. avatar +4. 로그인 상태 + +초기 버전은 `GET /api/v1/auth/me`가 반환하는 정보만 표시한다. OAuth provider 연결·해제, 이름 수정, 탈퇴, 로그아웃은 해당 API가 마련되기 전까지 제공하지 않는다. + +## 핵심 사용 흐름 + +### 로그인 + +```text +소개 또는 로그인 +→ Google/GitHub 선택 +→ 서버 OAuth callback +→ 일회용 code 교환 +→ access token은 메모리에 보관 +→ /api/v1/auth/me 확인 +→ 시작 +``` + +### 세션 복구 + +```text +앱 진입 +→ CSRF token 조회 +→ refresh cookie rotation +→ 새 access token을 메모리에 보관 +→ /api/v1/auth/me 확인 +→ 시작 또는 기존 route 복귀 +``` + +### 문서 탐색 + +```text +소개 또는 Signed-in 전역 메뉴 +→ API 문서 +→ Overview / Authentication / OpenAPI reference 탐색 +``` + +## 초기 API 허용 범위 + +현재 API 구현이 완료되기 전 UI 애플리케이션 코드는 다음 경로만 호출할 수 있다. + +| Method | Path | 목적 | +| --- | --- | --- | +| GET | `/oauth2/authorization/google` | Google SSO 시작 | +| GET | `/oauth2/authorization/github` | GitHub SSO 시작 | +| POST | `/api/v1/auth/exchange` | 일회용 로그인 code 교환 | +| GET | `/api/v1/auth/csrf` | refresh용 CSRF token 조회 | +| POST | `/api/v1/auth/refresh` | 로그인 session 복구와 rotation | +| GET | `/api/v1/auth/me` | 현재 사용자 확인 | +| GET | `/openapi.json` | API 문서 조회 | +| GET | `/overview.md` | API 개요 조회 | + +## 금지 및 보류 범위 + +### 비로그인 UI에서 차단 + +- `POST /api/v1/clients/register` +- API Key 및 Client Credentials 발급 CTA, form, 자동 호출 + +서버 endpoint가 존재하더라도 비로그인 UI에서는 접근 경로나 호출 코드를 만들지 않는다. + +### 현재 API 구현 전 보류 + +- 로그인 기반 API Key 발급·조회·재발급·폐기 +- API client 생성과 `client_id`, `client_secret` 최초 1회 노출 +- `POST /oauth2/token` + +`자격 증명` 메뉴는 유지하되 실제 API가 마련되기 전에는 준비 중 상태만 표시한다. + +### API 구현 후 추가 + +- 기존 공개 발급 API 폐쇄와 로그인 기반 API Key 발급 API +- 로그인 사용자와 기존 API Key의 소유 관계 정리 +- API Key 및 API client 목록·재발급·폐기 API +- Client Credentials access token 기반 Playground +- 로그아웃, 계정 변경, OAuth provider 연결 관리 + +이 항목들은 현재 IA의 메뉴나 화면 수에 포함하지 않는다. diff --git a/ui/README.md b/ui/README.md deleted file mode 100644 index d7cb634..0000000 --- a/ui/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# profanity-filter-api frontend - -이 디렉터리는 `profanity-filter-api`의 프론트엔드 실험과 공개 문서 UI를 관리합니다. -현재 실제로 빌드하고 확인하는 앱은 `sample-app`입니다. - -## 현재 구조 - -```text -ui/ -├── README.md # FE 영역 전체 안내 -├── openapi.yaml # 코드 기준으로 정리한 수동 OpenAPI 스펙 -└── sample-app/ # Vite + React + TypeScript 샘플 앱 -``` - -`sample-app`은 공개 랜딩 페이지와 `/docs` 문서 화면을 제공합니다. -이전 정적 목업은 제거했고, 앞으로 FE 판단과 수정은 `sample-app`을 기준으로 합니다. - -## 현재 앱이 하는 일 - -- `/`: 서비스 소개용 공개 랜딩 페이지를 렌더링합니다. -- `/docs`: API 서버의 `https://api.kr-filter.com/openapi.json`을 읽어 API 문서를 렌더링합니다. -- `sample-app/public/overview.md`: 문서 첫 화면에서 보여줄 시작 가이드와 오류 모델 설명을 제공합니다. -- `sample-app/src/demo/ConsoleDemo.tsx`: API Key 발급과 필터 테스트 콘솔 후보 화면입니다. 아직 라우트에 연결하지 않았습니다. - -## 실행 - -```bash -cd ui/sample-app -npm install -npm run dev -``` - -운영 빌드와 로컬 미리보기: - -```bash -cd ui/sample-app -npm run build -npm run preview -``` - -문서 구조 검사: - -```bash -cd ui/sample-app -npm run test:docs -``` - -## 기술 선택 - -현재 샘플 앱은 다음 조합을 사용합니다. - -- Vite: 개발 서버와 정적 빌드 도구 -- React: 랜딩 페이지와 문서 화면 컴포넌트 구성 -- TypeScript: API 문서와 화면 상태 타입 안정성 확보 -- Scalar API Reference: OpenAPI 문서 렌더링 -- Nginx static container: k3s에서 정적 파일 서빙 - -향후 실제 콘솔 기능을 붙일 때 검토할 후보는 다음과 같습니다. - -- TanStack Query: API 호출 상태, 캐시, 재시도 관리 -- React Hook Form + Zod: 발급 폼, 문의 폼, 단어 제안 입력 검증 -- shadcn/ui + lucide-react: 운영형 UI 컴포넌트와 아이콘 구성 - -## 앞으로 만들 화면 후보 - -현재 PR 범위는 공개 랜딩과 API 문서입니다. -콘솔이나 계정성 기능은 별도 작업으로 분리하는 것이 좋습니다. - -- API Key 발급 -- API Key 재발급과 폐기 -- 비속어 필터 테스트 콘솔 -- 단어 추가, 제거, 수정 요청 -- 문의 접수 - -## 백엔드와 맞춰야 할 계약 - -- 문의 API를 만들지, `mailto`나 외부 폼으로 시작할지 결정해야 합니다. -- API Key를 브라우저에 저장할지, 사용자가 매번 입력하게 할지 결정해야 합니다. -- 관리자 단어 승인 API는 현재 백엔드 구현 상태를 확인한 뒤 화면 제공 여부를 정해야 합니다. -- 공개 문서의 OpenAPI JSON은 API 서버가 제공하는 `/openapi.json`을 source-of-truth로 둡니다. - -## 배포 구상 - -```text -ui/sample-app - -> npm run build - -> dist/ - -> Docker image - -> k3s Deployment - -> Service - -> HTTPRoute -``` - -처음에는 하나의 FE 앱 안에서 라우팅만 나눠도 충분합니다. -도메인을 분리할 경우 공개 페이지, 문서, 콘솔의 책임을 먼저 나눈 뒤 결정합니다. diff --git a/ui/app/.dockerignore b/ui/app/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/ui/app/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/ui/sample-app/.gitignore b/ui/app/.gitignore similarity index 100% rename from ui/sample-app/.gitignore rename to ui/app/.gitignore diff --git a/ui/sample-app/Dockerfile b/ui/app/Dockerfile similarity index 100% rename from ui/sample-app/Dockerfile rename to ui/app/Dockerfile diff --git a/ui/sample-app/index.html b/ui/app/index.html similarity index 66% rename from ui/sample-app/index.html rename to ui/app/index.html index 9665b16..bf27980 100644 --- a/ui/sample-app/index.html +++ b/ui/app/index.html @@ -3,6 +3,8 @@ + + 말조심하세욧 diff --git a/ui/sample-app/nginx.conf b/ui/app/nginx.conf similarity index 65% rename from ui/sample-app/nginx.conf rename to ui/app/nginx.conf index 1f651fb..6d4bdc7 100644 --- a/ui/sample-app/nginx.conf +++ b/ui/app/nginx.conf @@ -9,12 +9,6 @@ server { try_files $uri $uri/ /index.html; } - location = /overview.md { - default_type text/markdown; - charset utf-8; - try_files /overview.md =404; - } - location = /health { access_log off; return 200 "OK\n"; diff --git a/ui/sample-app/package-lock.json b/ui/app/package-lock.json similarity index 91% rename from ui/sample-app/package-lock.json rename to ui/app/package-lock.json index f8b1c1f..e8b260b 100644 --- a/ui/sample-app/package-lock.json +++ b/ui/app/package-lock.json @@ -1,17 +1,18 @@ { - "name": "profanity-filter-console-sample", + "name": "profanity-filter-ui", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "profanity-filter-console-sample", + "name": "profanity-filter-ui", "version": "0.0.0", "dependencies": { - "@scalar/api-reference-react": "^0.9.47", + "@fontsource-variable/noto-sans-kr": "^5.2.10", + "@phosphor-icons/react": "^2.1.10", + "@scalar/api-reference-react": "^0.9.56", "react": "^19.2.1", - "react-dom": "^19.2.1", - "yaml": "^2.9.0" + "react-dom": "^19.2.1" }, "devDependencies": { "@types/node": "^26.0.0", @@ -376,13 +377,13 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", - "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.6.0", + "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } @@ -472,9 +473,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.12.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", - "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -497,21 +498,21 @@ } }, "node_modules/@codemirror/state": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", - "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.43.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", - "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.6.0", + "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" @@ -960,34 +961,34 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/core/node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@floating-ui/utils": { @@ -1033,6 +1034,15 @@ } } }, + "node_modules/@fontsource-variable/noto-sans-kr": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-kr/-/noto-sans-kr-5.2.10.tgz", + "integrity": "sha512-UZOO7HF44Rt5+7SCeFMHYVgbKu36Jet6IxrAd7jjEkQMVDmeefwd0H8V5pSZydvBOOxClk3V5cQsujJqGHhQMw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@headlessui/tailwindcss": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", @@ -1134,9 +1144,9 @@ "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", - "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -1218,9 +1228,9 @@ } }, "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", "license": "MIT" }, "node_modules/@opentelemetry/api": { @@ -1238,6 +1248,19 @@ "integrity": "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==", "license": "MIT" }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@replit/codemirror-css-color-picker": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@replit/codemirror-css-color-picker/-/codemirror-css-color-picker-6.3.0.tgz", @@ -1607,24 +1630,24 @@ ] }, "node_modules/@scalar/agent-chat": { - "version": "0.12.11", - "resolved": "https://registry.npmjs.org/@scalar/agent-chat/-/agent-chat-0.12.11.tgz", - "integrity": "sha512-JX1rFryU6kHHcMzzewr9o4Qe0st/27A8AFvka7v0OWsIoNJHcO9aA5gitedci6eZJNsNSBj6bXpH5VrCU1jAfA==", + "version": "0.12.20", + "resolved": "https://registry.npmjs.org/@scalar/agent-chat/-/agent-chat-0.12.20.tgz", + "integrity": "sha512-DvIGnYHF819dIuFxfCj8fGB3GfB3q59FS2MQQFOQeuCKZVElBH/wgF/cCZyncFn6Dcm7N1NZ7FJ8+Yb87KiodA==", "license": "MIT", "dependencies": { "@ai-sdk/vue": "3.0.33", - "@scalar/api-client": "3.10.4", - "@scalar/components": "0.27.2", - "@scalar/helpers": "0.8.2", - "@scalar/icons": "0.7.3", - "@scalar/json-magic": "0.12.16", - "@scalar/openapi-types": "0.9.1", - "@scalar/schemas": "0.5.0", - "@scalar/themes": "0.16.0", - "@scalar/types": "0.14.0", - "@scalar/use-toasts": "0.10.2", - "@scalar/validation": "0.6.0", - "@scalar/workspace-store": "0.54.4", + "@scalar/api-client": "3.13.5", + "@scalar/components": "0.27.7", + "@scalar/helpers": "0.9.1", + "@scalar/icons": "0.7.4", + "@scalar/json-magic": "0.12.18", + "@scalar/openapi-types": "0.9.2", + "@scalar/schemas": "0.7.3", + "@scalar/themes": "0.16.3", + "@scalar/types": "0.16.3", + "@scalar/use-toasts": "0.10.3", + "@scalar/validation": "0.6.1", + "@scalar/workspace-store": "0.55.5", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.7.8", @@ -1637,29 +1660,28 @@ } }, "node_modules/@scalar/api-client": { - "version": "3.10.4", - "resolved": "https://registry.npmjs.org/@scalar/api-client/-/api-client-3.10.4.tgz", - "integrity": "sha512-TwmbM6hnzPs1vZUzucczNkPTzxTOtV+gu+TIq8/yEZBYF7GC6+6YerxPrGYjTgifp2UbIYf9GlSvmZv8RzSkIQ==", + "version": "3.13.5", + "resolved": "https://registry.npmjs.org/@scalar/api-client/-/api-client-3.13.5.tgz", + "integrity": "sha512-3hMqxTUH2ITe0OQb5J48W6NHs10L5IN8Xe/M1GtiZjNSUnDAkCRJiJaCfsTfk1ogDcyB4RHdnB5SQtapkpWC1A==", "license": "MIT", "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", - "@scalar/components": "0.27.2", - "@scalar/helpers": "0.8.2", - "@scalar/icons": "0.7.3", - "@scalar/json-magic": "0.12.16", - "@scalar/oas-utils": "0.18.5", - "@scalar/openapi-types": "0.9.1", - "@scalar/sidebar": "0.9.23", - "@scalar/snippetz": "0.9.17", - "@scalar/themes": "0.16.0", + "@scalar/blocks": "0.1.6", + "@scalar/components": "0.27.7", + "@scalar/helpers": "0.9.1", + "@scalar/icons": "0.7.4", + "@scalar/oas-utils": "0.19.6", + "@scalar/openapi-types": "0.9.2", + "@scalar/sidebar": "0.9.31", + "@scalar/snippetz": "0.9.22", + "@scalar/themes": "0.16.3", "@scalar/typebox": "^0.1.3", - "@scalar/types": "0.14.0", - "@scalar/use-codemirror": "0.14.12", - "@scalar/use-hooks": "0.4.7", - "@scalar/use-toasts": "0.10.2", - "@scalar/workspace-store": "0.54.4", - "@types/har-format": "^1.2.16", + "@scalar/types": "0.16.3", + "@scalar/use-codemirror": "0.14.13", + "@scalar/use-hooks": "0.4.8", + "@scalar/use-toasts": "0.10.3", + "@scalar/workspace-store": "0.55.5", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", @@ -1679,9 +1701,9 @@ } }, "node_modules/@scalar/api-client/node_modules/nanoid": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz", - "integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -1697,28 +1719,29 @@ } }, "node_modules/@scalar/api-reference": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@scalar/api-reference/-/api-reference-1.60.0.tgz", - "integrity": "sha512-godMRo3E/m6uZtm0zVfDa5xCg1IGD+eEt4uVWzR0v62mbm2O70ZWY9l+ondg8CrPQFsCdtWpWW+nYdzvp1tWTw==", + "version": "1.62.7", + "resolved": "https://registry.npmjs.org/@scalar/api-reference/-/api-reference-1.62.7.tgz", + "integrity": "sha512-mY5QflySFwrsHnb3m7a9gP5p92cO8na/C0+UtgRX0FPq1bRQ1U7KiHIzcGjSY4rnF81aSLQvyo5rmzuxTEKVJA==", "license": "MIT", "dependencies": { "@headlessui/vue": "1.7.23", - "@scalar/agent-chat": "0.12.11", - "@scalar/api-client": "3.10.4", - "@scalar/code-highlight": "0.3.6", - "@scalar/components": "0.27.2", - "@scalar/helpers": "0.8.2", - "@scalar/icons": "0.7.3", - "@scalar/oas-utils": "0.18.5", - "@scalar/schemas": "0.5.0", - "@scalar/sidebar": "0.9.23", - "@scalar/snippetz": "0.9.17", - "@scalar/themes": "0.16.0", - "@scalar/types": "0.14.0", - "@scalar/use-hooks": "0.4.7", - "@scalar/use-toasts": "0.10.2", - "@scalar/validation": "0.6.0", - "@scalar/workspace-store": "0.54.4", + "@scalar/agent-chat": "0.12.20", + "@scalar/api-client": "3.13.5", + "@scalar/blocks": "0.1.6", + "@scalar/code-highlight": "0.4.2", + "@scalar/components": "0.27.7", + "@scalar/helpers": "0.9.1", + "@scalar/icons": "0.7.4", + "@scalar/oas-utils": "0.19.6", + "@scalar/schemas": "0.7.3", + "@scalar/sidebar": "0.9.31", + "@scalar/snippetz": "0.9.22", + "@scalar/themes": "0.16.3", + "@scalar/types": "0.16.3", + "@scalar/use-hooks": "0.4.8", + "@scalar/use-toasts": "0.10.3", + "@scalar/validation": "0.6.1", + "@scalar/workspace-store": "0.55.5", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.1.0", @@ -1732,13 +1755,13 @@ } }, "node_modules/@scalar/api-reference-react": { - "version": "0.9.47", - "resolved": "https://registry.npmjs.org/@scalar/api-reference-react/-/api-reference-react-0.9.47.tgz", - "integrity": "sha512-2vT7WCgU2AY2wrB+Xit09b76h5uQsQ/zP68LyTKSPYG8FIYeVsuGkrpIJGbtY/f57d3rQCqrbrZjLb30VzuWKw==", + "version": "0.9.56", + "resolved": "https://registry.npmjs.org/@scalar/api-reference-react/-/api-reference-react-0.9.56.tgz", + "integrity": "sha512-xshLWYX3WQbbz/bFfAURV03UUm/zUMg0UsEEEEuGPks/yqkGSl/bio39osuHCSD05uw1s+hH0hKEXCyYdRgxEg==", "license": "MIT", "dependencies": { - "@scalar/api-reference": "1.60.0", - "@scalar/types": "0.14.0" + "@scalar/api-reference": "1.62.7", + "@scalar/types": "0.16.3" }, "engines": { "node": ">=22" @@ -1748,9 +1771,9 @@ } }, "node_modules/@scalar/api-reference/node_modules/nanoid": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz", - "integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -1765,10 +1788,43 @@ "node": "^18 || >=20" } }, + "node_modules/@scalar/asyncapi-upgrader": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@scalar/asyncapi-upgrader/-/asyncapi-upgrader-0.1.3.tgz", + "integrity": "sha512-ab+fjILyHA7j+tFqhZ+S7/uP5EKiBPbX32ZvfoK68UE+fEIDndwFrlfSr54zYXAa9fUxacXgNiKGnGZ2QxjE1Q==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/blocks": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@scalar/blocks/-/blocks-0.1.6.tgz", + "integrity": "sha512-URXPhhQu2/YRtdQJOqKeG3/gwpQlM6oE/7ihLKzx0O2MOrTjSkL9Pb0deDoVfuQ6IE/Mnax9FgOdfAuMhDbMfw==", + "license": "MIT", + "dependencies": { + "@scalar/components": "0.27.7", + "@scalar/helpers": "0.9.1", + "@scalar/icons": "0.7.4", + "@scalar/snippetz": "0.9.22", + "@scalar/themes": "0.16.3", + "@scalar/types": "0.16.3", + "@scalar/workspace-store": "0.55.5", + "@types/har-format": "^1.2.16", + "js-base64": "^3.7.8", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@scalar/code-highlight": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@scalar/code-highlight/-/code-highlight-0.3.6.tgz", - "integrity": "sha512-G/EtLF6a3IFGfIv5b7Uj5v/yXFY9+ktViVkfAkSoWJdHNBNk0S/Rb2OgRq24kp0D2TDXhLYF3E9OSHzquS7WqQ==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@scalar/code-highlight/-/code-highlight-0.4.2.tgz", + "integrity": "sha512-TLrMYd+Ts6j/hfIT9t7VPydaJDQIV49x+zPds/+4S+yFqXMmbns7lt1Xx24w9JYdEWAmGcrJNWgUDrV5p9oz9Q==", "license": "MIT", "dependencies": { "hast-util-to-text": "^4.0.2", @@ -1792,20 +1848,20 @@ } }, "node_modules/@scalar/components": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@scalar/components/-/components-0.27.2.tgz", - "integrity": "sha512-cwIf042ehbD4dZWUIIlXZp0VWvaH7jznj/gpuLEqtuEDla5MrhTWYWj20aufpTpCeGexf1mqgouaWHUZ7lYByQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@scalar/components/-/components-0.27.7.tgz", + "integrity": "sha512-DFQEXvsWUAHBcXsz+Cyc6+r6jCEduXtwsQbFjIIjdRT9uYCO4Rj7llVy9vrr/1/3rKCZIROP5AJxKwTG915XTw==", "license": "MIT", "dependencies": { "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", - "@scalar/code-highlight": "0.3.6", - "@scalar/helpers": "0.8.2", - "@scalar/icons": "0.7.3", - "@scalar/themes": "0.16.0", - "@scalar/use-hooks": "0.4.7", + "@scalar/code-highlight": "0.4.2", + "@scalar/helpers": "0.9.1", + "@scalar/icons": "0.7.4", + "@scalar/themes": "0.16.3", + "@scalar/use-hooks": "0.4.8", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", @@ -1817,18 +1873,18 @@ } }, "node_modules/@scalar/helpers": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.2.tgz", - "integrity": "sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.1.tgz", + "integrity": "sha512-UKSLIPfN++f+zzbsZ+F6I0lNrR4yX4PtokjHgGwGiHapxT5VJqAF4zFTIP2KCd27XG7KnAIA7qq62O4xRBxc6w==", "license": "MIT", "engines": { "node": ">=22" } }, "node_modules/@scalar/icons": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@scalar/icons/-/icons-0.7.3.tgz", - "integrity": "sha512-5uSUvumj6yJEAZT7/MKpgrkNl76waDXUpu0kUBBFJ83GhZirBIK+Z9SktShEvJT0+rk/j9Zaer3BHoEhYwAEBQ==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@scalar/icons/-/icons-0.7.4.tgz", + "integrity": "sha512-pr83SmvijorirYd2UUD2PouCpoUKC+tCByg93yoCh9jaIk+l0eoKuX+jpHjaB7/EQXsGSU26TyNjiKJC50f0sQ==", "license": "MIT", "dependencies": { "@phosphor-icons/core": "^2.1.1", @@ -1841,9 +1897,9 @@ } }, "node_modules/@scalar/icons/node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1856,12 +1912,12 @@ "license": "MIT" }, "node_modules/@scalar/json-magic": { - "version": "0.12.16", - "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.12.16.tgz", - "integrity": "sha512-w8cDbZhHCzmIblWx92IVWoAXsbI4Fz3m++jiBANTSO1hgphF6UqEPQiCt3wnMPaxaanjMQxjS/iBk1UGXR2EGA==", + "version": "0.12.18", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.12.18.tgz", + "integrity": "sha512-3oZr+jUUiwD3C+x2CROBtlyIUnwt9ScRhFfczuOTPZlD7Pnb5cyTTfkiHbxxU4qd66ij0Z3JA4X/Nj0OADyEiA==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", + "@scalar/helpers": "0.9.1", "pathe": "^2.0.3", "yaml": "^2.8.3" }, @@ -1870,15 +1926,15 @@ } }, "node_modules/@scalar/oas-utils": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/@scalar/oas-utils/-/oas-utils-0.18.5.tgz", - "integrity": "sha512-GLL0DPEaKkIrDmkwY83RDnCKQRuzw1u6MjGlEX5iVs/LDjQRvWnkAz9A5iUrH/YHr+ike5NIl3/WzaEWiz6x6A==", + "version": "0.19.6", + "resolved": "https://registry.npmjs.org/@scalar/oas-utils/-/oas-utils-0.19.6.tgz", + "integrity": "sha512-aSlZZqTxjGsIPSv/+d8ZFLvploBnNeWwR7k0+LKHDI+2MnfiN0/ZaLmuT9y2wfO5q+e+Kl3B8ewrFBgdzWv3hg==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", - "@scalar/themes": "0.16.0", - "@scalar/types": "0.14.0", - "@scalar/workspace-store": "0.54.4", + "@scalar/helpers": "0.9.1", + "@scalar/themes": "0.16.3", + "@scalar/types": "0.16.3", + "@scalar/workspace-store": "0.55.5", "flatted": "^3.4.0", "vue": "^3.5.30", "yaml": "^2.8.3" @@ -1888,51 +1944,51 @@ } }, "node_modules/@scalar/openapi-types": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.9.1.tgz", - "integrity": "sha512-gkGhSkxSzADaBiNg+ZAbJuwj+ZUmzP2Pg9CWZ7ZP+0fck2WjPeDDM7aAbouAm0aQQMF9xBjSPXSA9a/qTHYaTw==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.9.2.tgz", + "integrity": "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g==", "license": "MIT", "engines": { "node": ">=22" } }, "node_modules/@scalar/openapi-upgrader": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.9.tgz", - "integrity": "sha512-D5b0rGLLZgmkO9mdW2j/ND1KBlH1u3RCpr87HPxv9P9ZSr6PtM5iLqFOJq0ACiaHjY2mikCrxgDmnUEhTzRpHQ==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.10.tgz", + "integrity": "sha512-FRYwCl4IXRi7yAF5/3Jho0jc2akTZfr/vRTYMjhm+96Fq0LaEuHJAvPcSkkU+j3IWGie6LaCQMZTngcSjoIzgA==", "license": "MIT", "dependencies": { - "@scalar/openapi-types": "0.9.1" + "@scalar/openapi-types": "0.9.2" }, "engines": { "node": ">=22" } }, "node_modules/@scalar/schemas": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.5.0.tgz", - "integrity": "sha512-aV0PWqFpQft9a6d4Z6HOTHKDaA+K4AquxSPfBDI1c5f6lMe5l3zViww/iBQA/Yw+V1+Y2iwSVzhuxaktC1UiMg==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.3.tgz", + "integrity": "sha512-B6S/zUptiRfMsmMy92u/LefpULus1h0q3od9l5xgZc+pslkfFhfFns+QsU0UJX3Lqp3tTsf64c3tIRqH7cZgVA==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", - "@scalar/validation": "0.6.0" + "@scalar/helpers": "0.9.1", + "@scalar/validation": "0.6.1" }, "engines": { "node": ">=22" } }, "node_modules/@scalar/sidebar": { - "version": "0.9.23", - "resolved": "https://registry.npmjs.org/@scalar/sidebar/-/sidebar-0.9.23.tgz", - "integrity": "sha512-2qC5vh1BGhnARF9/psKr5jPbwWyg4RIfflNmh7q/jHxNyeCrHGr1yyUiV8TsWmURR5ZNMiyUPzHaio4hdAcZNw==", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@scalar/sidebar/-/sidebar-0.9.31.tgz", + "integrity": "sha512-kgjas/f9bWk8Xa97bvVUCAuiYnGXzPRSQidX3gVKp3hGjouIZmhCYy1Jg4nPBM4OwyJu5+x7ItK47THY1ZIcyA==", "license": "MIT", "dependencies": { - "@scalar/components": "0.27.2", - "@scalar/helpers": "0.8.2", - "@scalar/icons": "0.7.3", - "@scalar/themes": "0.16.0", - "@scalar/use-hooks": "0.4.7", - "@scalar/workspace-store": "0.54.4", + "@scalar/components": "0.27.7", + "@scalar/helpers": "0.9.1", + "@scalar/icons": "0.7.4", + "@scalar/themes": "0.16.3", + "@scalar/use-hooks": "0.4.8", + "@scalar/workspace-store": "0.55.5", "vue": "^3.5.30" }, "engines": { @@ -1940,13 +1996,13 @@ } }, "node_modules/@scalar/snippetz": { - "version": "0.9.17", - "resolved": "https://registry.npmjs.org/@scalar/snippetz/-/snippetz-0.9.17.tgz", - "integrity": "sha512-BC8ai3E9O5TbJwdGTounh9T6BVSTuYJ/BObZ6wfmXxMK/AL7bDfYED5xBqqzucAGpjVUaN0u796w5EHB82ryrQ==", + "version": "0.9.22", + "resolved": "https://registry.npmjs.org/@scalar/snippetz/-/snippetz-0.9.22.tgz", + "integrity": "sha512-fM4Y1uIcOod53IF56/lzeWalFn8gCqkTLcTHjL6oNTvHkt7JK4ntudRjcprQn/mttegM0j1u+WXdH4gv7dkirA==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", - "@scalar/types": "0.14.0", + "@scalar/helpers": "0.9.1", + "@scalar/types": "0.16.3", "js-base64": "^3.7.8", "stringify-object": "^6.0.0" }, @@ -1955,9 +2011,9 @@ } }, "node_modules/@scalar/themes": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@scalar/themes/-/themes-0.16.0.tgz", - "integrity": "sha512-xcd5cdouNKGGI+D3v4SVm5WVLjiLDPT0jBiZrNA6m/h2yDaIeFoXMDrH61aj962Y+P7KR33/2b0xLHgws5Os8Q==", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@scalar/themes/-/themes-0.16.3.tgz", + "integrity": "sha512-PIYSUQwAafVlA684H1e2JJjbg/o3xmJo0Yn6cMzOAevPfyO2uEBQuKr7C2TnZ0emBOAzr3rd9V41Cp1mZVjaSQ==", "license": "MIT", "dependencies": { "nanoid": "^5.1.6" @@ -1967,9 +2023,9 @@ } }, "node_modules/@scalar/themes/node_modules/nanoid": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz", - "integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -1991,12 +2047,12 @@ "license": "MIT" }, "node_modules/@scalar/types": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.14.0.tgz", - "integrity": "sha512-aS1FvHbKhBC51mWqXmhkPe6lmhCd9+u//DQ1YTiCWOz4/fXAdWfwHvW9UZ7AzzcGwGiCo9diE81xV+UvSI1qIg==", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.3.tgz", + "integrity": "sha512-8TVNlK8AdvIekrapAoEVwy+IVRbMTSDYnpprskAJV4XanmZ52Kru6RmS6d+tbEBfMo2dTgQ9cV9P0kqsAu/AuA==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", + "@scalar/helpers": "0.9.1", "nanoid": "^5.1.6", "type-fest": "^5.3.1", "zod": "^4.3.5" @@ -2006,9 +2062,9 @@ } }, "node_modules/@scalar/types/node_modules/nanoid": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz", - "integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -2024,9 +2080,9 @@ } }, "node_modules/@scalar/use-codemirror": { - "version": "0.14.12", - "resolved": "https://registry.npmjs.org/@scalar/use-codemirror/-/use-codemirror-0.14.12.tgz", - "integrity": "sha512-m+0tmVOHZVAybbcNEizR3m9RYOLtU59AStRkk29J5qaW4J+tsjrz4BDMceeTpoVawL/lxp2vDvQR+w/gKC3miQ==", + "version": "0.14.13", + "resolved": "https://registry.npmjs.org/@scalar/use-codemirror/-/use-codemirror-0.14.13.tgz", + "integrity": "sha512-OnsOZmKpNZGWHKBVR63hhCqn0Tt5zgHEEUKUd96e2ujfxD7vlIyEwZgDp2YNWJ6a7mD3tiXd7FEDLv0aKA8+Fw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -2050,13 +2106,13 @@ } }, "node_modules/@scalar/use-hooks": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@scalar/use-hooks/-/use-hooks-0.4.7.tgz", - "integrity": "sha512-8zajxhnKMJuO1HF36y8TeVuSIol2pueYdRvITZTEY8TuRbnwxrvJZk25II7YpxGMORAEDr0bwNF88Dr+QUfw1w==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@scalar/use-hooks/-/use-hooks-0.4.8.tgz", + "integrity": "sha512-Hu6XZhnJifRNHokWPeqiNk5/aJyxLEU6Vm0zmV4+SXeGL/Eq4DfSzSfAB31BBfQwDFgMaJukNJIItYufsTIj3A==", "license": "MIT", "dependencies": { - "@scalar/use-toasts": "0.10.2", - "@scalar/validation": "0.6.0", + "@scalar/use-toasts": "0.10.3", + "@scalar/validation": "0.6.1", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", @@ -2067,9 +2123,9 @@ } }, "node_modules/@scalar/use-toasts": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@scalar/use-toasts/-/use-toasts-0.10.2.tgz", - "integrity": "sha512-1iHQFbDXv0YQRp13aa63S5EcTJ5K8T0ocnLxk+nziloPrLjKt6jdRt6vOHsLSv5sm9kFKcVKNQTQgialmKCOGA==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@scalar/use-toasts/-/use-toasts-0.10.3.tgz", + "integrity": "sha512-aViyTxk4AlfvODcvyyz5tkFEJbzDaQpNBreZmhHmKWx8HZIN74WIGCxVoHV17YRF13+rAX0IPurSkm4vaAW6LA==", "license": "MIT", "dependencies": { "vue": "^3.5.30", @@ -2080,28 +2136,29 @@ } }, "node_modules/@scalar/validation": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.0.tgz", - "integrity": "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.1.tgz", + "integrity": "sha512-XeJ+pvxag0rguuRT6hxNSXIsxleB8Gcs6WQ55vFRkB4qo2CCO+wIli+uipzM3pILu5cP7agcL3pgADiZzd1ZNg==", "license": "MIT", "engines": { "node": ">=20" } }, "node_modules/@scalar/workspace-store": { - "version": "0.54.4", - "resolved": "https://registry.npmjs.org/@scalar/workspace-store/-/workspace-store-0.54.4.tgz", - "integrity": "sha512-82ffXzHc/TDR6mr9BSzzRkxOaVG8mHQlWLKnPiv8GiFNETgBTQKv9nELEZQpLcG02cJVgVytl+X/TjgZHRDKAw==", + "version": "0.55.5", + "resolved": "https://registry.npmjs.org/@scalar/workspace-store/-/workspace-store-0.55.5.tgz", + "integrity": "sha512-aJBVOWmpgTlGd+ifG2UELQLY4CKBAVR9fXEgSUjgrDj9ma1PdjD/idXhzURqn0nNDZvHA6mnFnTMo4dw+BKymg==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", - "@scalar/json-magic": "0.12.16", - "@scalar/openapi-upgrader": "0.2.9", - "@scalar/schemas": "0.5.0", - "@scalar/snippetz": "0.9.17", + "@scalar/asyncapi-upgrader": "0.1.3", + "@scalar/helpers": "0.9.1", + "@scalar/json-magic": "0.12.18", + "@scalar/openapi-upgrader": "0.2.10", + "@scalar/schemas": "0.7.3", + "@scalar/snippetz": "0.9.22", "@scalar/typebox": "0.1.3", - "@scalar/types": "0.14.0", - "@scalar/validation": "0.6.0", + "@scalar/types": "0.16.3", + "@scalar/validation": "0.6.1", "js-base64": "^3.7.8", "type-fest": "^5.3.1", "vue": "^3.5.30", @@ -2127,9 +2184,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.17.1", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", - "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz", + "integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==", "license": "MIT", "funding": { "type": "github", @@ -2137,12 +2194,12 @@ } }, "node_modules/@tanstack/vue-virtual": { - "version": "3.13.29", - "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.29.tgz", - "integrity": "sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ==", + "version": "3.13.32", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.32.tgz", + "integrity": "sha512-E8OCutx7QnwZdvpJijz0Q2PHsYDWBWjnGr3TvgWiqxTU35jB1kVhtkd93scRV7tTFuId2tg3x2iFiw+IE4evjQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.17.1" + "@tanstack/virtual-core": "3.17.4" }, "funding": { "type": "github", @@ -2220,9 +2277,9 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -2286,9 +2343,9 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, "node_modules/@unhead/vue": { @@ -2338,13 +2395,13 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", - "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.38", + "@vue/shared": "3.5.40", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -2363,90 +2420,88 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", - "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", - "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.38", - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.15", + "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", - "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/reactivity": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", - "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.38" + "@vue/shared": "3.5.40" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", - "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", - "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/runtime-core": "3.5.38", - "@vue/shared": "3.5.38", + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", - "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38" - }, - "peerDependencies": { - "vue": "3.5.38" + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/shared": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", - "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", "license": "MIT" }, "node_modules/@vueuse/core": { @@ -2752,9 +2807,9 @@ "license": "MIT" }, "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, "node_modules/csstype": { @@ -3012,9 +3067,9 @@ } }, "node_modules/fuse.js": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.2.tgz", - "integrity": "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz", + "integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==", "license": "Apache-2.0", "engines": { "node": ">=10" @@ -3376,9 +3431,9 @@ } }, "node_modules/identifier-regex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.0.1.tgz", - "integrity": "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.1.0.tgz", + "integrity": "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==", "license": "MIT", "dependencies": { "reserved-identifiers": "^1.0.0" @@ -3403,13 +3458,13 @@ } }, "node_modules/is-identifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.0.1.tgz", - "integrity": "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.1.0.tgz", + "integrity": "sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==", "license": "MIT", "dependencies": { - "identifier-regex": "^1.0.0", - "super-regex": "^1.0.0" + "identifier-regex": "^1.1.0", + "super-regex": "^1.1.0" }, "engines": { "node": ">=18" @@ -3455,9 +3510,9 @@ } }, "node_modules/js-base64": { - "version": "3.7.8", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", - "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.1.tgz", + "integrity": "sha512-U73qptcvf/HIOauFOmqT3a0mDUp0MYlfd15oqoe9kqZt5XhiXVb+HG09sLvI9PQ9tZIBFS4nlErai8zbWazP0g==", "license": "BSD-3-Clause" }, "node_modules/js-tokens": { @@ -4484,9 +4539,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "funding": [ { "type": "opencollective", @@ -4653,9 +4708,9 @@ } }, "node_modules/radix-vue/node_modules/nanoid": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz", - "integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -5039,9 +5094,9 @@ } }, "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", "license": "MIT" }, "node_modules/tagged-tag": { @@ -5067,9 +5122,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "license": "MIT", "peer": true }, @@ -5146,9 +5201,9 @@ "license": "0BSD" }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -5443,16 +5498,16 @@ } }, "node_modules/vue": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", - "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-sfc": "3.5.38", - "@vue/runtime-dom": "3.5.38", - "@vue/server-renderer": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" }, "peerDependencies": { "typescript": "*" @@ -5464,9 +5519,9 @@ } }, "node_modules/vue-component-type-helpers": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", - "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.7.tgz", + "integrity": "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==", "license": "MIT" }, "node_modules/vue-sonner": { diff --git a/ui/sample-app/package.json b/ui/app/package.json similarity index 54% rename from ui/sample-app/package.json rename to ui/app/package.json index d8e3952..ee83038 100644 --- a/ui/sample-app/package.json +++ b/ui/app/package.json @@ -1,5 +1,5 @@ { - "name": "profanity-filter-console-sample", + "name": "profanity-filter-ui", "private": true, "version": "0.0.0", "type": "module", @@ -7,17 +7,14 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "npm run test:register && npm run test:docs && npm run test:login", - "test:login": "node scripts/check-login-auth.mjs", - "test:register": "node scripts/check-register-flow.mjs", - "test:docs": "node scripts/check-docs-layout.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { - "@scalar/api-reference-react": "^0.9.47", + "@fontsource-variable/noto-sans-kr": "^5.2.10", + "@phosphor-icons/react": "^2.1.10", + "@scalar/api-reference-react": "^0.9.56", "react": "^19.2.1", - "react-dom": "^19.2.1", - "yaml": "^2.9.0" + "react-dom": "^19.2.1" }, "devDependencies": { "@types/node": "^26.0.0", diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx new file mode 100644 index 0000000..af9aff6 --- /dev/null +++ b/ui/app/src/App.tsx @@ -0,0 +1,395 @@ +import { + ArrowRight, + BookOpen, + Check, + Copy, + GithubLogo, + Key, + List, + LockKey, + Moon, + ShieldCheck, + Sun, + UserCircle, + X, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useState } from "react"; + +import { exchangeLoginCode, restoreLoginSession, startSocialLogin, type LoginUser } from "./auth"; +import DocsPage from "./docs/DocsPage"; + +type Theme = "light" | "dark"; +type RoutePath = "/" | "/docs" | "/login" | "/app" | "/app/credentials" | "/app/account"; +type CredentialKind = "api-key" | "oauth"; +type AuthStatus = "checking" | "anonymous" | "exchanging" | "authenticated" | "failed"; + +const ROUTES: RoutePath[] = ["/", "/docs", "/login", "/app", "/app/credentials", "/app/account"]; + +function currentPath(): RoutePath { + const pathname = window.location.pathname; + return ROUTES.includes(pathname as RoutePath) ? (pathname as RoutePath) : "/"; +} + +function preferredTheme(): Theme { + const saved = window.localStorage.getItem("pf-theme"); + if (saved === "light" || saved === "dark") return saved; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export default function App() { + const [path, setPath] = useState(currentPath); + const [theme, setTheme] = useState(preferredTheme); + const [authStatus, setAuthStatus] = useState("checking"); + const [loginUser, setLoginUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [authError, setAuthError] = useState(""); + const [mobileOpen, setMobileOpen] = useState(false); + const [credentialDialog, setCredentialDialog] = useState(null); + const authenticated = authStatus === "authenticated"; + + useEffect(() => { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + window.localStorage.setItem("pf-theme", theme); + }, [theme]); + + useEffect(() => { + const onPopState = () => setPath(currentPath()); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + useEffect(() => { + let cancelled = false; + const code = window.location.pathname === "/login" + ? new URLSearchParams(window.location.hash.replace(/^#/, "")).get("code") + : null; + + async function authenticate() { + try { + if (code) { + setAuthStatus("exchanging"); + window.history.replaceState({}, "", "/login"); + } + const session = code ? await exchangeLoginCode(code) : await restoreLoginSession(); + if (cancelled) return; + setAccessToken(session.accessToken); + setLoginUser(session.user); + setAuthStatus("authenticated"); + } catch (error) { + if (cancelled) return; + setAccessToken(null); + setLoginUser(null); + setAuthStatus(code ? "failed" : "anonymous"); + setAuthError(code && error instanceof Error ? error.message : ""); + } + } + + void authenticate(); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (path === "/app" || path === "/app/credentials") navigate("/"); + if ((authStatus === "anonymous" || authStatus === "failed") && path === "/app/account") navigate("/login"); + if (authenticated && path === "/login") navigate("/"); + }, [authStatus, authenticated, path]); + + function navigate(next: RoutePath) { + if (window.location.pathname !== next) window.history.pushState({}, "", next); + setPath(next); + setMobileOpen(false); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + function signOut() { + setAccessToken(null); + setLoginUser(null); + setAuthStatus("anonymous"); + navigate("/login"); + } + + const page = useMemo(() => { + switch (path) { + case "/docs": + return ; + case "/login": + return ; + case "/app/account": + return ; + default: + return ( + + ); + } + }, [authError, authenticated, authStatus, loginUser, path]); + + return ( +
+ setMobileOpen((open) => !open)} + onNavigate={navigate} + onTheme={() => setTheme(theme === "dark" ? "light" : "dark")} + path={path} + theme={theme} + /> +
{page}
+ {credentialDialog ? ( + setCredentialDialog(null)} /> + ) : null} +
+ ); +} + +type NavigationProps = { + authenticated: boolean; + loginUser: LoginUser | null; + mobileOpen: boolean; + onMenu: () => void; + onNavigate: (path: RoutePath) => void; + onTheme: () => void; + path: RoutePath; + theme: Theme; +}; + +function GlobalHeader({ authenticated, loginUser, mobileOpen, onMenu, onNavigate, onTheme, path, theme }: NavigationProps) { + return ( +
+ + +
+ +