diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce3de3..20354a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - test: + python-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,3 +21,50 @@ jobs: DATABASE_URL: sqlite:///./data/test.db CHROMA_DIR: ./data/test_chroma run: pytest -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20.19.0" + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + run: npm ci + - name: Build frontend + run: npm run build + - name: Install Chromium for browser smoke + run: npx playwright install --with-deps chromium + - name: Run browser smoke + run: npm run test:smoke + + deno-api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + RATE_LIMIT_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.4.5 + - name: Type-check API and tests + run: deno check supabase/functions/api/index.ts supabase/functions/api/*_test.ts + - name: Run Deno API tests + run: deno test --allow-env --allow-net --allow-read supabase/functions/api/ diff --git a/README.md b/README.md index c6ec433..2697e01 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Hands-on tutorial project for learning **LangChain**, **RESTful API**, **Dify**, and **WebHooks**. -本機使用 FastAPI + LangChain + SQLite;正式網站使用 GitHub Pages + Supabase Edge Functions + Supabase Database。預設 `LLM_PROVIDER=mock`,**不需要 API Key** 就能開始練 REST 與 RAG 流程。 +本機使用 FastAPI + LangChain + SQLite。預設 `LLM_PROVIDER=mock`,**不需要 API Key** 就能開始練 REST 與 RAG 流程。 + +> ⚠️ **線上 API 已停用(2026-09-21)**:原本的 Supabase 專案已移除,[GitHub Pages 網站](https://frobel0520.github.io/AI-Agent-Tutorial/)只剩前端畫面,筆記、RAG、WebHook 與 Dify 功能在線上都無法使用。要實際練習請看下方「快速開始(本機)」。Supabase 相關程式與[部署文件](deploy/github-supabase-deploy.md)保留,供日後重建。 ## 學習路徑 @@ -10,9 +12,20 @@ Hands-on tutorial project for learning **LangChain**, **RESTful API**, **Dify**, |-------|------|------| | 1 | RESTful API + LangChain RAG | [docs/01-rest-api.md](docs/01-rest-api.md), [docs/02-langchain.md](docs/02-langchain.md) | | 2 | WebHook | [docs/03-webhook.md](docs/03-webhook.md) | -| 3 | Dify 整合 | [docs/04-dify.md](docs/04-dify.md) | - -## 需求 +| 3 | Dify 整合 | [docs/04-dify.md](docs/04-dify.md) | + +## 開發流程(SDLC) + +本專案沿用 Planning → SA → SD/ADR → Task DAG → CI/Release Gate 的交付流程: + +- [Project Plan](docs/01-project-plan.md) +- [System Analysis](docs/02-system-analysis.md) +- [System Design / ADR](docs/03-system-design.md) +- [MVP Release Gate](docs/04-mvp-release-gate.md) + +功能開發使用 feature/- 分支,依序經過 dev、main,並以 GitHub Actions 部署 GitHub Pages 與 Supabase Edge Function。 + +## 需求 - Python 3.11+ - (選用)Docker Desktop — Ollama 本機模型、Dify 自架 @@ -85,6 +98,8 @@ pytest -q ## 上線部署(GitHub Pages + Supabase) +> 目前**未部署**:Supabase 專案已移除,repository variables 已清空,`Deploy Supabase Edge Function` workflow 已停用。以下是重建時的步驟;重建後重新設定 variables、啟用 workflow,網站的停用提示就會自動消失。 + 正式環境不使用 Render。架構如下: - GitHub Pages:發布 `frontend/` 的 React + Vite 學習台(建置產物為 `frontend/dist`) diff --git a/deploy/github-supabase-deploy.md b/deploy/github-supabase-deploy.md index b71dd80..e42b3e3 100644 --- a/deploy/github-supabase-deploy.md +++ b/deploy/github-supabase-deploy.md @@ -20,6 +20,7 @@ React 前端原始碼在 `frontend/src/`。GitHub Actions 會先執行 `npm ci` 1. 在 Supabase 建立專案。 2. SQL Editor 執行 `supabase/schema.sql`。 3. 若資料表已存在,再執行 `supabase/migrations/20260830000000_enable_rls_for_edge_api.sql` 與 `20260831000000_add_dify_access.sql`。 +4. **一律執行** `supabase/migrations/20260909000000_add_edge_rate_limit.sql`(API 限流;`schema.sql` 沒有包含)。 這個專案的瀏覽器請求全部經過 Edge Function;RLS migration 會阻擋瀏覽器直接讀寫資料表。Edge Function 使用 server-only service role key,因此該 key 絕不能放在 GitHub Pages。 @@ -38,6 +39,7 @@ React 前端原始碼在 `frontend/src/`。GitHub Actions 會先執行 `npm ci` | `GOOGLE_API_KEY` | 使用 Gemini 時 | Google AI API key | | `GEMINI_MODEL` | 使用 Gemini 時 | 有效的 Gemini model 名稱 | | `WEBHOOK_SECRET` | 否 | 訂閱未提供個別 secret 時使用 | +| `WEBHOOK_ALLOWED_URLS` | 使用 WebHook 時 | 允許送出的 HTTPS 網址,逗號分隔、完全相同才放行;留空則註冊回 503、既有訂閱送出記為失敗 | | `CORS_ORIGINS` | 否 | 逗號分隔的允許來源;空白時為公開教學模式 | | `DIFY_API_BASE` | 使用 Dify 時 | Dify 的 `/v1` API URL | | `DIFY_API_KEY` | 使用 Dify 時 | Dify Chat App API key | @@ -54,6 +56,8 @@ supabase functions deploy api ## 3. 部署 Edge Function +> ⚠️ push 到 `main` 且改到 `supabase/functions/**` 時,`.github/workflows/supabase-functions.yml` 會**自動部署** Edge Function,但**不會**執行 migration。含新 migration 的 PR 進 `main` 之前,先在 SQL Editor 執行該 migration(目前是 `20260909000000_add_edge_rate_limit.sql`),並設定好 `WEBHOOK_ALLOWED_URLS`。限流表不存在時 API 會 fail closed,除了 `GET /health` 以外都回 503。 + 手動部署: ```powershell diff --git a/deploy/supabase-setup.md b/deploy/supabase-setup.md index a36122e..ce14d83 100644 --- a/deploy/supabase-setup.md +++ b/deploy/supabase-setup.md @@ -23,9 +23,12 @@ 1. Supabase Dashboard → **SQL Editor** 2. 貼上並執行本 repo 的 `supabase/schema.sql` 3. 若資料表原本已存在,再執行 `supabase/migrations/20260830000000_enable_rls_for_edge_api.sql` +4. **一律執行** `supabase/migrations/20260909000000_add_edge_rate_limit.sql`(API 限流;`schema.sql` 沒有包含) RLS migration 會阻止 `anon` 與 `authenticated` 直接讀寫這四張表;Edge Function 使用 server-only service role key 執行資料庫操作。 +> ⚠️ 限流 migration 必須在部署新版 Edge Function **之前**完成。限流表不存在時 API 會 fail closed,除了 `GET /health` 以外的路由都回 503。 + ## Step 3 — 設定 Function Secrets 在 Supabase Dashboard → **Edge Functions → Secrets** 設定: @@ -34,8 +37,11 @@ RLS migration 會阻止 `anon` 與 `authenticated` 直接讀寫這四張表;Ed SUPABASE_SERVICE_ROLE_KEY= LLM_PROVIDER=mock WEBHOOK_SECRET=<一組隨機字串> +WEBHOOK_ALLOWED_URLS=https://webhook.site/ ``` +`WEBHOOK_ALLOWED_URLS` 是允許送出 WebHook 的 HTTPS 網址清單,逗號分隔,必須**完全相同**才放行(不支援萬用字元,不可含 port、帳密或 `#`)。留空時 WebHook 功能停用:註冊回 503,既有訂閱每次送出都記為失敗。只放你信任、且不會解析到內網的 endpoint。 + 若要使用 Groq: ```env diff --git a/docs/01-project-plan.md b/docs/01-project-plan.md new file mode 100644 index 0000000..24b63c0 --- /dev/null +++ b/docs/01-project-plan.md @@ -0,0 +1,118 @@ +# Project Plan — AI-Agent-Tutorial + +| 項目 | 內容 | +|---|---| +| 文件版本 | 1.0 | +| 文件狀態 | SDLC baseline | +| 更新日期 | 2026-09-09 | +| 產品定位 | 給初學者的 LangChain、REST API、WebHook、Dify 實作教學 | +| 預算約束 | 以 USD 0 的託管方案為預設,付費服務只能是明確的選配 | +| 正式部署 | GitHub Pages + Supabase | + +## 1. 目標與成功條件 + +本專案要讓沒有先備程式經驗的學習者,能沿著學習路徑完成: + +1. 建立與測試 REST API。 +2. 建立筆記並理解簡易 RAG 問答。 +3. 建立 WebHook、觀察事件 payload,理解簽章與事件紀錄。 +4. 使用 Google 登入後,讓被授權的帳號呼叫 Dify。 +5. 在不使用 Render 的前提下,從 GitHub Pages 完成正式網站部署。 + +成功條件不是只有「頁面能開」,而是每個學習步驟都有可重現的輸入、輸出、驗收方式與失敗說明。 + +## 2. 範圍 + +### In scope + +- React + Vite 學習台,部署到 GitHub Pages。 +- Supabase Auth、Postgres、RLS 與 Edge Function API。 +- 本機 FastAPI 路線,作為 REST/LangChain/Dify 的教學與除錯環境。 +- Google SSO、Dify 存取授權與伺服器端密鑰邊界。 +- WebHook 註冊、簽章、事件接收與事件紀錄。 +- 可在免費額度內運作的 LLM/Dify 整合方式。 +- 可由 CI 與 release gate 重複執行的驗收流程。 + +### Out of scope + +- Render、長駐付費 VM 或自建高可用 Dify 叢集。 +- 把 Dify API key、Supabase service-role key 或 LLM key 放進前端。 +- 以本專案取代正式企業級 webhook broker、queue 或 SIEM。 +- 在沒有需求與驗收條件前,先做大型前端重構或資料庫換代。 + +## 3. 現況基線 + +| 層 | 目前責任 | 主要位置 | +|---|---|---| +| 學習台 | React/Vite UI、Google 登入、教學步驟 | frontend/ | +| 正式 API | Supabase Edge Function,提供 health、notes、ask、webhooks、events、dify/ask | supabase/functions/api/ | +| 正式資料 | Supabase Postgres、RLS、Dify access table | supabase/ | +| 本機 API | FastAPI、SQLite、LangChain、本機 Dify/Ollama 教學路線 | src/、static/ | +| 部署 | GitHub Actions 發布 Pages 與 Supabase Edge Function | .github/workflows/ | + +正式網站不依賴 Render。static/ 保留為本機 FastAPI 的舊版回退頁面與教學素材,不等於正式網站的第二套部署目標。 + +## 4. 需求基線 + +| ID | 需求 | 驗收結果 | +|---|---|---| +| FR-01 | 訪客可開啟學習台並看到目前學習步驟 | 頁面載入成功,API 狀態可辨識 | +| FR-02 | 使用者可建立、查詢共用教學筆記 | 明確標示共用沙盒、受共享額度限制;不宣稱個人資料隔離 | +| FR-03 | 使用者可送出問題並看到 RAG 回答與來源 | 失敗時顯示可理解的錯誤 | +| FR-04 | WebHook 事件可被送出、接收、驗證並留下紀錄 | 簽章錯誤與逾時可觀察 | +| FR-05 | Google 登入後,只有被授權帳號可以呼叫 Dify | 未登入為 401,未授權為 403 | +| FR-06 | push 到 main 可觸發正式部署 | Pages 與 Edge Function workflow 各自可驗證 | +| FR-07 | 文件與實際路由、環境變數、部署方式一致 | release gate 不允許明顯過時說明 | + +## 5. 非功能需求 + +| 類別 | 基線要求 | +|---|---| +| 成本 | 預設不需要 Render;服務選擇需符合目前免費額度 | +| 安全 | 前端只拿公開 URL;敏感 key 只存在 Supabase secrets 或本機環境 | +| 身分 | 受保護功能使用 Supabase Auth;授權判斷在伺服器端完成 | +| 可用性 | API 失敗、Dify 未設定、tunnel 中斷都要有明確狀態 | +| 效能 | 教學資料量小時保持簡單;超過基線前不引入不必要的基礎設施 | +| 可測試性 | Python 測試、前端 build、部署設定檢查可在 CI 重跑 | +| 可維運性 | 每次 release 有 smoke、rollback 方式與已知限制 | + +## 6. 里程碑 + +| Milestone | 內容 | 狀態 | +|---|---|---| +| M0 | GitHub Pages + Supabase baseline、React runtime、Dify access | 已完成,基準為 origin/main | +| M1 | 建立本文件、SA、SD/ADR、Task DAG、Release Gate | 本分支進行中 | +| M2 | 收斂 Auth、CORS、輸入限制與 webhook SSRF/replay 風險 | 待排程 | +| M3 | 補齊前端 runtime smoke、CI frontend gate 與 secrets 檢查 | 待排程 | +| M4 | 依驗收證據發布下一個可教學版本 | 待排程 | + +## 7. 風險與處置 + +| 風險 | 影響 | 處置 | +|---|---|---| +| Edge API 的公開路由可能被濫用 | 資料、LLM 額度與 webhook 受影響 | M2 定義訪客/登入者/管理者權限矩陣並實作 | +| WebHook 目標 URL 可能形成 SSRF | 伺服器被利用存取內網 | 只允許安全目標,阻擋 private/link-local/metadata 位址並限制 redirect | +| Dify 使用免費方案或本機 tunnel | URL/額度不穩定 | UI 顯示狀態;文件記錄 tunnel 生命週期與替代路線 | +| React build 通過但瀏覽器白屏 | 使用者無法學習 | 加入 runtime smoke、錯誤邊界與部署後 smoke | +| 依賴未鎖定或 workflow 使用 latest | build 漂移 | 固定 Node/Supabase CLI/Python 依賴版本 | + +## 8. Git 與交付規則 + +- production branch:main;只接受通過 gate 的變更。 +- integration branch:dev;需要跨功能整合或 preview 時使用。 +- task branch:feature/-;修 bug 用 fix/,測試用 test/。 +- 變更流程:feature → dev → main → GitHub Pages/Supabase deploy。 +- 每個 task contract 必須有 Goal、Input contract、Output artifact、Out of scope、Acceptance checks、depends_on 與 evidence path。 +- 不直接在 main 上開發;不使用 reset --hard 丟棄工作。 +- secrets 不進 Git;本機與 CI 都以範例檔提供名稱,不提供真值。 + +## 9. Definition of Done + +一個功能只有在下列條件都成立時才算完成: + +- 需求、SA、SD 或 ADR 已更新,且與實作一致。 +- 驗收案例至少涵蓋成功、未授權/錯誤與邊界情境。 +- 相關 Python test、frontend build、必要的 browser smoke 與設定檢查通過。 +- 沒有未處理的 critical/high 風險;若有明確 waiver,需記錄期限與 owner。 +- release note、部署步驟、rollback 與已知限制已更新。 +- PR 只包含單一可理解的 task,並保留可追溯的 evidence path。 diff --git a/docs/02-system-analysis.md b/docs/02-system-analysis.md new file mode 100644 index 0000000..5dc1909 --- /dev/null +++ b/docs/02-system-analysis.md @@ -0,0 +1,106 @@ +# System Analysis — AI-Agent-Tutorial + +| 項目 | 內容 | +|---|---| +| 文件版本 | 1.0 | +| 文件狀態 | SA baseline | +| 更新日期 | 2026-09-09 | +| 對應計畫 | docs/01-project-plan.md | + +## 1. 系統邊界 + +~~~text +學習者瀏覽器 + ├─ GitHub Pages React/Vite + │ ├─ Supabase Auth(Google) + │ └─ Supabase Edge Function API + │ ├─ Postgres + RLS + │ ├─ LLM provider + │ └─ Dify API(只有授權帳號) + └─ 本機 FastAPI(教學/除錯路線,不是正式託管) +~~~ + +瀏覽器是不可信的 client。任何「只有登入者可以」或「只有被授權者可以」的規則,都必須在 Supabase Edge Function 或資料庫 RLS 再判斷一次。 + +## 2. Actors + +| Actor | 目標 | 信任等級 | +|---|---|---| +| 訪客 | 閱讀教學、查看公開狀態 | 不可信 | +| Google 使用者 | 建立筆記、使用受保護的學習功能 | 已驗證但仍不可信 | +| Dify 授權使用者 | 呼叫 Dify workflow | 已驗證且通過 allowlist | +| WebHook sender | 向接收端送出簽章事件 | 以 secret/簽章驗證 | +| Maintainer | 管理 secrets、授權帳號、發布版本 | 受控管理者 | +| GitHub Actions | 建置與部署 | CI workload identity/repository secrets | + +## 3. 主要用例與驗收 + +| Use case | 前置條件 | 主要流程 | 失敗驗收 | +|---|---|---|---| +| UC-01 開啟學習台 | Pages 可用 | 載入 React,取得 API health | API 失敗時不得白屏,顯示目前狀態 | +| UC-02 Google 登入 | Supabase Auth provider 已設定 | 登入、回到 callback、保存 session | 取消登入可返回教學台,不洩漏 token | +| UC-03 建立筆記 | 共用教學沙盒額度可用 | POST notes、回傳 note id、刷新列表 | 欄位超限 4xx、額度超限 429 | +| UC-04 RAG 問答 | 有可檢索的筆記 | POST ask、回傳 answer 與 sources | LLM timeout/額度耗盡顯示可理解錯誤 | +| UC-05 WebHook | 有安全且允許的 target | 建立訂閱、送事件、驗證簽章、寫 event log | 無效簽章、逾時、重播可被拒絕或去重 | +| UC-06 Dify 問答 | Google session + dify_access | Edge 驗證 JWT、查 allowlist、伺服器端呼叫 Dify | 401/403 與 Dify 502 分開呈現 | +| UC-07 發布 | PR 通過 gate | merge main、Pages/Edge workflows 部署 | 必要設定缺失時 workflow fail fast | + +## 4. 介面與資料契約 + +| 介面 | Client | 認證基線 | 輸出重點 | +|---|---|---|---| +| GET /health | Pages/維運 | 公開狀態可接受 | API、provider、Dify 設定狀態 | +| GET/POST/PUT/DELETE /notes | Pages | 共用公開教學沙盒,Edge 共享限流 | 筆記資料與 note id | +| POST /ask | Pages | 公開但有共享額度 | answer、sources、錯誤碼 | +| GET/POST/DELETE /webhooks | Pages | Supabase JWT + dify_access 操作員 | subscription id;不回傳 secret | +| GET /events | Pages | Supabase JWT + dify_access 操作員 | 共用教學事件紀錄 | +| POST /hooks/incoming | sender/Edge | HMAC;timestamp/nonce 防重播仍待實作 | accepted event、拒絕原因 | +| POST /dify/ask | Pages | Supabase JWT + dify_access | Dify answer、provider error | + +所有 request body 都需要長度、格式與數量上限。錯誤回應應保持一致,至少包含可供 UI 顯示的 code 與不含 secret 的 message。 + +## 5. 身分與授權矩陣 + +| 能力 | 訪客 | 已登入 | Dify allowlist | Maintainer | +|---|---:|---:|---:|---:| +| 看公開教學與 health | ✓ | ✓ | ✓ | ✓ | +| 讀取/寫入共用教學筆記 | 依 quota | 依 quota | 依 quota | 依 quota | +| 呼叫一般 RAG | 依 quota | 依 quota | 依 quota | 依 quota | +| 管理 webhook subscription/查看事件 | ✗ | ✗ | ✓ | 需列入 allowlist | +| 呼叫 Dify | ✗ | ✗ | ✓ | ✓ | +| 授權 Dify 帳號 | ✗ | ✗ | ✗ | ✓ | +| 讀取 secrets | ✗ | ✗ | ✗ | 僅 Supabase/CI secret store | + +此矩陣描述 SEC-QA 增量的目標行為,只有部署並驗收後才能宣稱線上已生效。一般筆記與 RAG 刻意保留公開沙盒政策,不具使用者資料隔離;勿放私人資料。所有操作員共用訂閱與事件紀錄。 + +## 6. 資料與所有權 + +| 資料 | 儲存處 | owner/保護規則 | +|---|---|---| +| auth user | Supabase Auth | Supabase 管理 | +| notes | Postgres notes | 共用教學資料;RLS 阻止瀏覽器直連,Edge service role 依路由政策存取 | +| dify_access | Postgres dify_access | 只有管理流程可新增/撤銷 | +| webhook_subscriptions | Postgres | allowlist 操作員共用管理;target 需通過可信 URL policy | +| event_logs | Postgres | 僅 allowlist 操作員查閱 | +| Dify/LLM key | Supabase Function Secrets | 永不回傳前端或寫入 log | + +## 7. 邊界、威脅與非功能案例 + +| 情境 | 期待行為 | +|---|---| +| 沒有 Authorization header | 受保護路由回 401,不進入下游服務 | +| JWT 有效但沒有 dify_access | Dify 回 403,不消耗 Dify 額度 | +| 使用者提供內網 webhook URL | 建立請求回 4xx,不發出 outbound request | +| webhook 重送相同 event | 以 idempotency/nonce policy 去重或明確拒絕 | +| Dify/LLM timeout | bounded timeout、可辨識的 502/504,不卡住 request | +| 超長 note 或 question | 4xx,不把未限制輸入送到 LLM | +| CORS 來自未知 origin | 不授權 credentialed request | +| 前端 JS runtime exception | Error Boundary/fallback 顯示診斷,不呈現全白頁 | + +## 8. SA Exit Criteria + +- Actors、trust boundary、route contract、auth matrix 已被 SD 引用。 +- 每個高風險流程至少有一個拒絕案例。 +- 未決定的公開/登入策略被列入 task,而不是默認為安全。 +- 資料 owner、RLS、secret boundary 已有明確責任位置。 +- 能從本文件產出 feature task contract 與可重跑的 acceptance checks。 diff --git a/docs/03-system-design.md b/docs/03-system-design.md new file mode 100644 index 0000000..8cb81ad --- /dev/null +++ b/docs/03-system-design.md @@ -0,0 +1,148 @@ +# System Design / ADR — AI-Agent-Tutorial + +| 項目 | 內容 | +|---|---| +| 文件版本 | 1.0 | +| 文件狀態 | SD baseline | +| 更新日期 | 2026-09-09 | +| 對應文件 | docs/01-project-plan.md、docs/02-system-analysis.md | + +## 1. 目標架構 + +~~~mermaid +flowchart LR + Browser[React/Vite on GitHub Pages] --> Auth[Supabase Auth] + Browser --> Edge[Supabase Edge Function] + Auth --> Edge + Edge --> DB[(Supabase Postgres + RLS)] + Edge --> LLM[LLM provider] + Edge --> Dify[Dify API] + Local[Local FastAPI] --> SQLite[(SQLite)] + Local --> Ollama[Optional Ollama] +~~~ + +### 邊界規則 + +1. Browser 只持有公開的 Supabase URL 與 anon key;不持有 service-role、Dify 或 LLM secret。 +2. Edge Function 是正式 API 的授權、輸入驗證、下游 timeout 與錯誤轉換邊界。 +3. Postgres RLS 不取代 Edge policy;兩者同時存在,避免未預期的 client access。 +4. Local FastAPI 是教學與除錯路線,不能被正式 Pages workflow 當成 runtime dependency。 + +## 2. 元件責任 + +| 元件 | 責任 | 不負責 | +|---|---|---| +| frontend | route view、session UI、錯誤/載入狀態、API client | 保存 server secret、決定授權 | +| Supabase Auth | Google OAuth、session、JWT | Dify allowlist 的產品政策 | +| Edge api | auth、policy、schema validation、下游呼叫 | 在瀏覽器執行 UI | +| Postgres | durable data、RLS、audit/event records | 呼叫外部 webhook | +| Dify | workflow/knowledge/LLM orchestration | 判斷本專案哪個 user 被授權 | +| FastAPI | local teaching endpoints、legacy page fallback | 正式雲端託管 | +| GitHub Actions | build、deploy、release evidence | 保存 runtime secrets | + +## 3. API 與錯誤設計 + +- 成功回應必須有穩定的 JSON shape;UI 不應依賴未文件化的欄位。 +- 401 表示沒有有效 session;403 表示身份存在但沒有該能力。 +- 4xx 表示輸入/政策拒絕;502/504 表示下游 provider 失敗或 timeout。 +- 不把 Authorization、Dify key、webhook secret、完整 target secret 寫入 log。 +- 每個外部 request 都要有 bounded timeout;webhook dispatch 不應無限等待。 +- 後續 webhook hardening 需要 URL allowlist/IP policy、redirect policy、timestamp、nonce 與 idempotency key。 + +## 4. 資料與 secret 設計 + +| 類別 | 目前設計 | 目標 guardrail | +|---|---|---| +| notes | Supabase table + RLS | owner policy、欄位長度上限、必要索引 | +| dify_access | 授權 table | admin-only mutation、撤銷可追蹤 | +| webhook subscriptions | target、secret;目前無 owner 欄位 | 本次採共用操作員權限及可信目的地;secret 加密保存另案處理 | +| event logs | 接收/送出紀錄 | payload 大小上限、retention、去重 | +| runtime secrets | Supabase Function Secrets | CI/local 使用 env,禁止進 repo | + +## 5. ADR + +### ADR-001:正式前端使用 GitHub Pages + Supabase + +- 狀態:accepted +- 決策:React/Vite 發布到 GitHub Pages,API 與資料使用 Supabase。 +- 原因:符合零預算與靜態 hosting 約束,前後端邊界清楚。 +- 代價:Edge runtime 有 timeout/quota;長駐 worker 與 background queue 不在這個 baseline。 + +### ADR-002:不使用 Render + +- 狀態:accepted +- 決策:正式部署與文件不把 Render 當必要依賴。 +- 原因:使用者明確要求移除 Render,且本方案可由 Pages + Supabase 完成。 +- 代價:本機 FastAPI 與正式 Edge API 是兩條教學路線,需要保持契約一致。 + +### ADR-003:保留 React/Vite,不以原生 HTML 繼續擴張 + +- 狀態:accepted +- 決策:frontend/ 是正式 UI;static/ 僅保留本機 legacy fallback。 +- 原因:元件、session、主題與錯誤狀態需要可維護的 UI 邊界。 +- 代價:每次 release 必須驗證 React build 與 runtime,而不是只看 Python test。 + +### ADR-004:Dify 必須經 Supabase Auth + dify_access + +- 狀態:accepted +- 決策:瀏覽器不直接打 Dify;Edge 先驗證 JWT,再查 allowlist,最後使用 server-side key 呼叫 Dify。 +- 原因:避免訪客消耗 Dify 額度與暴露 API key。 +- 代價:OAuth、授權 table 與 Edge secrets 都是部署前置條件。 + +### ADR-005:Webhook outbound 先視為高風險功能 + +- 狀態:accepted with follow-up +- 決策:在完成 SSRF、owner、replay、rate-limit、retry policy 前,不宣稱 webhook 是 production-safe。 +- 原因:使用者可控制 target URL,而 Edge 會發出 outbound request。 +- 代價:M2 前功能可作為教學示範,但 release gate 必須標示限制。 + +## 6. Task DAG + +| Task | 內容 | depends_on | evidence path | 狀態 | +|---|---|---|---|---| +| SDLC-001 | 專案計畫、SA、SD/ADR、release gate | M0 baseline | docs/01~04 | 本分支 | +| SEC-001 | Auth matrix、CORS、輸入長度、quota | SDLC-001 | tests/security、PR evidence | 待辦 | +| SEC-002 | Webhook SSRF、owner、replay、idempotency | SEC-001 | webhook tests、smoke | 待辦 | +| QA-001 | React Error Boundary、runtime smoke、browser acceptance | SDLC-001 | frontend QA evidence | 待辦 | +| CI-001 | Python + frontend build + secret/config checks | SDLC-001 | GitHub Actions run | 待辦 | +| REL-001 | release note、production smoke、rollback drill | SEC-002、QA-001、CI-001 | release record | 待辦 | + +每個待辦 task 開始前都要補齊 task contract;task 完成後才可由 dev 合併到 main。 + +## 7. SD Exit Criteria + +- 元件責任與信任邊界已定義。 +- ADR 已記錄 Pages、Supabase、React、Dify、Webhook 與 no-Render 決策。 +- Task DAG 可直接拆成 feature branches。 +- API、secret、error、timeout 與部署邊界未依賴前端自律。 +- 未完成的安全工作已明確標示,不以「目前能跑」代替 production readiness。 + +## 8. SEC-QA:安全與前端可靠性增量(2026-09-09) + +本次依使用者授權實作以下三個獨立驗收邊界;Luna 負責 coding,主代理負責契約、整合與驗證。以下是本次變更契約,不代表正式環境已部署。 + +| Task ID | Goal/Output artifact | Input contract/Fixture | Acceptance checks | depends_on | +|---|---|---|---|---| +| SEC-QA-01 | Edge Webhook 權限與目的地限制 | Supabase JWT、dify_access、可信 HTTPS URL allowlist、偽造 fetch/DB | 未登入 401、未授權 403;註冊及舊訂閱 dispatch 均驗證 URL;拒絕 redirect 與未允許目的地 | SDLC-001 | +| SEC-QA-02 | Dify 身分與共享 API 限流 | 經驗證的 auth UUID、原子 Postgres rate-limit RPC、假時鐘/RPC | Dify 不採信 body.user;超額 429 + Retry-After;限流儲存失敗 503;跨 instance 使用共同計數 | SDLC-001 | +| SEC-QA-03 | React Error Boundary 與瀏覽器 smoke | 真實 production build、模擬 API/Auth、測試專用拋錯元件 | 頁面可渲染;API 失敗仍有 UI;render 例外有 fallback;401/403/429 明確呈現 | SDLC-001 | + +證據位置:supabase/functions/api/ 測試、frontend/ smoke 測試與 CI 執行紀錄;實際指令與結果記錄於 release gate。 + +### ADR-006:Webhook 管理限授權帳號與明確可信目的地 + +- 筆記與一般 RAG 維持共用教學沙盒,不在此增量改為個人資料空間。 +- Webhook 管理及事件紀錄使用現有 dify_access allowlist 作為教學操作員權限;登入本身不代表有管理權。 +- Webhook 目的地由管理者設定精確 HTTPS URL allowlist,空值禁止 outbound;每次送出重新檢查,禁止 redirect。清單只能放管理者信任且不會解析到內網的 endpoint。 +- 這是共用操作員模型,不是各使用者分別擁有訂閱;若需多租戶隔離,再獨立設計 ownership migration。 + +### ADR-007:共享限流與不可偽造的 Dify 身分 + +- Dify user 取自驗證成功的 Supabase UUID,不採用瀏覽器提供的 user。 +- 公開 API 以 Postgres 原子計數實作跨 instance 的全域路由額度,避免單一 instance 記憶體計數或可偽造 IP header 被繞過;Dify 可額外限制每帳號用量。 +- 超額回 429 並提供等待秒數;限流資料庫不可用時 fail closed。全域額度意味某位訪客可能耗盡共享窗口,但能限制整體服務消耗。 +- 不新增付費服務;仍消耗既有 Supabase 額度。migration 必須先於新 Edge Function 上線。 + +### Out of scope + +本次不包含 webhook replay/outbox 重試、多租戶筆記隔離、FastAPI 本機安全改造、全面 CORS 重設及正式部署。前端 smoke 使用測試資料,不消耗真實 Dify 額度。Error Boundary 不保證攔截 JS bundle 下載或模組載入前的錯誤。 diff --git a/docs/04-mvp-release-gate.md b/docs/04-mvp-release-gate.md new file mode 100644 index 0000000..765e4ad --- /dev/null +++ b/docs/04-mvp-release-gate.md @@ -0,0 +1,91 @@ +# MVP Release Gate — AI-Agent-Tutorial + +| 項目 | 內容 | +|---|---| +| 文件版本 | 1.0 | +| 文件狀態 | release gate baseline | +| 更新日期 | 2026-09-09 | +| 適用分支 | dev → main | + +## 1. 開發與 PR gate + +- [ ] 分支符合 feature/-、fix/ 或 test/。 +- [ ] PR 連到一個 task contract,包含 goal、scope、depends_on、acceptance 與 evidence。 +- [ ] 變更沒有把 secrets、.env 真值、token 或完整 webhook secret 放入 Git。 +- [ ] 相關 SA/SD/ADR 與 README 已同步。 +- [ ] 沒有無關的大型重構或未解釋的 generated files。 +- [ ] git diff --check 通過。 + +## 2. 自動化檢查 + +在 repo root 執行: + +~~~powershell +.\.venv\Scripts\python.exe -m pytest -q +npm ci --prefix frontend +npm run build --prefix frontend +git diff --check +~~~ + +CI 至少要覆蓋 Python test 與 frontend build。當新增 Supabase/Deno 檢查或 browser smoke 後,必須把指令與版本固定到 workflow,而不是只在本機手動驗證。 + +## 3. Acceptance smoke + +### 前端 + +- [ ] GitHub Pages 開啟不是白屏,favicon、標題、側欄與上方欄正常。 +- [ ] 未登入時 Dify 入口顯示登入要求,不會直接呼叫 Dify。 +- [ ] Google 登入 callback 成功,右上角可看到使用者狀態。 +- [ ] 主題切換、登出與頁面重新整理後的狀態符合設計。 +- [ ] API timeout/401/403/502 都有可理解的 UI fallback。 + +### Supabase/Dify + +- [ ] /health 回應 provider 與 Dify 設定狀態,且不暴露 secret。 +- [ ] 無 token 呼叫受保護路由得到 401。 +- [ ] 有效 token 但不在 dify_access 得到 403。 +- [ ] 授權帳號的 Dify request 成功或得到可診斷的下游錯誤。 +- [ ] notes、events、webhooks 的 RLS/owner 行為符合 SA。 + +### WebHook + +- [ ] 合法簽章可被接受。 +- [ ] 錯誤簽章被拒絕。 +- [ ] timeout、重送、重播、private target 與過大 payload 有明確 policy。 +- [ ] event log 不含可重放的敏感 secret。 + +## 4. 部署 gate + +- [ ] GitHub Actions 的 Pages workflow 綠燈。 +- [ ] Supabase Edge Function workflow 綠燈。 +- [ ] repository/environment secrets 已設定,名稱與 deploy 文件一致。 +- [ ] Pages 使用的公開 API URL 指向目前 Supabase project。 +- [ ] deploy 後完成至少一次 production smoke,記錄 URL、時間與 workflow run。 + +## 5. Rollback + +1. 先停止繼續發布,保留失敗 workflow run 與錯誤訊息。 +2. 若是前端回歸,將 main 回復到上一個已驗收 commit,透過正常 PR/revert 重新部署。 +3. 若是 Edge Function 回歸,使用上一個已驗收 commit 重新執行 Supabase deploy workflow。 +4. 若涉及資料 migration,不直接刪資料;先執行相容性修復或明確的 rollback migration。 +5. 在 release note 記錄 root cause、影響範圍、修復與再次驗收證據。 + +禁止用 reset --hard 或手動覆蓋遠端 branch 來做 rollback。 + +## 6. 目前 baseline 的明確限制 + +以下項目在完成對應 task 前,不得標記為 production-hardened: + +- Edge API 的訪客/登入者路由政策仍需逐一收斂。 +- webhook target 的 SSRF、owner、replay、rate-limit 與 retry policy 仍需補強。 +- FastAPI 與 Edge Function 的 CORS/輸入限制需統一。 +- 前端尚需把 build gate 擴展成 runtime smoke,避免再次出現「build 綠燈但瀏覽器白屏」。 +- 依賴與 CLI 版本需要固定,避免 workflow 使用浮動版本。 + +## 7. Release DoD + +- [ ] 所有 acceptance checks 通過,或有明確、限期、可追蹤的 waiver。 +- [ ] 沒有未處理的 critical/high defect。 +- [ ] 文件、環境變數、migration、部署與 rollback 說明一致。 +- [ ] CI run、production smoke 與 browser evidence 已保存。 +- [ ] main 的 commit 可追溯到 PR、task contract 與 release note。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5c4ffa2 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +playwright-report/ +test-results/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a2ee9f3..994bdde 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "react-dom": "18.3.1" }, "devDependencies": { + "@playwright/test": "1.49.1", "vite": "5.4.14" } }, @@ -427,6 +428,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -1019,6 +1036,53 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/frontend/package.json b/frontend/package.json index 09639b6..18e4f6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "vite build --base ./", - "preview": "vite preview" + "preview": "vite preview", + "test:smoke": "playwright test", + "smoke": "npm run build && npm run test:smoke" }, "dependencies": { "@supabase/supabase-js": "2.45.6", @@ -14,6 +16,7 @@ "react-dom": "18.3.1" }, "devDependencies": { + "@playwright/test": "1.49.1", "vite": "5.4.14" } } diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..f273df9 --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: /.*\.spec\.js/, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://127.0.0.1:4173/smoke/", + trace: "retain-on-failure", + }, + webServer: { + command: "node tests/build-smoke.mjs && node tests/serve-dist.mjs", + url: "http://127.0.0.1:4173/smoke/", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8263d26..e4fbb73 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,9 @@ const configuredApiBase = String(import.meta.env.VITE_API_BASE_URL || "").trim() const isLocalDevelopment = ["localhost", "127.0.0.1"].includes(window.location.hostname); const API_BASE = (configuredApiBase || (isLocalDevelopment ? window.location.origin : "")) .replace(/\/$/, ""); +// The hosted Supabase backend was retired in 2026-09; a Pages build without an API URL is UI-only. +const ONLINE_API_RETIRED = !API_BASE; +const ONLINE_API_RETIRED_MESSAGE = "線上 API 已停用,請在本機啟動 FastAPI 練習。"; const SUPABASE_URL = String(import.meta.env.VITE_SUPABASE_URL || "").trim(); const SUPABASE_PUBLISHABLE_KEY = String(import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || "").trim(); const authClient = SUPABASE_URL && SUPABASE_PUBLISHABLE_KEY @@ -22,6 +25,32 @@ const authClient = SUPABASE_URL && SUPABASE_PUBLISHABLE_KEY }) : null; +const API_ERROR_MESSAGES = { + 401: "登入狀態已失效或缺少登入,請重新登入。", + 403: "你沒有權限執行此操作。", + 413: "送出的內容太大,請縮短後再試。", + 429: "請求過於頻繁,請稍後再試。", +}; +const MAX_VALIDATION_DETAIL_LENGTH = 200; + +function apiErrorMessage(status, responseText) { + if (status === 422) { + const detail = validationDetail(responseText); + return detail ? `輸入內容不符合要求:${detail}` : "輸入內容不符合要求,請檢查後再試。"; + } + return API_ERROR_MESSAGES[status] || "服務目前無法完成請求,請稍後再試。"; +} + +// Only 422 details are shown: they describe the caller's own input, not server internals. +function validationDetail(responseText) { + try { + const detail = JSON.parse(responseText)?.detail; + return typeof detail === "string" && detail.length <= MAX_VALIDATION_DETAIL_LENGTH ? detail : null; + } catch { + return null; + } +} + function readStoredTheme() { try { const storedTheme = window.localStorage.getItem("ai-agent-tutorial-theme"); @@ -291,6 +320,13 @@ function Sidebar({ activeStepId, sidebarOpen, sidebarHealthLabel, sidebarHealthE } function ProviderCallout({ health, error }) { + if (ONLINE_API_RETIRED) { + return ( + <> + 線上 API 已停用:這個網站無法連到後端。請在本機執行 python src\run.py,再開啟 http://localhost:8000/learn 練習。 + + ); + } if (error) { return ( <> @@ -480,21 +516,21 @@ function App() { ...requestOptions, headers: { "Content-Type": "application/json", - ...(await getAuthHeaders()), ...optionHeaders, + // Prefer the current Supabase session over caller-provided headers. + ...(await getAuthHeaders()), }, }); const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status}:${apiErrorMessage(response.status, text)}`); + } let data = text; try { data = text ? JSON.parse(text) : null; } catch { data = text; } - if (!response.ok) { - const detail = typeof data === "object" && data?.detail ? JSON.stringify(data.detail) : text; - throw new Error(`${response.status} ${response.statusText}: ${detail || "請稍後再試"}`); - } return data; }, [getAuthHeaders]); @@ -558,6 +594,12 @@ function App() { }, [refreshDifyAccess]); const loadHealth = useCallback(async () => { + if (ONLINE_API_RETIRED) { + setHealth(null); + setHealthError(ONLINE_API_RETIRED_MESSAGE); + setConnection({ state: "error", label: "線上 API 已停用" }); + return null; + } setConnection({ state: "loading", label: "檢查中…" }); try { const nextHealth = await api("/health"); @@ -589,6 +631,10 @@ function App() { useEffect(() => { let active = true; + if (ONLINE_API_RETIRED) { + loadHealth(); + return undefined; + } loadHealth().then(() => loadNotes().catch((error) => { if (active) { showBanner(error.message, true); @@ -813,13 +859,22 @@ function App() { setSidebarOpen(false)} />
+ {ONLINE_API_RETIRED ? ( +
+ 線上 API 已停用 +

+ 這個網站原本使用的 Supabase 後端已經移除,筆記、RAG、WebHook 與 Dify 功能在線上都無法使用,下方內容保留作為閱讀教材。 + 要實際練習,請依 README 的快速開始 在本機執行 python src\run.py,再開啟 http://localhost:8000/learn。 +

+
+ ) : null}
GET STARTED5 個步驟 · 約 10 分鐘

從這裡開始學 RAG

diff --git a/frontend/src/AppErrorBoundary.jsx b/frontend/src/AppErrorBoundary.jsx new file mode 100644 index 0000000..bcba4c5 --- /dev/null +++ b/frontend/src/AppErrorBoundary.jsx @@ -0,0 +1,35 @@ +import React from "react"; + +const FALLBACK_TITLE = "頁面暫時無法顯示"; +const FALLBACK_MESSAGE = "畫面遇到非預期問題,請重新載入後再試。"; + +export class AppErrorBoundary extends React.Component { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+

AI AGENT TUTORIAL

+

{FALLBACK_TITLE}

+

{FALLBACK_MESSAGE}

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 7cbccbf..e3116ea 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,11 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App.jsx"; +import { AppErrorBoundary } from "./AppErrorBoundary.jsx"; import "./styles.css"; -createRoot(document.getElementById("root")).render(); +createRoot(document.getElementById("root")).render( + + + , +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7e166a4..a649515 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -471,6 +471,25 @@ a { padding: 36px clamp(20px, 4vw, 64px) 80px; } +.offline-notice { + max-width: 960px; + margin: 0 auto 16px; + padding: 16px 20px; + border: 1px solid var(--warn); + border-radius: 12px; + background: var(--warn-soft); + color: var(--text); + line-height: 1.7; +} + +.offline-notice strong { + color: var(--warn); +} + +.offline-notice p { + margin: 6px 0 0; +} + .hero { position: relative; overflow: hidden; @@ -996,6 +1015,44 @@ button:disabled { display: none !important; } +.app-error-boundary { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--bg); + color: var(--text); +} + +.app-error-boundary-card { + width: min(100%, 520px); + padding: 32px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + box-shadow: 0 18px 50px rgba(23, 32, 51, 0.12); + text-align: center; +} + +.app-error-boundary-eyebrow { + margin: 0 0 12px; + color: var(--muted); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.14em; +} + +.app-error-boundary-card h1 { + margin: 0 0 12px; + font-size: clamp(1.5rem, 4vw, 2rem); +} + +.app-error-boundary-card p:not(.app-error-boundary-eyebrow) { + margin: 0 0 24px; + color: var(--muted); + line-height: 1.7; +} + @media (max-width: 1120px) { .topbar-inner { gap: 18px; diff --git a/frontend/tests/build-smoke.mjs b/frontend/tests/build-smoke.mjs new file mode 100644 index 0000000..e980d60 --- /dev/null +++ b/frontend/tests/build-smoke.mjs @@ -0,0 +1,12 @@ +import { build } from "vite"; +import { fileURLToPath } from "node:url"; + +const frontendRoot = fileURLToPath(new URL("..", import.meta.url)); +process.env.VITE_SUPABASE_URL = "https://mock.supabase.test"; +process.env.VITE_SUPABASE_PUBLISHABLE_KEY = "mock-publishable-key"; +process.env.VITE_API_BASE_URL = ""; + +await build({ + configFile: `${frontendRoot}/vite.smoke.config.js`, + mode: "smoke", +}); diff --git a/frontend/tests/error-boundary-fixture.jsx b/frontend/tests/error-boundary-fixture.jsx new file mode 100644 index 0000000..e544336 --- /dev/null +++ b/frontend/tests/error-boundary-fixture.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { AppErrorBoundary } from "../src/AppErrorBoundary.jsx"; +import "../src/styles.css"; + +function ThrowingFixture() { + throw new Error("fixture-only secret-like details must not be rendered"); +} + +createRoot(document.getElementById("root")).render( + + + , +); diff --git a/frontend/tests/error-boundary.html b/frontend/tests/error-boundary.html new file mode 100644 index 0000000..b469a45 --- /dev/null +++ b/frontend/tests/error-boundary.html @@ -0,0 +1,12 @@ + + + + + + Error Boundary fixture + + +
+ + + diff --git a/frontend/tests/serve-dist.mjs b/frontend/tests/serve-dist.mjs new file mode 100644 index 0000000..e1077ef --- /dev/null +++ b/frontend/tests/serve-dist.mjs @@ -0,0 +1,53 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendRoot = fileURLToPath(new URL("..", import.meta.url)); +const distRoot = resolve(frontendRoot, "dist"); +const prefix = "/smoke"; +const port = Number(process.env.PORT || 4173); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function requestedFile(requestUrl) { + const pathname = new URL(requestUrl, "http://127.0.0.1").pathname; + if (pathname === `${prefix}/` || pathname === prefix) { + return resolve(distRoot, "index.html"); + } + if (!pathname.startsWith(`${prefix}/`)) { + return null; + } + const relativePath = pathname.slice(prefix.length + 1); + const filePath = resolve(distRoot, relativePath); + if (filePath !== distRoot && !filePath.startsWith(`${distRoot}${sep}`)) { + return null; + } + return filePath; +} + +const server = createServer((request, response) => { + const filePath = request.url ? requestedFile(request.url) : null; + if (!filePath || !existsSync(filePath) || !statSync(filePath).isFile()) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": contentTypes[extname(filePath)] || "application/octet-stream", + }); + createReadStream(filePath).pipe(response); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Serving ${distRoot} at http://127.0.0.1:${port}${prefix}/`); +}); diff --git a/frontend/tests/smoke.spec.js b/frontend/tests/smoke.spec.js new file mode 100644 index 0000000..37594c2 --- /dev/null +++ b/frontend/tests/smoke.spec.js @@ -0,0 +1,173 @@ +import { expect, test } from "@playwright/test"; + +const SMOKE_ACCESS_TOKEN = "smoke-session-token"; + +async function installMockSession(page) { + await page.route("https://mock.supabase.test/**", (route) => route.abort()); + await page.addInitScript((accessToken) => { + window.localStorage.setItem( + "sb-mock-auth-token", + JSON.stringify({ + access_token: accessToken, + expires_at: Math.floor(Date.now() / 1000) + 3600, + expires_in: 3600, + refresh_token: "smoke-refresh-token", + token_type: "bearer", + user: { + email: "smoke@example.test", + id: "00000000-0000-4000-8000-000000000001", + user_metadata: { full_name: "Smoke Test" }, + }, + }), + ); + }, SMOKE_ACCESS_TOKEN); +} + +async function mockHealthyApi(page, requests) { + await page.route("**/health", async (route) => { + requests.push(route.request()); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + app_name: "Smoke API", + dify_configured: false, + llm_provider: "mock", + llm_ready: true, + persistent_data: true, + status: "ok", + storage: "mock", + }), + }); + }); + await page.route("**/notes", async (route) => { + requests.push(route.request()); + if (route.request().method() === "GET") { + await route.fulfill({ contentType: "application/json", body: "[]" }); + return; + } + await route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 1 }) }); + }); + await page.route("**/dify/access", async (route) => { + requests.push(route.request()); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ authenticated: true, authorized: false }), + }); + }); +} + +async function openApp(page, requests = []) { + await installMockSession(page); + await mockHealthyApi(page, requests); + await page.goto("./"); + await expect(page.getByRole("heading", { name: "從這裡開始學 RAG" })).toBeVisible(); +} + +test("built app renders at a path prefix with headings, navigation, and assets", async ({ page }) => { + const requests = []; + await openApp(page, requests); + + await expect(page.getByRole("navigation", { name: "教學步驟" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "學習路徑" })).toBeVisible(); + expect(page.url()).toContain("/smoke/"); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((request) => request.headers().authorization === `Bearer ${SMOKE_ACCESS_TOKEN}`)).toBe(true); + + const assetFailures = []; + page.on("response", (response) => { + if (response.url().includes("/smoke/") && response.status() >= 400) { + assetFailures.push(`${response.status()} ${response.url()}`); + } + }); + await page.reload(); + await expect(page.getByRole("heading", { name: "從這裡開始學 RAG" })).toBeVisible(); + expect(assetFailures).toEqual([]); +}); + +test("render exception shows safe Traditional Chinese fallback and reload action", async ({ page }) => { + await page.goto("./tests/error-boundary.html"); + + await expect(page.getByRole("heading", { name: "頁面暫時無法顯示" })).toBeVisible(); + await expect(page.getByRole("button", { name: "重新載入頁面" })).toBeVisible(); + await expect(page.getByText("fixture-only secret-like details must not be rendered")).toHaveCount(0); +}); + +test("API failure keeps the page rendered", async ({ page }) => { + await installMockSession(page); + await page.route("**/health", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ detail: "internal provider details must stay hidden" }), + }); + }); + await page.route("**/notes", async (route) => { + await route.fulfill({ status: 503, contentType: "application/json", body: "{}" }); + }); + await page.route("**/dify/access", async (route) => { + await route.fulfill({ contentType: "application/json", body: JSON.stringify({ authenticated: true, authorized: false }) }); + }); + await page.goto("./"); + + await expect(page.getByRole("heading", { name: "從這裡開始學 RAG" })).toBeVisible(); + await expect(page.getByText("服務目前無法完成請求,請稍後再試。").first()).toBeVisible(); + await expect(page.getByText("internal provider details must stay hidden")).toHaveCount(0); +}); + +test("422 shows the API's validation reason but not a structured detail", async ({ page }) => { + await openApp(page); + const responses = [ + { detail: "content must contain between 1 and 20000 characters." }, + { detail: [{ loc: ["body", "content"], msg: "internal validator trace" }] }, + ]; + await page.route("**/notes", async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + await route.fulfill({ status: 422, contentType: "application/json", body: JSON.stringify(responses.shift()) }); + }); + + await page.getByLabel("筆記標題").fill("smoke"); + await page.getByLabel("筆記內容(LLM 之後會從這裡找答案)").fill("smoke content"); + + await page.getByRole("button", { name: "建立筆記" }).click(); + await expect(page.locator("#noteResult")).toHaveText( + "422:輸入內容不符合要求:content must contain between 1 and 20000 characters.", + ); + + await page.getByRole("button", { name: "建立筆記" }).click(); + await expect(page.locator("#noteResult")).toHaveText("422:輸入內容不符合要求,請檢查後再試。"); + await expect(page.getByText("internal validator trace")).toHaveCount(0); +}); + +test("401, 403, and 429 are clear and do not trigger automatic retries", async ({ page }) => { + const requests = []; + await openApp(page, requests); + const statusResponses = [401, 403, 429]; + await page.route("**/webhooks", async (route) => { + const status = statusResponses.shift(); + requests.push(route.request()); + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ detail: "sensitive backend detail" }), + }); + }); + + await page.getByLabel("WebHook 接收 URL").fill("https://example.test/hook"); + for (const [status, message] of [ + [401, "登入狀態已失效或缺少登入,請重新登入。"], + [403, "你沒有權限執行此操作。"], + [429, "請求過於頻繁,請稍後再試。"], + ]) { + await page.getByRole("button", { name: "註冊 WebHook" }).click(); + // The same message also appears in the global status banner; assert the webhook result box. + await expect(page.locator("#webhookResult")).toHaveText(`${status}:${message}`); + await expect(page.getByText("sensitive backend detail")).toHaveCount(0); + } + + const webhookRequests = requests.filter((request) => request.url().endsWith("/webhooks")); + expect(webhookRequests).toHaveLength(3); + expect(webhookRequests.every((request) => request.headers().authorization === `Bearer ${SMOKE_ACCESS_TOKEN}`)).toBe(true); +}); diff --git a/frontend/vite.smoke.config.js b/frontend/vite.smoke.config.js new file mode 100644 index 0000000..5738296 --- /dev/null +++ b/frontend/vite.smoke.config.js @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; + +const frontendRoot = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + root: frontendRoot, + // Match the production build (`vite build --base ./`) so assets resolve under /smoke/. + base: "./", + build: { + rollupOptions: { + input: { + app: `${frontendRoot}/index.html`, + "tests/error-boundary": `${frontendRoot}/tests/error-boundary.html`, + }, + }, + }, +}); diff --git a/supabase/functions/api/index.ts b/supabase/functions/api/index.ts index 49c63ff..93c7878 100644 --- a/supabase/functions/api/index.ts +++ b/supabase/functions/api/index.ts @@ -1,4 +1,14 @@ import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2"; +import { + authorizeDifyUser, + buildDifyPayload, + consumeRateLimit, + executeDifyRequest, + rateLimitRouteKey, + SecurityError, + validateWebhookDestination, + webhookRequestInit, +} from "./security.ts"; const FUNCTION_NAME = "api"; const DEFAULT_APP_NAME = "AI-Agent-Tutorial"; @@ -6,8 +16,18 @@ const DEFAULT_TOP_K = 3; const MAX_RETRIEVAL_NOTES = 500; const MAX_WEBHOOK_RESPONSE_BODY = 2000; const MAX_INCOMING_WEBHOOK_BODY = 100_000; +const MAX_JSON_BODY_BYTES = 256_000; +const MAX_NOTE_CONTENT_LENGTH = 20_000; +const MAX_QUESTION_LENGTH = 4_000; +const MAX_WEBHOOK_SUBSCRIPTIONS_PER_EVENT = 5; const WEBHOOK_TIMEOUT_MS = 10_000; const LLM_TIMEOUT_MS = 60_000; +const RATE_LIMIT_WINDOW_SECONDS = 60; +const RATE_LIMIT_READS_PER_WINDOW = 120; +const RATE_LIMIT_WRITES_PER_WINDOW = 30; +const RATE_LIMIT_EXTERNAL_CALLS_PER_WINDOW = 10; +const RATE_LIMIT_INCOMING_WEBHOOKS_PER_WINDOW = 30; +const RATE_LIMIT_DIFY_USER_PER_WINDOW = 10; const DOCS_URL = "https://github.com/frobel0520/AI-Agent-Tutorial/tree/main/docs"; const NOTE_COLUMNS = "id,title,content,created_at,updated_at"; const SYSTEM_PROMPT = @@ -61,6 +81,7 @@ class HttpError extends Error { constructor( public readonly status: number, public readonly detail: string, + public readonly headers: HeadersInit = {}, ) { super(detail); this.name = "HttpError"; @@ -149,10 +170,28 @@ async function difyAccessEnabled(userId: string): Promise { } async function requireDifyAccess(request: Request): Promise { + await requireDifyUser(request); +} + +async function requireDifyUser(request: Request): Promise { + const user = await authenticatedUser(request); + try { + authorizeDifyUser(user, await difyAccessEnabled(user.id)); + } catch (error) { + if (error instanceof SecurityError) { + throw new HttpError(error.status, error.message, error.headers); + } + throw error; + } + return user; +} + +async function requireOperatorAccess(request: Request): Promise { const user = await authenticatedUser(request); if (!await difyAccessEnabled(user.id)) { - throw new HttpError(403, "Your account is not authorized to use Dify."); + throw new HttpError(403, "Operator access requires an enabled dify_access record."); } + return user; } async function readDifyAccess(request: Request): Promise { @@ -197,6 +236,7 @@ function corsHeaders(request: Request): HeadersInit { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Origin": allowOrigin, "Access-Control-Max-Age": "86400", + "Access-Control-Expose-Headers": "Retry-After", "Content-Type": "application/json; charset=utf-8", Vary: "Origin", }; @@ -206,8 +246,10 @@ function jsonResponse( body: unknown, status: number, request: Request, + extraHeaders: HeadersInit = {}, ): Response { const headers = corsHeaders(request); + Object.assign(headers, extraHeaders); if (status === 204) { return new Response(null, { status, headers }); } @@ -229,15 +271,48 @@ function parseJsonObject(rawBody: string): JsonObject { } async function readJsonBody(request: Request): Promise { - return parseJsonObject(await request.text()); + return parseJsonObject(await readRawBody(request, MAX_JSON_BODY_BYTES)); } async function readRawBody(request: Request, maxLength: number): Promise { - const rawBody = await request.text(); - if (rawBody.length > maxLength) { - throw new HttpError(413, "Request body is too large."); + const contentLength = request.headers.get("Content-Length"); + if (contentLength) { + const declaredLength = Number(contentLength); + if (Number.isFinite(declaredLength) && declaredLength > maxLength) { + throw new HttpError(413, "Request body is too large."); + } } - return rawBody; + + if (!request.body) { + return ""; + } + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.byteLength; + if (totalBytes > maxLength) { + await reader.cancel(); + throw new HttpError(413, "Request body is too large."); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); } function requiredString( @@ -319,7 +394,7 @@ async function findNote(noteId: number): Promise { async function createNote(request: Request): Promise { const body = await readJsonBody(request); const title = requiredString(body, "title", 1, 200); - const content = requiredString(body, "content"); + const content = requiredString(body, "content", 1, MAX_NOTE_CONTENT_LENGTH); const { data, error } = await supabase .from("notes") .insert({ title, content }) @@ -338,7 +413,7 @@ async function updateNote(request: Request, noteId: number): Promise { const body = await readJsonBody(request); - const question = requiredString(body, "question"); + const question = requiredString(body, "question", 1, MAX_QUESTION_LENGTH); const topK = parseTopK(body); const notes = await listNotes(); const sources = selectRelevantNotes(notes, question, topK); @@ -799,7 +897,8 @@ async function askQuestion(request: Request): Promise { }; } -async function listWebhooks(): Promise { +async function listWebhooks(request: Request): Promise { + await requireOperatorAccess(request); const { data, error } = await supabase .from("webhook_subscriptions") .select("id,url,event_types,created_at") @@ -811,25 +910,34 @@ async function listWebhooks(): Promise { } async function createWebhook(request: Request): Promise { + await requireOperatorAccess(request); const body = await readJsonBody(request); const url = requiredString(body, "url", 1, 500); - let parsedUrl: URL; try { - parsedUrl = new URL(url); - } catch { - throw new HttpError(422, "url must be a valid HTTP or HTTPS URL."); - } - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - throw new HttpError(422, "url must be a valid HTTP or HTTPS URL."); + validateWebhookDestination(url, env("WEBHOOK_ALLOWED_URLS")); + } catch (error) { + if (error instanceof SecurityError) { + throw new HttpError(error.status, error.message, error.headers); + } + throw error; } const eventTypes = typeof body.event_types === "undefined" ? "*" : requiredString(body, "event_types", 1, 200); const secret = optionalString(body, "secret", 1, 200); + const subscriptionCount = await supabase + .from("webhook_subscriptions") + .select("id", { count: "exact", head: true }); + if (subscriptionCount.error) { + databaseError(subscriptionCount.error, "webhook subscription count"); + } + if ((subscriptionCount.count ?? 0) >= MAX_WEBHOOK_SUBSCRIPTIONS_PER_EVENT) { + throw new HttpError(429, "The webhook subscription limit has been reached."); + } const { data, error } = await supabase .from("webhook_subscriptions") - .insert({ event_types: eventTypes, secret, url }) + .insert({ event_types: eventTypes, secret, url: validateWebhookDestination(url, env("WEBHOOK_ALLOWED_URLS")) }) .select("id,url,event_types,created_at") .single(); if (error) { @@ -838,7 +946,8 @@ async function createWebhook(request: Request): Promise { return { status: 201, body: data }; } -async function deleteWebhook(webhookId: number): Promise { +async function deleteWebhook(request: Request, webhookId: number): Promise { + await requireOperatorAccess(request); const { data, error: lookupError } = await supabase .from("webhook_subscriptions") .select("id") @@ -858,7 +967,8 @@ async function deleteWebhook(webhookId: number): Promise { return { status: 204, body: null }; } -async function listEvents(): Promise { +async function listEvents(request: Request): Promise { + await requireOperatorAccess(request); const { data, error } = await supabase .from("event_logs") .select("id,event_type,payload,created_at") @@ -871,35 +981,16 @@ async function listEvents(): Promise { } async function askDify(request: Request): Promise { - await requireDifyAccess(request); - const body = await readJsonBody(request); - const question = requiredString(body, "question"); - const user = typeof body.user === "undefined" ? "tutorial-user" : requiredString(body, "user"); - const baseUrl = requireConfiguration("DIFY_API_BASE").replace(/\/$/, ""); - const apiKey = requireConfiguration("DIFY_API_KEY"); - const response = await fetchWithTimeout( - `${baseUrl}/chat-messages`, - { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - conversation_id: "", - inputs: {}, - query: question, - response_mode: "blocking", - user, - }), - }, - LLM_TIMEOUT_MS, - ); - const data = await responseData(response); - if (!response.ok) { - console.error("Dify request failed", response.status, errorMessage(data, "unknown error")); - throw new HttpError(502, "Dify request failed."); - } + const { question, data } = await executeDifyRequest({ + accessEnabled: difyAccessEnabled, + apiKey: requireConfiguration("DIFY_API_KEY"), + authenticate: () => authenticatedUser(request), + baseUrl: requireConfiguration("DIFY_API_BASE"), + fetch: (input, init) => fetchWithTimeout(input, init, LLM_TIMEOUT_MS), + maxQuestionLength: MAX_QUESTION_LENGTH, + onAuthorized: (userId) => enforceRateLimit("POST:dify_ask", `user:${userId}`, RATE_LIMIT_DIFY_USER_PER_WINDOW), + readBody: () => readJsonBody(request), + }); const objectData: JsonObject = data && typeof data === "object" && !Array.isArray(data) ? data as JsonObject @@ -932,6 +1023,54 @@ function getRoute(request: Request): string[] { return pathname.split("/").filter(Boolean); } +function rateLimitAmount(routeKey: string): number { + if (routeKey.includes("ask") || routeKey.includes("POST:dify")) { + return RATE_LIMIT_EXTERNAL_CALLS_PER_WINDOW; + } + if (routeKey.includes("incoming_webhooks")) { + return RATE_LIMIT_INCOMING_WEBHOOKS_PER_WINDOW; + } + if (routeKey.startsWith("GET:")) { + return RATE_LIMIT_READS_PER_WINDOW; + } + return RATE_LIMIT_WRITES_PER_WINDOW; +} + +async function enforceRateLimit( + routeKey: string, + bucketKey: string, + limit: number, +): Promise { + try { + await consumeRateLimit( + async (args) => { + const { data, error } = await supabase.rpc("consume_api_rate_limit", args); + if (error) { + throw new Error(error.message); + } + const row = Array.isArray(data) ? data[0] : data; + return row + ? { + allowed: row.allowed === true, + retry_after_seconds: Number(row.retry_after_seconds) || 1, + } + : null; + }, + { + p_route_key: routeKey, + p_bucket_key: bucketKey, + p_limit: limit, + p_window_seconds: RATE_LIMIT_WINDOW_SECONDS, + }, + ); + } catch (error) { + if (error instanceof SecurityError) { + throw new HttpError(error.status, error.message, error.headers); + } + throw error; + } +} + async function routeRequest(request: Request): Promise { const route = getRoute(request); const [resource, identifier, action] = route; @@ -988,18 +1127,18 @@ async function routeRequest(request: Request): Promise { if (resource === "webhooks") { if (!identifier && request.method === "GET") { - return listWebhooks(); + return listWebhooks(request); } if (!identifier && request.method === "POST") { return createWebhook(request); } if (identifier && request.method === "DELETE") { - return deleteWebhook(parseId(identifier)); + return deleteWebhook(request, parseId(identifier)); } } if (resource === "events" && !identifier && request.method === "GET") { - return listEvents(); + return listEvents(request); } if (resource === "dify" && identifier === "ask" && request.method === "POST") { @@ -1019,11 +1158,18 @@ Deno.serve(async (request) => { } try { + const routeKey = rateLimitRouteKey(request.method, getRoute(request)); + if (routeKey) { + await enforceRateLimit(routeKey, "global", rateLimitAmount(routeKey)); + } const result = await routeRequest(request); return jsonResponse(result.body, result.status, request); } catch (error) { if (error instanceof HttpError) { - return jsonResponse({ detail: error.detail }, error.status, request); + return jsonResponse({ detail: error.detail }, error.status, request, error.headers); + } + if (error instanceof SecurityError) { + return jsonResponse({ detail: error.message }, error.status, request, error.headers); } console.error("Unhandled API error", error); return jsonResponse({ detail: "Internal server error." }, 500, request); diff --git a/supabase/functions/api/rate_limit_test.ts b/supabase/functions/api/rate_limit_test.ts new file mode 100644 index 0000000..097fac6 --- /dev/null +++ b/supabase/functions/api/rate_limit_test.ts @@ -0,0 +1,89 @@ +import { assert, assertEquals } from "jsr:@std/assert@1"; +import postgres from "npm:postgres@3.4.5"; + +// Applies the rate-limit migration to a throwaway Postgres database and calls +// the RPC directly. Skipped locally without RATE_LIMIT_TEST_DATABASE_URL; CI must set it. +const DATABASE_URL = Deno.env.get("RATE_LIMIT_TEST_DATABASE_URL"); +const MIGRATION_URL = new URL( + "../../migrations/20260909000000_add_edge_rate_limit.sql", + import.meta.url, +); +// Supabase provides these roles; a plain Postgres database does not. +const SUPABASE_ROLES = ["anon", "authenticated", "service_role"]; + +type RateLimitRow = { allowed: boolean; retry_after_seconds: number }; + +const databaseTest = { + ignore: !DATABASE_URL && Deno.env.get("CI") !== "true", +}; + +async function withMigratedDatabase( + run: (sql: postgres.Sql) => Promise, +): Promise { + if (!DATABASE_URL) { + throw new Error("RATE_LIMIT_TEST_DATABASE_URL is required in CI."); + } + const sql = postgres(DATABASE_URL, { max: 1, onnotice: () => {} }); + try { + for (const role of SUPABASE_ROLES) { + const [existing] = await sql`select 1 from pg_roles where rolname = ${role}`; + if (!existing) { + await sql.unsafe(`create role ${role}`); + } + } + await sql`drop table if exists public.api_rate_limits`; + await sql.unsafe(await Deno.readTextFile(MIGRATION_URL)); + await run(sql); + } finally { + await sql.end(); + } +} + +async function consume(sql: postgres.Sql, limit: number): Promise { + const [row] = await sql` + select * from public.consume_api_rate_limit('POST:ask', 'global', ${limit}, 60) + `; + return row; +} + +async function storedCount(sql: postgres.Sql): Promise { + const [row] = await sql<{ request_count: number }[]>` + select request_count from public.api_rate_limits + where route_key = 'POST:ask' and bucket_key = 'global' + `; + return row.request_count; +} + +Deno.test({ + ...databaseTest, + name: "consume_api_rate_limit denies calls past the limit and keeps the count bounded", + fn: () => + withMigratedDatabase(async (sql) => { + const results: RateLimitRow[] = []; + for (let call = 0; call < 5; call++) { + results.push(await consume(sql, 3)); + } + + assertEquals(results.map((result) => result.allowed), [true, true, true, false, false]); + assert(results[3].retry_after_seconds >= 1); + assertEquals(await storedCount(sql), 4); + }), +}); + +Deno.test({ + ...databaseTest, + name: "consume_api_rate_limit opens a new window after the old one expires", + fn: () => + withMigratedDatabase(async (sql) => { + await consume(sql, 1); + assertEquals((await consume(sql, 1)).allowed, false); + + await sql` + update public.api_rate_limits + set window_started_at = now() - interval '61 seconds' + `; + + assertEquals((await consume(sql, 1)).allowed, true); + assertEquals(await storedCount(sql), 1); + }), +}); diff --git a/supabase/functions/api/security.ts b/supabase/functions/api/security.ts new file mode 100644 index 0000000..8855633 --- /dev/null +++ b/supabase/functions/api/security.ts @@ -0,0 +1,288 @@ +export class SecurityError extends Error { + constructor( + public readonly status: number, + message: string, + public readonly headers: Record = {}, + ) { + super(message); + this.name = "SecurityError"; + } +} + +export type RateLimitRpcResult = { + allowed: boolean; + retry_after_seconds: number; +}; + +export type RateLimitRpc = (args: { + p_route_key: string; + p_bucket_key: string; + p_limit: number; + p_window_seconds: number; +}) => Promise; + +// Must stay identical to the p_route_key allowlist in consume_api_rate_limit +// (supabase/migrations/20260909000000_add_edge_rate_limit.sql); security_test.ts checks it. +export const RATE_LIMIT_ROUTE_KEYS: ReadonlySet = new Set([ + "GET:notes", "POST:notes", "PUT:notes", "DELETE:notes", + "POST:ask", "GET:webhooks", "POST:webhooks", "DELETE:webhooks", + "GET:events", "POST:incoming_webhooks", "POST:dify_ask", + "GET:dify_access", "GET:other", "POST:other", "PUT:other", + "DELETE:other", "OTHER:other", +]); + +export function rateLimitRouteKey(method: string, route: readonly string[]): string | null { + const [resource, identifier] = route; + if ((route.length === 0 || resource === "health") && method === "GET") { + return null; + } + + const normalizedMethod = ["GET", "POST", "PUT", "DELETE"].includes(method) ? method : "OTHER"; + + // Deliberately use fixed route classes, never a client-controlled path or id. + const routeClass = resource === "notes" + ? "notes" + : resource === "ask" && !identifier + ? "ask" + : resource === "webhooks" + ? "webhooks" + : resource === "events" + ? "events" + : resource === "hooks" && identifier === "incoming" + ? "incoming_webhooks" + : resource === "dify" && identifier === "ask" + ? "dify_ask" + : resource === "dify" && identifier === "access" + ? "dify_access" + : "other"; + const routeKey = `${normalizedMethod}:${routeClass}`; + // A method the route does not serve (e.g. GET /ask) must still map to a key the + // database accepts, so the router can answer 404/405 instead of a 503. + return RATE_LIMIT_ROUTE_KEYS.has(routeKey) ? routeKey : `${normalizedMethod}:other`; +} + +const IPV4_OCTET_COUNT = 4; + +function isPrivateIpv4(address: string): boolean { + const octets = address.split(".").map(Number); + if ( + octets.length !== IPV4_OCTET_COUNT || + octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) + ) { + return false; + } + + const [first, second, third] = octets; + return first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 0 && third === 0) || + (first === 192 && second === 168) || + (first === 198 && second >= 18 && second <= 19) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224; +} + +function isPrivateIpv6(address: string): boolean { + const normalized = address.toLowerCase(); + if (normalized === "::" || normalized === "::1") { + return true; + } + + const firstHextet = normalized.split(":").find(Boolean); + if (!firstHextet) { + return false; + } + const firstValue = Number.parseInt(firstHextet, 16); + if (!Number.isInteger(firstValue)) { + return false; + } + + // fc00::/7 is unique-local and fe80::/10 is link-local. + if ((firstValue & 0xfe00) === 0xfc00 || (firstValue & 0xffc0) === 0xfe80) { + return true; + } + + const mappedIpv4 = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); + return mappedIpv4 ? isPrivateIpv4(mappedIpv4[1]) : false; +} + +function canonicalizeWebhookUrl(rawUrl: string): string { + if (rawUrl.includes("#")) { + throw new SecurityError(422, "Webhook URL must not contain a fragment."); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(rawUrl); + } catch { + throw new SecurityError(422, "Webhook URL must be a valid HTTPS URL."); + } + + if (parsedUrl.protocol !== "https:") { + throw new SecurityError(422, "Webhook URL must use HTTPS."); + } + if (parsedUrl.username || parsedUrl.password) { + throw new SecurityError(422, "Webhook URL must not contain credentials."); + } + if (parsedUrl.port) { + throw new SecurityError(422, "Webhook URL must use the standard HTTPS port."); + } + + const hostname = parsedUrl.hostname.replace(/^\[|\]$/g, ""); + if (!hostname) { + throw new SecurityError(422, "Webhook URL must contain a hostname."); + } + if ( + hostname.endsWith(".") || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + !hostname.includes(".") || + isPrivateIpv4(hostname) || + hostname.includes(":") + ) { + throw new SecurityError(422, "Webhook URL must not target a private, loopback, or link-local address."); + } + + return parsedUrl.href; +} + +export function parseWebhookAllowedUrls(rawValue: string): readonly string[] { + const entries = rawValue.split(",").map((entry) => entry.trim()).filter(Boolean); + if (entries.length === 0) { + return []; + } + return [...new Set(entries.map(canonicalizeWebhookUrl))]; +} + +export function validateWebhookDestination(rawUrl: string, rawAllowlist: string): string { + const allowedUrls = parseWebhookAllowedUrls(rawAllowlist); + if (allowedUrls.length === 0) { + throw new SecurityError(503, "Webhook delivery is disabled because WEBHOOK_ALLOWED_URLS is empty."); + } + + const canonicalUrl = canonicalizeWebhookUrl(rawUrl); + if (!allowedUrls.includes(canonicalUrl)) { + throw new SecurityError(422, "Webhook URL is not in the exact WEBHOOK_ALLOWED_URLS allowlist."); + } + return canonicalUrl; +} + +export function webhookRequestInit( + body: string, + headers: Record, +): RequestInit { + return { + body, + headers, + method: "POST", + redirect: "error", + }; +} + +export type VerifiedUser = { id: string }; + +export function authorizeDifyUser( + user: VerifiedUser | null, + accessEnabled: boolean, +): string { + if (!user?.id) { + throw new SecurityError(401, "Your login session is invalid or expired."); + } + if (!accessEnabled) { + throw new SecurityError(403, "Your account is not authorized to use Dify."); + } + return user.id; +} + +export function buildDifyPayload(question: string, verifiedUserId: string): Record { + return { + conversation_id: "", + inputs: {}, + query: question, + response_mode: "blocking", + user: verifiedUserId, + }; +} + +export type DifyGatewayDependencies = { + authenticate: () => Promise; + accessEnabled: (userId: string) => Promise; + readBody: () => Promise>; + fetch: (input: string, init: RequestInit) => Promise; + baseUrl: string; + apiKey: string; + maxQuestionLength: number; + onAuthorized: (userId: string) => Promise; +}; + +export async function executeDifyRequest( + dependencies: DifyGatewayDependencies, +): Promise<{ question: string; userId: string; data: unknown }> { + const verifiedUser = await dependencies.authenticate(); + const userId = authorizeDifyUser( + verifiedUser, + verifiedUser ? await dependencies.accessEnabled(verifiedUser.id) : false, + ); + await dependencies.onAuthorized(userId); + + const body = await dependencies.readBody(); + const question = body.question; + if ( + typeof question !== "string" || + question.trim().length < 1 || + question.trim().length > dependencies.maxQuestionLength + ) { + throw new SecurityError(422, "question must contain a valid bounded string."); + } + + const response = await dependencies.fetch( + `${dependencies.baseUrl.replace(/\/$/, "")}/chat-messages`, + { + body: JSON.stringify(buildDifyPayload(question.trim(), userId)), + headers: { + Authorization: `Bearer ${dependencies.apiKey}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + const rawResponse = await response.text(); + let data: unknown = null; + if (rawResponse) { + try { + data = JSON.parse(rawResponse); + } catch { + data = rawResponse; + } + } + if (!response.ok) { + throw new SecurityError(502, "Dify request failed."); + } + return { data, question: question.trim(), userId }; +} + +export async function consumeRateLimit( + rpc: RateLimitRpc, + args: Parameters[0], +): Promise { + let result: RateLimitRpcResult | null; + try { + result = await rpc(args); + } catch (error) { + console.error("Rate limit RPC failed", error instanceof Error ? error.message : error); + throw new SecurityError(503, "Rate limiting is temporarily unavailable."); + } + + if (!result || typeof result.allowed !== "boolean") { + throw new SecurityError(503, "Rate limiting is temporarily unavailable."); + } + if (!result.allowed) { + const retryAfter = Math.max(1, Math.ceil(Number(result.retry_after_seconds) || 1)); + throw new SecurityError(429, "Rate limit exceeded.", { "Retry-After": String(retryAfter) }); + } +} diff --git a/supabase/functions/api/security_test.ts b/supabase/functions/api/security_test.ts new file mode 100644 index 0000000..59af75d --- /dev/null +++ b/supabase/functions/api/security_test.ts @@ -0,0 +1,55 @@ +import { assert, assertEquals } from "jsr:@std/assert@1"; +import { RATE_LIMIT_ROUTE_KEYS, rateLimitRouteKey } from "./security.ts"; + +const MIGRATION_URL = new URL( + "../../migrations/20260909000000_add_edge_rate_limit.sql", + import.meta.url, +); +const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]; +const ROUTES = [ + [], + ["health"], + ["notes"], + ["notes", "1"], + ["ask"], + ["ask", "extra"], + ["webhooks"], + ["webhooks", "1"], + ["events"], + ["hooks", "incoming"], + ["dify", "ask"], + ["dify", "access"], + ["unknown"], +]; + +async function migrationRouteKeys(): Promise { + const sql = await Deno.readTextFile(MIGRATION_URL); + const list = sql.match(/p_route_key not in \(([^)]*)\)/)?.[1]; + assert(list, "consume_api_rate_limit route allowlist not found in the migration."); + return [...list.matchAll(/'([^']+)'/g)].map((match) => match[1]).sort(); +} + +Deno.test("RATE_LIMIT_ROUTE_KEYS matches the migration allowlist", async () => { + assertEquals([...RATE_LIMIT_ROUTE_KEYS].sort(), await migrationRouteKeys()); +}); + +Deno.test("every method and route maps to a key the database accepts", async () => { + const allowed = new Set(await migrationRouteKeys()); + for (const method of METHODS) { + for (const route of ROUTES) { + const routeKey = rateLimitRouteKey(method, route); + assert( + routeKey === null || allowed.has(routeKey), + `${method} /${route.join("/")} produced ${routeKey}, which the database rejects with a 503.`, + ); + } + } +}); + +Deno.test("a method the route does not serve falls back to the other class", () => { + assertEquals(rateLimitRouteKey("GET", ["ask"]), "GET:other"); + assertEquals(rateLimitRouteKey("PUT", ["webhooks"]), "PUT:other"); + assertEquals(rateLimitRouteKey("PATCH", ["notes", "1"]), "OTHER:other"); + assertEquals(rateLimitRouteKey("POST", ["ask"]), "POST:ask"); + assertEquals(rateLimitRouteKey("GET", ["health"]), null); +}); diff --git a/supabase/migrations/20260909000000_add_edge_rate_limit.sql b/supabase/migrations/20260909000000_add_edge_rate_limit.sql new file mode 100644 index 0000000..c9cf72f --- /dev/null +++ b/supabase/migrations/20260909000000_add_edge_rate_limit.sql @@ -0,0 +1,116 @@ +-- Durable shared rate limiting for the Edge API. +-- The Edge Function calls this through the service role only. It uses fixed +-- route classes and a global bucket; client IPs are deliberately not inputs. + +create table if not exists public.api_rate_limits ( + route_key text not null, + bucket_key text not null, + window_started_at timestamptz not null, + request_count integer not null check (request_count between 0 and 1000001), + updated_at timestamptz not null, + primary key (route_key, bucket_key), + check (char_length(route_key) between 1 and 64), + check (char_length(bucket_key) between 1 and 80) +); + +alter table public.api_rate_limits enable row level security; +revoke all on table public.api_rate_limits from public, anon, authenticated; + +create or replace function public.consume_api_rate_limit( + p_route_key text, + p_bucket_key text, + p_limit integer, + p_window_seconds integer +) +returns table (allowed boolean, retry_after_seconds integer) +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_now timestamptz := clock_timestamp(); + v_window_started_at timestamptz; + v_request_count integer; +begin + if p_route_key not in ( + 'GET:notes', 'POST:notes', 'PUT:notes', 'DELETE:notes', + 'POST:ask', 'GET:webhooks', 'POST:webhooks', 'DELETE:webhooks', + 'GET:events', 'POST:incoming_webhooks', 'POST:dify_ask', + 'GET:dify_access', 'GET:other', 'POST:other', 'PUT:other', + 'DELETE:other', 'OTHER:other' + ) then + raise exception 'unsupported rate limit route'; + end if; + + if p_bucket_key <> 'global' and not ( + p_route_key = 'POST:dify_ask' and + p_bucket_key ~ '^user:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ) then + raise exception 'unsupported rate limit bucket'; + end if; + + if p_limit < 1 or p_limit > 1000000 or p_window_seconds < 1 or p_window_seconds > 86400 then + raise exception 'invalid rate limit policy'; + end if; + + -- The unique key plus ON CONFLICT update is the concurrency boundary. + -- The first denied request moves the counter to p_limit + 1 and it stays + -- there, so the count stays bounded but is still distinguishable from the + -- last allowed request. + insert into public.api_rate_limits( + route_key, bucket_key, window_started_at, request_count, updated_at + ) values ( + p_route_key, p_bucket_key, v_now, 1, v_now + ) + on conflict (route_key, bucket_key) do update + set + window_started_at = case + when public.api_rate_limits.window_started_at <= + v_now - pg_catalog.make_interval(secs => p_window_seconds) + then v_now + else public.api_rate_limits.window_started_at + end, + request_count = case + when public.api_rate_limits.window_started_at <= + v_now - pg_catalog.make_interval(secs => p_window_seconds) + then 1 + when public.api_rate_limits.request_count <= p_limit + then public.api_rate_limits.request_count + 1 + else public.api_rate_limits.request_count + end, + updated_at = v_now; + + select limits.window_started_at, limits.request_count + into v_window_started_at, v_request_count + from public.api_rate_limits as limits + where limits.route_key = p_route_key and limits.bucket_key = p_bucket_key; + + -- Keep cleanup bounded per call. The fixed route/bucket policy prevents + -- arbitrary path keys from turning this table into an unbounded store. + with stale as ( + select ctid + from public.api_rate_limits + where updated_at < v_now - pg_catalog.make_interval(secs => p_window_seconds * 2) + order by updated_at + limit 100 + ) + delete from public.api_rate_limits as limits + using stale + where limits.ctid = stale.ctid; + + return query + select + v_request_count <= p_limit, + greatest( + 1, + ceil(extract(epoch from ( + v_window_started_at + pg_catalog.make_interval(secs => p_window_seconds) - v_now + )))::integer + ); +end; +$$; + +revoke all on function public.consume_api_rate_limit(text, text, integer, integer) + from public, anon, authenticated; +grant execute on function public.consume_api_rate_limit(text, text, integer, integer) + to service_role;