---
title: "Custom App 開發者指南"
description: "說明如何透過 API 開發 Custom App，涵蓋 VFS 架構、編譯流程、內建 SDK、Server-Side Action、獨立登入與成員邀請、Custom Table 結構管理，以及 AI Agent 自動化開發最佳實踐。"
canonical: "https://www.ai-go.app/zh-TW/docs/custom-app-dev"
locale: "zh-TW"
last_updated: "2026-09-01"
source: "AI GO（https://www.ai-go.app）"
---

# AI GO Custom App — 開發者指南

本文檔說明如何透過 API 開發 AI GO Custom App，適用於 AI Agent 自動化開發和人類開發者手動整合。

---

## 目錄

1. [什麼是 Custom App](#1-什麼是-custom-app)
2. [認證與連線](#2-認證與連線)
3. [首次連入：理解 App 架構](#3-首次連入理解-app-架構)
4. [VFS 檔案結構](#4-vfs-檔案結構)
5. [程式碼注入 API](#5-程式碼注入-api)
6. [編譯與偵錯](#6-編譯與偵錯)
7. [內建 SDK](#7-內建-sdk)
8. [Server-Side Actions](#8-server-side-actions)
9. [驗證與發布](#9-驗證與發布)
10. [常見問題](#10-常見問題)
11. [Shadow DOM 與 CSS 樣式規範](#11-shadow-dom-與-css-樣式規範)
12. [VFS 注入腳本開發規範](#12-vfs-注入腳本開發規範)
13. [資料中心：租戶級自建表](#13-資料中心租戶級自建表)
14. [共享資料表隔離策略 (Data Domain Separation)](#14-共享資料表隔離策略-data-domain-separation)
15. [檔案上傳與儲存 (Storage API)](#15-檔案上傳與儲存-storage-api)
16. [開發與部署最佳實踐 (Best Practices)](#16-開發與部署最佳實踐-best-practices)
17. [Internal App 獨立登入與成員邀請](#17-internal-app-獨立登入與成員邀請)
18. [Public 匿名檢視功能](#18-public-匿名檢視功能)
19. [External App 獨立認證系統](#19-external-app-獨立認證系統)
20. [套件管理與第三方依賴](#20-套件管理與第三方依賴)
21. [引用系統 API](#21-引用系統-api)
22. [接收外部 Webhook](#22-接收外部-webhook)
23. [簽核工作流 (Approval Workflow)](#23-簽核工作流-approval-workflow)
24. [企業知識檢索 (ctx.knowledge)](#24-企業知識檢索-ctxknowledge)
25. [外部服務閘道 (Egress)](#25-外部服務閘道-egress)
26. [Scope 授權模型](#26-scope-授權模型)
27. [建立與刪除 App（API）](#27-建立與刪除-appapi)

---

## 1. 什麼是 Custom App

Custom App 是 AI GO 平台內的可程式化微應用，讓您以 **React + TypeScript** 建立自訂的業務工具。

### 核心概念

- **VFS（Virtual File System）**：以 JSON 物件 `{"檔案路徑": "檔案內容"}` 儲存所有原始碼
- **esbuild 編譯器**：將 React TSX 編譯為瀏覽器可執行的 JS bundle
- **Runtime 沙箱**：在 **Shadow DOM** 隔離環境中安全執行已編譯的 App
- **Server-Side Actions**：Python 後端腳本，在 App 專屬的隔離 runner 中執行（詳見第 8 章）

### Internal vs External

| 特性 | Internal（內部應用） | External（外部應用） | Public（匿名檢視） |
|------|:---:|:---:|:---:|
| 使用場景 | 組織內部管理工具 | 對外客戶/供應商應用 | 產品目錄、場館展示等公開頁面 |
| 認證方式 | 主站帳號登入 | 獨立帳號系統 | 無需登入（匿名） |
| API 操作 | 完全相同（透過 Builder API） | 完全相同（透過 Builder API） | 唯讀 pub/ API（詳見第 18 章） |
| 資料寫入 | CRUD | CRUD | 僅讀取 |

---

## 2. 認證與連線

### 租戶空間網址（所有 API 的共同前綴）

所有 API 與登入一律走**租戶空間**網址：

```
https://{tenant}.ai-go.app/*
```

`{tenant}` 是您所屬租戶的識別碼——用戶登入平台時，網址列的第一段就是它（例如 `https://acme.ai-go.app/dashboard` 的 `acme`）。本文件所有端點範例中的 `https://{tenant}.ai-go.app` 都應代入實際租戶。

> ⚠️ **打錯租戶（含打 apex `https://ai-go.app`）的症狀是 401「帳號或密碼錯誤」**，與密碼打錯完全同形（平台反帳號列舉的刻意設計）。看到 401 請先確認網址的租戶前綴是否正確，不要一路查密碼。

### 取得 JWT Token

所有 API 操作需要具備 `builder.access` 權限的帳號。平台管理員會提供可用的帳號與密碼。

```http
POST https://{tenant}.ai-go.app/api/v1/auth/login
Content-Type: application/json

{
  "email": "developer@example.com",
  "password": "your_password"
}
```

**回應**：
```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "...",
  "expires_in": 3600,
  "token_type": "bearer"
}
```

### 使用 JWT

```http
GET /api/v1/builder/apps/{slug}
Authorization: Bearer {access_token}
```

---

## 3. 首次連入：理解 App 架構

> **重要**：開始修改前，必須先讀取並理解當前 App 的 VFS 結構，避免使用不相容的架構。

### 標準流程

```
1. GET /api/v1/builder/apps/{slug}
   → 取得 vfs_state, vfs_version, access_mode

2. 分析 VFS 結構：
   - 讀取 src/App.tsx → 理解路由結構
   - 讀取 src/routes.ts → 理解導航配置
   - 讀取 src/pages/_manifest.json → 頁面清單

3. 確認 SDK：
   - src/api.ts → 資料中心自建表 CRUD
   - src/db.ts → DB Proxy
   - src/action.ts → Server-Side Action
```

### 核心規則

1. 使用 **React 18 + TypeScript + HashRouter**（若 App 只有單一頁面，可直接渲染元件，不需 Router）
2. React / ReactDOM / lucide-react / react-router-dom 由 Runtime 提供，不可自行安裝
3. CSS 使用全域 `App.css`，不支援 CSS Modules 或 Tailwind
4. 入口點必須是 `src/main.tsx`
5. Server-Side Action 用 Python 撰寫，放在 `actions/` 目錄
6. **Runtime 在 Shadow DOM 中執行** — CSS 變數必須用 `:host, :root` 雙選擇器，不可只用 `:root`（詳見第 11 章）
7. **Shadow DOM 容器需設定 `overflow-y: auto`** — 否則內容過長時無法捲動（詳見第 11 章）

---

## 4. VFS 檔案結構

### 標準檔案樹

```
├── package.json                    # 依賴宣告
├── src/
│   ├── main.tsx                    # 入口點（必須存在）
│   ├── App.tsx                     # 路由 + Layout
│   ├── App.css                     # 全域樣式
│   ├── routes.ts                   # 導航配置
│   ├── api.ts                      # SDK：資料中心自建表 CRUD
│   ├── db.ts                       # SDK：DB Proxy
│   ├── action.ts                   # SDK：Server-Side Action
│   ├── approval.ts                 # SDK：簽核系統操作（僅 Internal App）
│   ├── user.ts                     # SDK：登入者角色/權限快照（僅 Internal App）
│   ├── db.json                     # Data Reference 定義（自動注入）
│   ├── pages/
│   │   ├── _manifest.json          # 頁面清單
│   │   ├── DashboardPage.tsx       # 頁面元件
│   │   └── NotFoundPage.tsx        # 404 頁面
│   └── components/
│       ├── AppLayout.tsx           # 主 Layout
│       ├── AppSidebar.tsx          # 側邊欄
│       └── AppHeader.tsx           # 頂部欄
└── actions/
    ├── manifest.json               # Action 註冊清單
    ├── _shared/                    # Action 之間的共用程式碼（非 Action，不需 execute）
    │   └── money.py
    └── example_action.py           # Action 實作
```

### `actions/_shared/`：Action 之間共用程式碼

Action **彼此之間不能互相 import**——`from other_action import ...` 不會 work，因為 Action 不是模組。要共用邏輯，放進 `actions/_shared/`：

```python
# actions/_shared/money.py —— 不是 Action，不需要 execute(ctx)
def round_to_cents(value):
    return round(value + 1e-9, 2)
```

```python
# actions/settle_overpayment.py
from _shared.money import round_to_cents

def execute(ctx):
    total = round_to_cents(sum(x["amount"] for x in ctx.params["items"]))
    ctx.response.json({"total": total})
```

`_shared/` 底下的檔案**不需要**（也不應該）登記進 `actions/manifest.json`——它們不是 Action，不會被派發，也不會產生對外端點。

### 不可修改的 SDK 檔案

| 檔案 | 說明 |
|------|------|
| `src/api.ts` | 資料中心自建表 CRUD SDK |
| `src/db.ts` | DB Proxy SDK |
| `src/action.ts` | Server Action SDK |
| `src/approval.ts` | Approval SDK（簽核操作，僅 Internal App，詳見第 23 章） |
| `src/user.ts` | User Context SDK（登入者角色／權限快照，僅 Internal App，詳見第 7 章） |
| `src/db.json` | Runtime 自動注入 |

---

## 5. 程式碼注入 API

### API 端點一覽

| 操作 | HTTP 方法 | 端點 |
|------|----------|------|
| 取得 App（含 VFS） | GET | `/api/v1/builder/apps/{slug}` |
| 全量覆寫 VFS | PUT | `/api/v1/builder/apps/{id}/source` |
| 局部更新檔案 | PATCH | `/api/v1/builder/apps/{id}/source/files` |
| 刪除檔案 | DELETE | `/api/v1/builder/apps/{id}/source/files` |

### 局部更新（推薦）

```http
PATCH /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "files": {
    "src/pages/NewPage.tsx": "import React from 'react';\n\nexport default function NewPage() {\n  return <div>新頁面</div>;\n}",
    "src/App.tsx": "...更新後的完整內容..."
  },
  "expected_version": 5
}
```

### 刪除檔案

```http
DELETE /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "paths": ["src/pages/OldPage.tsx"],
  "expected_version": 6
}
```

### 樂觀鎖

所有修改端點支援 `expected_version` 參數：
- 取得 App 時記錄 `vfs_version`
- 修改時帶入 `expected_version`
- 若版本不匹配 → 回傳 **409 Conflict**

---

## 6. 編譯與偵錯

### 編譯 API

```http
POST /api/v1/compile/compile/{slug}?dev=true
Authorization: Bearer {JWT}
```

**成功回應**：
```json
{
  "success": true,
  "html": "<!DOCTYPE html>...",
  "bundle_js": "...",
  "css": "..."
}
```

**失敗回應**：
```json
{
  "success": false,
  "error": "✘ [ERROR] Could not resolve \"./pages/MissingPage\"..."
}
```

### 編譯限制

| 限制 | 值 |
|------|-----|
| 最大檔案數 | 200 |
| 單檔大小 | 1 MB |
| 編譯超時 | 30 秒 |

### External 模組（由 Runtime 提供）

以下模組不需安裝，直接 import 即可：

```
react, react-dom, lucide-react, react-router-dom, react-hot-toast
```

---

## 7. 內建 SDK

### 資料中心自建表（`src/api.ts`）

操作**租戶級自建表**（由管理員在資料中心建立，全租戶共用；結構規格見 [第 13 章](#13-資料中心租戶級自建表)）：

```typescript
import { listTables, queryTable, insertRow, updateRow, deleteRow } from "../api";

// 列出本租戶所有自建表（含欄位定義）
const tables = await listTables();

// 查詢：回傳分頁信封 { items, total, page, page_size }
const page = await queryTable("orders", {
  filters: [{ field: "status", op: "eq", value: "open" }],  // op ∈ eq / contains / gte / lte
  sort: "-created_at",   // <實體名> 升冪、-<實體名> 降冪，單欄
  page: 1,
  page_size: 25,
});

await insertRow("orders", { customer_name: "王大明", amount: 1200 });
await updateRow("orders", rowId, { status: "closed" });
await deleteRow("orders", rowId);
```

- 表名與欄位名一律用**實體名**（physical name），不是顯示名——顯示名可被隨時改掉。
- SDK 依 `window.__IS_EXTERNAL__` 自動分流 `/data-center` 或 `/ext/data-center`，不需自己判斷。
- **SDK 沒有結構操作**：App 執行期無法建表或改欄，需要新表／新欄位請走第 13 章的管理員流程。

### DB Proxy（`src/db.ts`）

操作已授權的系統資料表：

```typescript
import { query, queryAdvanced, insert, update, remove } from "../db";

const customers = await query("customers", { limit: 50 });
const result = await queryAdvanced("customers", {
  filters: [{ column: "status", op: "eq", value: "active" }],
  order_by: [{ column: "name", direction: "asc" }],
});
```

#### `db.update()` PATCH 格式注意事項

後端 Proxy API 的 PATCH 端點要求 payload 必須以 `{"data": {...}}` 包裝，但目前 `db.ts` SDK 的 `update()` 函式直接發送欄位物件，會觸發 **「無有效欄位資料」** 錯誤。

**臨時解法**：在需要更新資料的場景中，改用直接 `fetch` 呼叫：

```typescript
// 錯誤: 目前 db.update() 會發送 {"state": "sent"} → 後端回傳 400
await db.update("sale_orders", orderId, { state: "sent" });

// 正確做法：直接 fetch 並以 {"data": {...}} 包裝
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const appId = (window as any).__APP_ID__ || '';
const token = (window as any).__APP_TOKEN__ || '';
const resp = await fetch(`${apiBase}/proxy/${appId}/sale_orders/${orderId}`, {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    ...(token ? { Authorization: `Bearer ${token}` } : {}),
  },
  credentials: 'include',
  body: JSON.stringify({ data: { state: "sent" } }),
});
```

> **備註**：`db.insert()` 同樣存在此格式不一致的問題。若 `insert()` 呼叫失敗，請套用相同的 `{"data": {...}}` 包裝模式。此問題預計在 SDK 下一版修正。

#### 處理簽核回傳 (Approval Workflow Intercepts)

當管理者在 AI GO 後台針對特定表（如 `sale_orders`）設定了「簽核流程」時，您的 `db.insert`, `db.update`, 或 `db.remove` 呼叫可能會被簽核引擎攔截：

- **Insert (Insert-then-flag)**：記錄會優先寫入資料庫（為了取得關聯 ID），但不會觸發正式的營運邏輯，並直接轉入 Pending 狀態等待簽核。
- **Update / Delete (Pre-guard)**：記錄不會被實際更新或刪除。系統會將您的 Payload 暫存於簽核單中，直到審核通過後才實際執行。

**被攔截時的回傳格式**：
當您的操作需要簽核時，API 會回傳帶有 `approval_status: "pending"` 的 Payload，前端開發者應攔截此狀態並給予使用者對應的提示，而不是單純顯示「操作成功」。

```typescript
// 範例：處理新增操作的簽核攔截
const result = await db.insert("sale_orders", { data: { amount_total: 5000 } });

if (result.approval_status === "pending") {
  // result.approval_message: "此操作需要簽核審批（2 層），已建立申請"
  toast.success(result.approval_message || "已送出簽核申請");
} else {
  toast.success("訂單新增成功");
}
```

#### 簽核通過/退回後的自動狀態變更 (Approval State Callbacks)

為了避免 Custom App 開發者還需要寫 Python 後端程式碼來處理簽核狀態，AI GO 提供了 **全域動態狀態回調 (Generic State Callbacks)**。

對於 `db.insert` 和 `db.update` 操作，您可以在建立 `ApprovalWorkflow` 時，配置以下三個欄位，核心引擎會在主管核准或退回時，自動更新該紀錄的狀態：

1. **`approved_state_field`**：要更新的狀態欄位名稱 (例如：`"state"`, `"status"`, `"doc_status"`)。
2. **`approved_state_value`**：核准後要寫入的值 (例如：`"approved"`, `"validate"`, `"done"`)。
3. **`rejected_state_value`**：退回後要寫入的值 (例如：`"rejected"`, `"draft"`)。

**運作原理**：
* 當前端送出 `insert` 操作時，紀錄會直接以 `draft` 或 `pending` 狀態存入資料庫，並產生一張待簽核單。
* 當最後一位主管點擊 **核准** 時，核心引擎會自動執行：`UPDATE "您的資料表" SET "{approved_state_field}" = '{approved_state_value}' WHERE id = ?`。
* 若主管點擊 **退回**，則會自動執行：`UPDATE "您的資料表" SET "{approved_state_field}" = '{rejected_state_value}' WHERE id = ?`。

有了這個機制，您的 Custom App 只要寫好前端介面與查詢條件（例如只撈取 `state === 'approved'` 的單據），即可完成端到端的無代碼簽核閉環！

> **延伸閱讀**：若您要在 App 內做**待簽核清單、核准／駁回按鈕、簽核進度顯示**，請參閱 [第 23 章 簽核工作流](#23-簽核工作流-approval-workflow)。

### Approval（`src/approval.ts`）

操作平台簽核系統（**僅 Internal App**）。所有函式都以「目前登入使用者」的身分執行：

```typescript
import { myPending, recordStatus, approve, reject, cancel } from "../approval";

const items = await myPending();                          // 待我簽核清單
const status = await recordStatus("sale_orders", orderId); // 該筆單據的簽核狀態（無則 null）
await approve(items[0].request_line_id, "同意");
await reject(items[0].request_line_id, "金額有誤");
await cancel(requestId, "改單重送");                       // 僅申請人本人
```

完整欄位、權限規則與 Server Action 端的 `ctx.approval` 請見 [第 23 章](#23-簽核工作流-approval-workflow)。

### User Context（`src/user.ts`）

讀取「目前登入使用者」的**角色與權限唯讀快照**，用來做角色條件顯示——與主站權限系統同一份來源，**不需要**（也不應該）自己 `fetch('/api/v1/auth/me')`。

資料由 Runtime 頁面在載入時注入（`window.__USER_ROLES__` / `window.__USER_PERMISSIONS__`）。**僅 Internal App 會被注入**；External App 與匿名檢視下所有查詢皆回空陣列（不洩漏平台權限結構）。

| 函式 | 說明 |
|------|------|
| `getCurrentUser()` | `{ roles: string[], permissions: string[] }` 唯讀複本 |
| `getRoles()` | 角色名稱清單（**顯示用**） |
| `getPermissions()` | 權限標籤清單，如 `['sale.write', 'crm.read']` |
| `hasPermission(perm)` | 是否具備某權限（`system.admin` 自動通過） |
| `hasAnyPermission(...perms)` | 任一具備 |
| `hasAllPermissions(...perms)` | 全部具備 |
| `isAdmin()` | 是否擁有 `system.admin` |

```typescript
import {
  getCurrentUser, getRoles, getPermissions,
  hasPermission, hasAnyPermission, hasAllPermissions, isAdmin,
} from "../user";

const me = getCurrentUser();          // { roles: [...], permissions: [...] }

if (hasPermission("sale.write")) {
  // 顯示「新增訂單」按鈕
}
if (hasAnyPermission("crm.write", "crm.delete")) { /* ... */ }
if (isAdmin()) { /* 擁有 system.admin，全開 */ }
```

> **判斷授權一律用 permission 標籤（`模組.動作`），不要用角色名稱。** 角色可被租戶任意改名（「業務」改成「Sales」您的判斷就失效），permission 標籤才穩定；`system.admin` 為萬能鑰匙，會自動通過所有 `hasPermission` 檢查。此行為與主站 `usePermissions()` 一致。

> **前端顯示／隱藏只是 UX，不是安全邊界。** 前端隱藏可被繞過。若不同角色可見的**資料**有機敏差異，必須在後端 Server Action 讀 `ctx.user_permissions` 分流（見第 8 章），或由 Data Reference 授權把關——後端才是強制點。

### Server Action（`src/action.ts`）

Action 的回傳值已經由 SDK 優化解構，`data` 將直接取得您在 Python 中回傳的 JSON payload。

```typescript
import { runAction, downloadFile } from "../action";

const { data, file } = await runAction("my_action", { key: "value" });
console.log("Action 結果:", data);
if (file) downloadFile(file);
```

---

## 8. Server-Side Actions

### 程式碼格式

```python
def execute(ctx):
    """必須定義 execute(ctx) 函式"""
    data = ctx.params.get("key", "default")
    customers = ctx.db.query("customers", limit=10)
    ctx.response.json({"result": customers})
```

### ctx 物件

| 方法 | 說明 |
|------|------|
| `ctx.params` | 前端傳入的參數 |
| `ctx.app_id` / `ctx.tenant_id` | 執行中的 App 與租戶 UUID（字串） |
| `ctx.action_name` | 當前執行的 Action 名稱 |
| `ctx.env` | 執行環境（`online` / `dev`） |
| `ctx.user_id` | 觸發者的使用者 UUID（字串） |
| `ctx.user_roles` | 觸發者的角色名稱清單（`list[str]`，唯讀快照；排程等無使用者上下文的觸發為空。**顯示用**） |
| `ctx.user_permissions` | 觸發者的權限標籤清單（`list[str]`，如 `['sale.write']`）。**角色條件邏輯的後端強制點**——前端 `src/user.ts` 的隱藏只是 UX |
| `ctx.db.query(table, **kwargs)` | 查詢資料。支援進階參數如 `order_by`, `search`, `limit`，範例：<br>`ctx.db.query("clients", limit=50, order_by=[{"column": "id", "direction": "desc"}])` |
| `ctx.db.insert(table, data)` | 新增記錄。對 ERP 表寫入時受簽核管制（詳見第 23 章） |
| `ctx.db.list_tables()` | 列出本租戶的自建表（含欄位定義） |
| `ctx.db.query_table(table, options)` | 查詢自建表記錄，回傳分頁信封（`filters` / `sort` / `page` / `page_size`） |
| `ctx.db.insert_row(table, data)` | 新增自建表記錄（收**扁平 dict**，不要包 `{"data": ...}`） |
| `ctx.db.update_row(table, row_id, data)` | 更新自建表記錄 |
| `ctx.db.delete_row(table, row_id)` | 刪除自建表記錄 |
| `ctx.approval.list_pending()` | 取得「觸發此 action 的使用者」的待簽核清單 |
| `ctx.approval.get_record_status(res_model, res_id)` | 查某筆記錄的簽核狀態（含各層明細） |
| `ctx.approval.approve(request_line_id, comment=None)` | 核准一層簽核（需為該層合格審核人） |
| `ctx.approval.reject(request_line_id, comment=None)` | 駁回簽核（需為該層合格審核人） |
| `ctx.approval.cancel(request_id, reason=None)` | 取消簽核申請（僅申請人本人） |
| `ctx.erp.*` | 觸發 ERP 業務引擎的正向確認類方法（`confirm_sale_order`、`create_invoice`、`validate_picking` 等，見下） |
| `ctx.knowledge.search(query, top_k=5, score_threshold=None)` | 檢索企業知識中心，回傳 chunk 級結果＋相關度分數（**純檢索，不經 LLM**，詳見第 24 章） |
| `ctx.knowledge.get_content(file_id)` | 取單一知識檔案的完整解析文字 |
| `ctx.http.call(service, path, method="GET", body=None, headers=None, params=None)` | 透過**已授權的 egress service** 呼叫外部 API。**第三個位置參數是 `method`**，不是 body；**不拋例外**，須檢查 `status`（詳見第 25 章） |
| `ctx.http.fetch(url, ...)` | 抓取任意公網 URL（SSRF 防護仍全數保留） |
| `ctx.messaging.list_channels()` / `add_channel()` / `update_channel()` / `remove_channel()` | 通訊渠道管理 |
| `ctx.messaging.inject_inbound()` / `save_ai_outbound()` / `get_ai_config()` | 通訊中心訊息讀寫 |
| `ctx.mcp.execute(task, wait=True)` / `trigger(task)` / `get_status(task_id)` | MCP 任務執行（同步／非同步／查狀態） |
| `ctx.secrets.get(key)` / `list_keys()` | 取得金鑰值／列出可用金鑰名稱 |
| `ctx.crypto.hash(alg, data)` | 雜湊計算 |
| `ctx.crypto.hmac_sign(key_name, data)` | HMAC 簽章（傳**金鑰名稱**，非金鑰值） |
| `ctx.crypto.base64_encode(data)` / `base64_decode(data)` | Base64 編解碼 |
| `ctx.crypto.aes_encrypt(key_name, plaintext)` / `aes_decrypt(key_name, ct, iv)` | AES-256 加解密 |
| `ctx.response.json(data)` | JSON 回應 |
| `ctx.response.file(content, filename, mime)` | 檔案下載回應 |
| `ctx.csv.export(rows, columns=None, filename=None)` | CSV 匯出 |

**`ctx.erp.*` 方法清單**

| 方法 | 觸發什麼 |
|------|---------|
| `confirm_sale_order(id)` / `confirm_purchase_order(id)` | 確認訂單（連動後續開票與出貨準備） |
| `create_invoice(order_id)` | 從銷售訂單開立銷項發票。**`order_id` 可傳清單**——多張訂單彙總進同一張發票（需同客戶、同租戶） |
| `create_bill(order_id)` | 從採購訂單建立進項帳單，只針對「已收貨未開帳」差額；同樣支援清單合併 |
| `validate_picking(id)` | 驗證揀貨單（連動庫存移動與存貨即時計價） |
| `post_move(id)` | 過帳會計傳票 |
| `confirm_payment(id)` | 確認收付款（連動 FIFO 自動沖銷） |
| `reconcile_payment(id)` | 補跑已過帳付款的沖銷引擎 |
| `confirm_payroll_run(id)` / `cancel_payroll_run(id)` | 確認／撤銷薪資結算（撤銷自動產生沖銷分錄）。回傳 dict，含產生的 move 資訊 |

> **延伸閱讀**：`ctx.approval.*` 的身分規則、scope 授權與例外處理，詳見 [第 23 章 簽核工作流](#23-簽核工作流-approval-workflow)。
> **延伸閱讀**：`ctx.db` **不提供結構操作**——Action 執行期無法建表或改欄，這是刻意的能力邊界（見 [第 13 章](#13-資料中心租戶級自建表)）。
> **延伸閱讀**：每個 `ctx` 模組方法都對應一個 **scope**，需經租戶管理者核可才生效；高風險 scope 另需擁有者密碼 step-up。詳見 [第 26 章](#26-scope-授權模型)。

### 依權限分流（後端強制點）

前端 `src/user.ts` 的顯示／隱藏只是 UX；**機敏資料的分流必須在 action 內用 `ctx.user_permissions` 判斷**：

```python
def execute(ctx):
    perms = ctx.user_permissions or []
    is_admin = "system.admin" in perms

    rows = ctx.db.query("sale_orders", limit=100)
    if not (is_admin or "hr.read" in perms):
        # 無權限者不回傳成本／薪資等機敏欄位
        rows = [{k: v for k, v in r.items() if k not in ("cost", "margin")} for r in rows]

    ctx.response.json({"rows": rows})
```

判斷用 **permission 標籤**（`模組.動作`）而非 `ctx.user_roles` 的角色名稱——角色可被租戶改名。排程等無使用者上下文的觸發，兩者皆為空清單，請自行決定要放行還是拒絕。

### 執行隔離與資源限制

> **舊版文件曾描述的「語言級沙箱」已不存在**：import 白名單、禁用 `exec` / `eval` / `open`、builtins 限制、禁 `class` 語法等規則**全部已移除**，一律以本節為準。

隔離改由語言層之外的三層縱深負責：

1. **專屬 runner pod**：每個 App 的 Action 在自己的 runner pod 內執行（單一併發），pod 就是隔離邊界，租戶之間以獨立節點排程分隔。
2. **網路政策**：pod 預設拒絕所有進出流量，僅放行 DNS、對平台後端的回呼，以及對外 443（叢集內私有網段除外）。
3. **`ctx` RPC 白名單**：存取平台資料與能力只能透過 `ctx` 模組方法，每個呼叫都經後端白名單驗證，白名單外一律 403。pod 內雖可寫任意 Python，能碰到的平台面仍受此限。

**程式碼層現況**

- 可 import runner 映像檔內已安裝的任何模組，可自由使用 `class` 與完整 builtins。
- 發布前的靜態檢查只有**結構防呆**：語法可解析 ＋ 必須定義 `execute(ctx)`。未定義名稱等錯誤要到執行期才浮現——**請務必先試跑再發布**。
- `print()` 會被攔截收集為執行 log 隨結果回傳，不會輸出到 stdout。

**資源限制**

| 限制 | 值 |
|------|-----|
| 執行逾時 | 30 秒（manifest `timeout_ms` 可自訂，上限 30 秒）；逾時後有 3 秒緩衝軟終止 |
| 記憶體 | 256 MB，屬**建議值**（超過僅記錄警告，不強制終止） |

---

## 9. 驗證與發布

### 標準開發迴圈

```
1. PATCH 修改檔案
2. POST 編譯（dev=true）
3. 編譯失敗 → 修改 → 回到 1
4. 編譯成功 → 預覽驗證
5. POST 發布
```

### 發布 API

```http
POST /api/v1/builder/apps/{app_id}/publish
Authorization: Bearer {JWT}
Content-Type: application/json

{ "published_assets": {} }
```

---

## 10. 常見問題

| 問題 | 解法 |
|------|------|
| 白屏 | 確認 `src/main.tsx` 正確掛載 React |
| 路由不動 | 使用 `HashRouter`，不可用 `BrowserRouter` |
| 頁面無法捲動 | Shadow DOM 容器需設定 `height: 100vh; overflow-y: auto`（詳見第 11 章） |
| CSS 不生效 | 在 `main.tsx` 中 `import "./App.css"` |
| CSS 變數全部遺失（部署後） | 使用 `:root` 定義變數，Shadow DOM 無法穿透。改用 `:host, :root`（詳見第 11 章） |
| `db.update()` 回傳「無有效欄位資料」 | SDK 的 `update()` 未以 `{"data": {...}}` 包裝 payload（詳見第 7 章 DB Proxy 注意事項） |
| `db.ts` 呼叫回傳 500 | 此為平台後端問題而非前端 Bug。請確認：1) Data Reference 是否已建立並發布 2) 引用的表名是否正確 3) 回報平台管理員檢查後端日誌 |
| `db.ts` / `api.ts` 回傳 401 | Token 可能過期或 SDK 變數未正確注入。確認 SDK 未被手動修改，且 Runtime 已正確啟動 |
| 409 Conflict | VFS 被同時修改，重新 GET 後合併重試 |
| 423 Locked | 有待審核的發布申請，等待或取消 |
| Action 超時 | 優化邏輯，控制在 30 秒內 |
| pub/ API 回傳 403 | 確認 `allow_anonymous_access=true` 且資料表 `is_public_readable=true`（詳見第 18 章） |
| pub/ API 回傳 429 | 超過匿名 Rate Limit（120/min per IP），稍後重試 |
| 寫入回傳 `approval_status: "pending"` | 操作被簽核流程攔截，不是失敗也不是成功（詳見第 23 章） |
| Action 拋「需要簽核審批」例外 | `ctx.erp` / `ctx.db` 的簽核 pre-guard，簽核通過後平台會自動執行，勿重試（詳見第 23 章） |

---

## 11. Shadow DOM 與 CSS 樣式規範

> **重要**：Custom App 在 AI GO Runtime 中是以 **Shadow DOM** 封裝執行的，與獨立 HTML 頁面有本質性差異。未遵守此規範將導致「本地開發正常，部署後樣式全部消失」的問題。

### 根本原因

CSS `:root` 選擇器匹配的是文件樹的根元素 `<html>`。當 App 在 Shadow DOM 內執行時，`:root` **無法穿透** Shadow 邊界，所有透過 `:root { --color: blue; }` 定義的 CSS 變數在 App 內完全讀取不到。

```
主站 HTML（<html> = :root 生效範圍）
  └── <custom-app-runtime>       ← Web Component
      └── #shadow-root (closed)  ← Shadow DOM 邊界
          └── <div id="root">    ← :root 不可觸及此區域
```

### 強制規則

所有 CSS 變數必須使用 `:host, :root` 雙選擇器：

```css
/* 正確：Shadow DOM 和獨立頁面皆可運作 */
:host, :root {
  --primary: #2563eb;
  --background: #fafbfc;
}

/* 錯誤：部署後變數全部遺失 */
:root {
  --primary: #2563eb;
}
```

同樣適用於 HTML 元素重設：

```css
/* 正確 */
html, :host {
  line-height: 1.5;
  font-family: 'Inter', system-ui, sans-serif;
}

/* 錯誤 */
html {
  line-height: 1.5;
}
```

### 選擇器對照表

| 選擇器 | 獨立 HTML 頁面 | Shadow DOM（AI GO Runtime）|
|--------|:---:|:---:|
| `:root` | 是 | 無法穿透 |
| `:host` | 無意義 | 命中 Shadow Host |
| `:host, :root` | fallback | 命中 |

### 自查 Checklist

- [ ] 全文搜尋 `:root {`（不含 `:host`）— 改為 `:host, :root {`
- [ ] 全文搜尋 `html {`（不含 `:host`）— 改為 `html, :host {`
- [ ] Dark Mode 的 `@media` 區塊同樣使用 `:host, :root`

### JavaScript API 限制

部分瀏覽器原生 API 在 Shadow DOM 中會被靜默阻擋（不拋錯、不顯示），導致「preview 正常、部署後無反應」：

| API | Shadow DOM 行為 | 替代方案 |
|-----|----------------|----------|
| `confirm()` | 靜默回傳 `false` | React `useState` 二階段確認 |
| `alert()` | 不顯示 | `react-hot-toast` 或自訂 Toast |
| `prompt()` | 回傳 `null` | React 自訂 input modal |

```tsx
// 正確：React state 確認
const [showConfirm, setShowConfirm] = useState(false);

// 錯誤: 錯誤：confirm() 在 Runtime 中永遠回傳 false
if (!confirm("確定嗎？")) return;
```

可正常使用的 API：`localStorage`、`fetch`、`window.location.reload()`。

### 容器滾動限制

Shadow DOM 的根容器預設不具備捲動能力。當 App 內容超出視窗高度時，使用者無法向下滾動。

**必須在最外層 Layout 元件設定明確的高度與溢出行為：**

```tsx
// 正確：明確設定高度與滾動
export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <div style={{
      height: "100vh",
      overflowY: "auto",
      backgroundColor: "var(--color-gray-50)",
    }}>
      {children}
    </div>
  );
}

// 錯誤: 錯誤：minHeight 不會觸發 overflow
<div style={{ minHeight: "100vh" }}>
```

### 單頁 App 路由簡化

若 Custom App 只有一個主頁面（例如訂單看板、儀表板），**不需要使用 React Router**。直接在 `App.tsx` 渲染主元件即可，避免 Router 上下文缺失導致白屏：

```tsx
// 單頁 App — 直接渲染，無需 Router
import OrderBoardPage from "./pages/OrderBoardPage";

export default function App() {
  return (
    <AppLayout>
      <Toaster position="top-center" />
      <OrderBoardPage />
    </AppLayout>
  );
}

// 錯誤: 單頁 App 卻使用 BrowserRouter — 在 Shadow DOM 中會白屏
import { BrowserRouter, Routes, Route } from "react-router-dom";
// BrowserRouter 無法控制 Runtime 的 URL，路由永遠匹配不到
```

> **何時需要 Router**：只有在 App 有多個頁面（搭配 Sidebar 導航切換）時才需要 `HashRouter`。

### 平台標識橫條（2026-09 起，各租戶陸續生效）

平台標識已改為頂部滿版橫條：「第三方應用程式｜由 AI GO 平台代管，非官方頁面」。行為如下：

- 只在**每位使用者首次造訪**時顯示，**5 秒後自動消失**。
- 不吃點擊（`pointer-events: none`），不會擋到 App 的互動元素。
- App **不需要**為它避讓版面；但也**不要**嘗試用 CSS／DOM 移除它——它有防竄改自癒機制，移除會被自動還原。

---

## 12. VFS 注入腳本開發規範

> **注意**：使用 Python 腳本直接組合 React JSX 原始碼時，極易因字串操作引入語法錯誤，導致 esbuild 編譯失敗。

### 字串操作風險

```python
# 危險：用 str.replace() 修改 JSX
text = text.replace(
    "return (\n    <main>",
    "return (\n  return (\n    <main>"  # 意外重複 return
)
# esbuild 報錯：Unexpected "return"
```

### 推薦做法

將每個 VFS 檔案以完整 raw string 定義，不做字串拼接或取代：

```python
# 正確：完整定義，不做字串操作
files["src/pages/CartPage.tsx"] = r'''import React from "react";

export default function CartPage() {
  return (
    <main className="container">
      <h1>購物車</h1>
    </main>
  );
}
'''
```

### 編譯防禦

部署腳本在呼叫 Compile API 後，**必須**檢查 `success` 欄位：

```python
result = r.json()
if not result.get("success"):
    print(f"編譯失敗:\n{result.get('error')}")
    sys.exit(1)  # 絕對不允許帶著編譯錯誤發布
```

### VFS 版本鎖

讀取 App 詳情時必須透過 `GET /builder/apps/{id}`（單一物件端點）取得精確的 `vfs_version`，而非使用列表端點。

### 路徑正規化（2026-09 起，各租戶陸續生效）

VFS 寫入時伺服器端會正規化檔案路徑：

- **非法路徑直接 400「無效的檔案路徑」**：含反斜線 `\`、以 `/` 開頭、或含 `..` 段的路徑一律拒收。
- **大小寫與副檔名自動折疊**：`Actions/` → `actions/`、`_Shared/` → `_shared/`、`.PY` → `.py`。
- **等價路徑視為同一檔**：`actions/./foo.py` 與 `actions/foo.py` 是同一個檔案，不會產生重複檔。

---

## 13. 資料中心：租戶級自建表

> **適用場景**：App 需要平台沒有的業務實體（例如「案件」「排班」「問卷」）。這類資料放在**資料中心自建表**——租戶級的真實資料表，不是 App 私有的動態結構。

### 13.1 核心語義：綁租戶，不綁 App

自建表綁 **tenant**。同一租戶下所有 Custom App 與資料中心 UI 看到**同一批表、同一份資料**。

**因此建表前必須先盤點。** 兩個 App 各建一張「客戶」表 = 資料分裂成兩份，事後難以合併。

```
1. GET /api/v1/data-center/tables      ← 盤點租戶既有自建表（不可跳過）
2. 有語意相同的表？ → 重用，不要新建
3. 需要新表 → 產出建表規格請租戶管理員確認
4. POST /api/v1/data-center/tables
   ├─ 201 → GET 驗收，繼續開發
   └─ 403 → 帳號缺 datacenter.schema_write（也非 system.admin）：不重試、不繞路，
            輸出可照抄的建表規格，請有權限者到資料中心 UI 建立，
            建好後 GET /tables 驗收再繼續
```

### 13.2 權限：結構與資料分開管

結構權限已拆成兩段（2026-09 起，各租戶陸續生效）：

| 操作 | 需要權限 |
|------|---------|
| 建表／改表／加欄／改欄 | **`datacenter.schema_write`**（`system.admin` 直通） |
| 刪表／刪欄 | **`system.admin`**（租戶管理員） |
| 讀結構（列表／讀 schema） | `builder.access` |
| 記錄 CRUD（查／增／改／刪） | `builder.access` |

> **預設角色未含 `datacenter.schema_write`**——需要租戶擁有者到角色管理 UI 為對應角色勾選後才生效。拿到 403 時先確認這一步。

這是平台刻意收窄的治理界線：**管制的是 schema 的形狀，不是它的使用**。App 執行期（`src/api.ts`、`ctx.db`）**沒有結構操作**——不能建表也不能改欄，這是刻意的能力邊界。

### 13.3 雙軌命名：顯示名 vs 實體名

| | 顯示名（display name） | 實體名（physical name） |
|---|---|---|
| 誰決定 | 你提供 | 系統從顯示名生成 |
| 可否修改 | 可以，隨時 | **建立後永不可變** |
| 字元集 | 任意（中文常見） | 純 ASCII |
| 用途 | UI 呈現 | API 識別、**刪除確認值** |

**所有 API 在指涉既有表／欄位時一律用實體名。** 改顯示名不影響任何既有引用或程式碼。

> 唯一例外：`relation` 欄位指向自建表時用的是 `target_table_id`（目標表的 **UUID**，從 `GET /tables` 回應的 `id` 取），不是實體名。

**實體名保留名單**（2026-09 起，各租戶陸續生效）：保留名單已擴大到平台地板表名（`users`、`tenants`、`audit_logs`、`api_keys` 等）。建表時若生成的實體名撞上保留名，回 **409「與平台保留表名衝突」**——取顯示名時請避開這類通用系統詞（例如用「專案成員」而非「使用者」）。

### 13.4 欄位型別

| 型別 | 說明 | 額外契約 |
|---|---|---|
| `text` | 文字 | |
| `number` | 數值 | |
| `boolean` | 布林 | |
| `date` | 日期 | |
| `datetime` | 日期時間 | |
| `select` | 單選 | 必須提供選項集；值受 CHECK 約束 |
| `relation` | 關聯 | 見下 |
| `json` | 結構化資料 | |
| `image` | 圖片 | 存 storage key，見 13.7 |

系統欄位 `id` / `created_at` / `updated_at` 自動帶，不可刪、不可改型別、**不計入欄位配額**。

**`relation` 的兩種目標（恰擇其一）**

- **→ 自建表**：`target_table_id` ＝ 目標表 **UUID**。建**真正的資料庫外鍵**，刪除仍被引用的列會被擋（409，`detail.dependents` 列出依賴者）。
- **→ ERP 表**：`target_erp_key` ＝ ERP 表 key。軟關聯，**不建外鍵**（跨 schema 邊界），寫入時驗證目標存在。

兩者恰擇其一——都給或都不給皆為錯誤；建立後不可變（改欄不收這兩個參數）。

### 13.5 配額

| | 免費檔 | 付費檔 |
|---|---|---|
| 每租戶表數 | **20** | **200** |
| 每表非系統欄位數 | **50** | **100** |

- 超限回 **409**（不是靜默截斷）。
- **單次 `POST /tables` 最多帶 50 個欄位**（schema 層上限），與每表欄位配額是兩回事：付費租戶想一次建 60 欄會拿到 **422 而不是 409**，必須先建表再逐次 `POST /tables/{key}/fields`。
- ERP 延伸欄位沿用同一組欄數配額。

### 13.6 刪除是兩段式的

刪表與刪欄**不可逆**，伺服器端強制兩段：

1. **影響預覽**：`GET /tables/{key}/impact` 或 `GET /tables/{key}/fields/{field_key}/impact` → 回傳記錄數、各欄位非空值統計、是否有其他表以關聯依賴它
2. **確認執行**：確認值走 query 參數 `confirm`，**必須等於實體名**（不可用顯示名——顯示名可改，拿它當確認值等於沒確認）

```http
DELETE /api/v1/data-center/tables/{key}?confirm={表實體名}
DELETE /api/v1/data-center/tables/{key}/fields/{field_key}?confirm={欄位實體名}
```

### 13.7 圖片欄位

`image` 欄位存的是 **storage key，不是 URL**。URL 只有一小時有效期，存進欄位會過期。

| 動作 | 端點 | 契約 |
|---|---|---|
| 上傳 | `POST /api/v1/data-center/tables/{key}/images` | 回傳 storage key 與一張可直接顯示的 URL；**把 key 存進欄位** |
| 取 URL | `GET /api/v1/data-center/images/url` | 帶 key，回短效期簽章 URL；**每次顯示時重取** |

允許 PNG／JPEG／GIF／WebP，單檔上限 **10 MB**；**SVG 被刻意排除**（可內嵌 script）。圖片會出現在檔案總管的「資料中心圖片」資料夾，使用者可自行刪除——刪掉後欄位顯示「圖片已移除」，這是已知取捨。

### 13.8 API 速查（主站，登入使用者身分）

| 操作 | 方法 | 端點 | 權限 |
|---|---|---|---|
| 列表 | GET | `/api/v1/data-center/tables` | `builder.access` |
| 讀單表 | GET | `/api/v1/data-center/tables/{key}` | `builder.access` |
| 建表 | POST | `/api/v1/data-center/tables` | `datacenter.schema_write` |
| 改表（顯示名等） | PATCH | `/api/v1/data-center/tables/{key}` | `datacenter.schema_write` |
| 刪表影響 | GET | `/api/v1/data-center/tables/{key}/impact` | `builder.access` |
| 刪表 | DELETE | `/api/v1/data-center/tables/{key}` | `system.admin` |
| 加欄 | POST | `/api/v1/data-center/tables/{key}/fields` | `datacenter.schema_write` |
| 改欄 | PATCH | `/api/v1/data-center/tables/{key}/fields/{field_key}` | `datacenter.schema_write` |
| 刪欄影響 | GET | `/api/v1/data-center/tables/{key}/fields/{field_key}/impact` | `builder.access` |
| 刪欄 | DELETE | `/api/v1/data-center/tables/{key}/fields/{field_key}` | `system.admin` |
| 查記錄 | GET | `/api/v1/data-center/tables/{key}/records` | `builder.access` |
| 新增記錄 | POST | `/api/v1/data-center/tables/{key}/records` | `builder.access` |
| 更新記錄 | PATCH | `/api/v1/data-center/tables/{key}/records/{record_id}` | `builder.access` |
| 刪記錄 | DELETE | `/api/v1/data-center/tables/{key}/records/{record_id}` | `builder.access` |

**建表範例**

```http
POST /api/v1/data-center/tables
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "display_name": "案件",
  "fields": [
    { "display_name": "案件編號", "field_type": "text" },
    { "display_name": "狀態", "field_type": "select", "options": ["進行中", "已結案"] },
    { "display_name": "負責人", "field_type": "relation", "target_erp_key": "employees" }
  ]
}
```

### 13.9 四種存取入口

| 呼叫者 | 前綴 | 能做什麼 |
|--------|------|---------|
| 主站／Builder（登入使用者） | `/api/v1/data-center/` | 結構（`datacenter.schema_write`，刪除限 `system.admin`）＋ 讀結構與記錄 CRUD |
| Internal App 執行期 | 同上（前端 `src/api.ts` 自動處理） | 讀結構 ＋ 記錄 CRUD |
| External App 執行期 | `/api/v1/ext/data-center/` | 讀結構 ＋ 記錄 CRUD（**無結構操作**） |
| 第三方整合（API Key） | `/api/v1/open/data-center/` | 讀結構 ＋ 記錄 CRUD（範圍＝整租戶自建表） |
| 匿名檢視 | `/api/v1/pub/data-center/{slug}/` | **唯讀**，且僅限被標記為公開可讀的表（見第 18 章） |

App 內的用法（`src/api.ts` 與 `ctx.db`）見 [第 7 章 內建 SDK](#7-內建-sdk)；SDK 會依 `window.__IS_EXTERNAL__` 自動分流 `/data-center` 或 `/ext/data-center`，不需自己判斷。

### 13.10 租戶使用者目錄：`GET /api/v1/users`（2026-09 起，各租戶陸續生效）

自建表常需要「記錄某筆資料屬於哪位使用者」。平台提供租戶使用者目錄端點：

```http
GET /api/v1/users
Authorization: Bearer {JWT}
```

- **已登入即可呼叫**（不需額外權限）。
- 回應為**分頁信封**，每筆刻意只有三欄：`id`、`name`、`status`——**不含 email**（隱私設計）。

**自建表要記使用者的目前做法**：

1. 用 `text` 欄位存使用者 **UUID**（`relation` 欄位尚不能指向 `users`）。
2. 顯示時呼叫 `GET /api/v1/users` 把 UUID 解成名字。

## 14. 共享資料表隔離策略 (Data Domain Separation)

> **適用場景**：當多個 Custom App 共用相同的 SaaS 標準表（如 `product_templates`、`sale_orders`、`customers`），如何防止資料互相污染。

在發展微服務架構的 Custom App 時，我們常會讓多個 App 共用核心表以方便未來建立統一的營收或顧客報表。但同時，不同 App 的前端應只看到屬於自己的資料。為達成此目的，必須引入 **Data Domain** 隔離策略。

### 核心策略：`app_domain` 標籤

利用資料表內的 JSON 欄位（通常為 `custom_data`），在所有相關記錄中注入 `app_domain` 屬性。每個 Custom App 都有專屬的 Domain 標識符（例：餐飲 = `"food"`，空間租借 = `"space"`）。

> **只適用 SaaS 表這一軌。** 資料中心自建表**不需要也不應該**帶 `app_domain`——自建表沒有 `custom_data` 欄位，而且「跨 App 共用同一份資料」正是它的設計目的，用標籤把它切開是反模式（見 [第 13 章](#13-資料中心租戶級自建表)）。

### 實作步驟

1. **注入標籤（Insert）**：
   在 SDK `db.ts` 或 Server-Side Action 執行 `insert` 時，強制將 `custom_data.app_domain` 寫入。
   ```typescript
   await insert("sale_orders", {
     data: {
       name: `ORDER-${Date.now()}`,
       amount_total: 1000,
       custom_data: {
         app_domain: "space",  // ← 宣告資料所有權
         booking_date: "2024-05-01"
       }
     }
   });
   ```

2. **強制過濾（Query）**：
   在所有 `query` 動作中，無論是列表查詢或關聯查詢，都必須顯式加入 JSON 欄位的過濾條件。
   ```typescript
   const spaces = await query("product_templates", {
     filters: [{
       column: "custom_data",
       op: "ilike",
       value: "%space%"  // ← 過濾只屬於 app_domain="space" 的資料
     }],
     limit: 100
   });
   ```
   *注意：使用 `ilike` 或透過進階 JSONB 操作符是常見解法，未來平台將提供原生 JSON 結構過濾支援。*

3. **白名單隔離（AppDataReference）**：
   在建立 App 的 DB Proxy 授權 (`app_data_references` 表) 時，這**無法限制列級別 (Row-Level)** 的存取。因此**前端程式碼層級的防護是必須的**。只有正確實作過濾的前端，配合正確的 AppDataReference 欄位白名單，才能達成完整的資料隔離。

### 分表 vs 共表 的選擇

- **使用資料中心自建表**：適用於平台沒有對應實體的新業務資料（如：客戶滿意度問卷、排班表）。自建表是租戶級資源，建表前先盤點既有表避免資料分裂（見第 13 章）。
- **共用標準核心表 (Shared standard tables) + `app_domain`**：適用於可以共用底層基礎建設的資料，如商品(`product_templates`)、訂單(`sale_orders`)、顧客(`customers`)。這有助於後續在管理員後台建立跨部門統一財報。

---

## 15. 檔案上傳與儲存 (Storage API)

> **適用場景**：Custom App 需要讓使用者上傳圖片、文件、或其他檔案時，使用平台提供的 Storage API 進行統一管理。

### 概述

Custom App 可透過 `/api/v1/ext/storage/*` 端點進行檔案的上傳、下載、列出與刪除。所有檔案會自動存放在租戶與 App 的隔離路徑下，確保資料安全。

### 認證方式

所有 Storage API 都需要 **Custom App Token**（與 `ext/proxy` 相同的認證機制）。Token 在 Runtime 中可透過 `window.__APP_TOKEN__` 取得。

### API 端點

| 操作 | HTTP 方法 | 端點 |
|------|----------|------|
| 上傳檔案 | POST | `/api/v1/ext/storage/upload` |
| 取得檔案 URL | GET | `/api/v1/ext/storage/url?path={path}` |
| 刪除檔案 | DELETE | `/api/v1/ext/storage/file?path={path}` |
| 列出檔案 | GET | `/api/v1/ext/storage/list?folder={folder}` |

### 上傳檔案

```http
POST /api/v1/ext/storage/upload
Authorization: Bearer {custom_app_token}
Content-Type: multipart/form-data

file: (binary)
folder: "receipts"    # 可選，子資料夾名稱
```

**回應**：
```json
{
  "path": "tenant-id/app-id/receipts/invoice.pdf",
  "bucket": "files",
  "size": 102400,
  "mime_type": "application/pdf"
}
```

### 取得 Signed URL

```http
GET /api/v1/ext/storage/url?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
```

**回應**：
```json
{
  "url": "<一小時內有效的簽章下載網址>",
  "expires_in": 3600
}
```

### 列出檔案

```http
GET /api/v1/ext/storage/list?folder=receipts&limit=50&offset=0
Authorization: Bearer {custom_app_token}
```

**回應**：
```json
{
  "files": [
    { "name": "invoice.pdf", "size": 102400, "updated_at": "2026-04-07T12:00:00Z" }
  ],
  "count": 1
}
```

### 刪除檔案

```http
DELETE /api/v1/ext/storage/file?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
```

### 限制與安全

| 限制 | 值 |
|------|-----|
| 單檔大小上限 | 100 MB |
| 儲存 Bucket | `files`（共用，路徑隔離） |
| 路徑格式 | `{tenant_id}/{app_id}/{folder}/{filename}` |
| 跨 App 存取 | 403 Forbidden |

### 在前端使用

```typescript
// 上傳檔案
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const token = (window as any).__APP_TOKEN__ || '';

async function uploadFile(file: File, folder?: string) {
  const formData = new FormData();
  formData.append('file', file);
  if (folder) formData.append('folder', folder);

  const resp = await fetch(`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/upload`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: formData,
  });
  return resp.json();
}

// 取得 Signed URL
async function getFileUrl(path: string) {
  const resp = await fetch(
    `${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/url?path=${encodeURIComponent(path)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const data = await resp.json();
  return data.url;
}
```

> **注意**：上傳的檔案會計入租戶的儲存空間用量統計，管理員可在 Dashboard 的「用量管理 > 儲存空間」查看。

---

## 16. 開發與部署最佳實踐 (Best Practices)

為了確保 Custom App 的維護性與跨環境同步的穩定度，請在開發與持續整合 (CI/CD) 過程中遵循以下防呆與最佳實踐：

### 15.1 VFS 同步與完整性驗證 (Environment Sync)

當您建立腳手架腳本 (Scaffolding Scripts) 將本機原始碼推送到雲端 Custom App 端點時，極易發生「本機檔案沒跟上雲端」的脫鉤狀況：
- **正確區分環境變數**：若您利用 API 撰寫快速部署腳本，請確保 CLI 工具嚴格校驗或區隔 `--env local` 與 `--env cloud`。將程式推到錯誤的環境金鑰，將導致您在雲端 Dashboard 發生 `500 - 無法從 Storage 載入模板` 的中斷錯誤（因為雲端只讀到版號卻無實際檔案）。
- **原子性操作**：強烈建議使用 `PATCH /api/v1/builder/apps/{id}/source/files` 一次性更新所有相依的 VFS 檔案，且在指令結束前加入一次 `GET` 確認 `VFS 檔案數量` 是否一致。

### 15.2 防禦性編碼與禁用佔位符 (Prevent Lazy Code Replacement)

在維護龐大的 React 元件或複雜邏輯時，人類或 AI Agent 輔助編碼經常會使用 `# ...` 或 `// ... 原有程式碼` 等懶惰佔位符 (Lazy Loading)。在傳統專案中這可能無害，但在 **VFS 動態編譯架構** 中是致命的：
- **破壞編譯器上下文**：esbuild 編譯器在雲端處理您的 TSX 時，任何省略或殘缺的程式片段將直接導致 AST 解析失敗，進而阻斷整個 App 的渲染。
- **嚴格規範**：
  1. 無論更新多小的改動，**每次覆寫單一 VFS 檔案都必須提供 100% 完整的檔案原始碼**。
  2. 嚴禁在原始碼中殘留 `// 這裡省略前面的程式碼` 或 `// ...`。
  3. 利用 TypeScript 嚴格定義型別。一旦發生型別隱含錯誤，Runtime 沙箱的除錯成本將高於本地開發。

### 15.3 啟動先渲染 Skeleton／Loading 佔位（2026-09 起，各租戶陸續生效）

平台會在 App 掛載後監看 **8 秒**：若 Shadow root 內仍然全空，會自動回報 runtime error，並對使用者顯示「App 已載入但沒有顯示任何內容」的 banner。

- **正確做法**：App 啟動時**立刻**渲染 skeleton 或 loading 佔位，再去打 API 補資料。
- **會被誤報的寫法**：「先跑長 API、拿到資料才做首次渲染」——長 API 超過 8 秒時，即使 App 本身沒壞也會被判為無內容。

---

## 17. Internal App 獨立登入與成員邀請

Internal Custom App 提供獨立的登入/註冊頁面，讓組織成員無需進入主站 Dashboard 即可直接存取應用。管理員也可透過邀請連結，讓新成員一步完成註冊並進入應用。

### 17.1 獨立登入頁 URL

每個 Internal App 都有一個獨立的登入入口：

```
https://{tenant}.ai-go.app/app-login/{slug}
```

- `{slug}`：App 的 slug（可在 Builder 或 API 查詢）
- 此頁面 **不需登入** 即可載入，會自動顯示 App 名稱和租戶 Logo
- 若使用者已有 session，會自動檢查權限並跳轉至 Runtime

### 17.2 登入流程

```
使用者開啟 /app-login/{slug}
    ↓
頁面載入公開 App 資訊（名稱、Logo）
    ↓
使用者輸入帳號密碼 → 登入
    ↓
自動呼叫 check-access API 檢查權限
    ↓
├── 有權限 → 跳轉至 /runtime/{slug}
└── 無權限 → 顯示「無法存取此應用」
```

### 17.3 相關 API 端點

#### 公開 App 資訊（不需認證）

```http
GET /api/v1/builder/apps/public/{slug}
```

**回應**：
```json
{
  "name": "後廚訂單管理",
  "slug": "c7c7a37d2ff0",
  "subdomain": null,
  "tenant_logo_url": "https://..."
}
```

> 若 App 不存在或未發布，回傳 `404`。

#### 權限檢查（需認證）

```http
GET /api/v1/builder/apps/check-access/{slug}
Authorization: Bearer {JWT}
```

**回應**：
```json
{
  "has_access": true,
  "app_name": "後廚訂單管理",
  "reason": null
}
```

**權限檢查邏輯**：

| 檢查項目 | 失敗時回應 |
|---------|----------|
| App 是否存在且已發布 | `has_access: false, reason: "App 不存在或尚未發布"` |
| 使用者是否屬於同一組織 | `has_access: false, reason: "您的帳號不屬於此應用所屬的組織"` |
| 使用者角色是否在允許名單 | `has_access: false, reason: "您的角色不在此應用的允許名單內"` |

### 17.4 邀請成員 + 直接進入 App

管理員可建立邀請連結，讓新成員在 App 登入頁完成註冊，無需進入主站（`redirect_url` 支援為 2026-09 起，各租戶陸續生效）：

```http
POST /api/v1/members
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "email": "new-member@company.com",
  "name": "新成員",
  "role_ids": ["member"],
  "redirect_url": "/app-login/{slug}"
}
```

**回應**：
```json
{
  "token": "U1HjLnpLHgAM8hZE...",
  "chat_invite_link": "https://{tenant}.ai-go.app/app-login/{slug}?token=U1HjLnpLHgAM8hZE..."
}
```

> **關鍵**：當 `redirect_url` 以 `/app-login/` 開頭時，系統會自動產生 App 專用邀請連結，受邀者直接在 App 登入頁完成註冊。**不帶 `redirect_url` 時受邀者落到主站 Dashboard。**

**權限**：需要 `hr.member_manage`。只有 `builder.access` 的帳號會拿到 **403**。

**`redirect_url` 是 fail-closed 白名單**，不符合任一條規則即回 **422**：

| 規則 | 說明 |
|------|------|
| 前綴白名單 | 限 `/app-login/`、`/runtime/`、`/dashboard/` 等平台已知前綴 |
| 字元集 | 純 ASCII `[A-Za-z0-9/._~-]` |
| 禁用字元 | 不可含 `?` 或 `#` |
| 長度 | ≤ 256 字 |

**重寄邀請**：`resend-invite` 同樣支援 `redirect_url`；省略時沿用上一張邀請的落點。

### 17.5 受邀成員的註冊體驗

當受邀者點擊邀請連結（含 `?token=xxx`），登入頁面會自動：

1. **切換為註冊模式** — 標題顯示「註冊 {App名稱}」
2. **鎖定 Email 欄位** — 預填邀請的 Email，不可修改
3. **顯示邀請資訊** — 「由『{邀請者}』邀請您加入『{組織名}』」
4. 受邀者只需填入 **姓名** 和 **密碼** 即可完成註冊
5. 註冊成功後 **自動登入** 並跳轉至 App Runtime

### 17.6 錯誤處理

| 情境 | 頁面顯示 |
|------|----------|
| slug 不存在 | 「應用不存在」錯誤頁 |
| 邀請 token 無效或已過期 | 「邀請連結無效」+ 「前往登入」按鈕 |
| 帳號或密碼錯誤 | 表單內顯示「帳號或密碼錯誤」（不跳轉） |
| 登入成功但無權限 | 「無法存取此應用」+ 建議聯繫管理員 |

### 17.7 忘記密碼

登入頁內建「忘記密碼」功能，點擊後彈出 Dialog（不離開頁面），輸入 Email 後寄送密碼重設信。重設完成後使用者可直接在原頁面登入，不會遺失 App slug 或邀請 token。

### 17.8 登出

Internal App 共用**平台自建的帳號系統**（JWT，token 由後端自管、存於 localStorage）。登出時呼叫平台登出端點撤銷 refresh token，並清除本地 token：

```typescript
// 登出並導回 App 登入頁
async function handleLogout() {
  await fetch("/api/v1/auth/logout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${(window as any).__APP_TOKEN__}`,
    },
    body: JSON.stringify({ all_devices: false }),
  });
  window.location.href = `/app-login/${APP_SLUG}`;
}
```

> **說明**：`POST /api/v1/auth/logout` 在伺服器端撤銷 refresh token（帶 `refresh_token` 只撤該裝置；`all_devices: true` 撤銷所有裝置），access token 為無狀態、於效期內自然失效。登出後使用者可在 `/app-login/{slug}` 重新登入。

> **注意**：此登出會同時影響主站 Dashboard 的登入狀態（同一套平台帳號系統）。

### 17.9 AI Agent 整合範例

Agent 可透過 API 自動化邀請流程，讓新成員快速加入並使用 App：

```python
import httpx

BASE = "https://{tenant}.ai-go.app/api/v1"  # {tenant} 代入實際租戶

# 1. 管理員登入
resp = httpx.post(f"{BASE}/auth/login", json={
    "email": "admin@company.com",
    "password": "admin_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}

# 2. 建立邀請（連結直接導向 App 登入頁；需 hr.member_manage）
resp = httpx.post(f"{BASE}/members", headers=headers, json={
    "email": "new-user@company.com",
    "name": "新同事",
    "role_ids": ["member"],
    "redirect_url": "/app-login/c7c7a37d2ff0"
})
invite_link = resp.json()["chat_invite_link"]
print(f"請將此連結傳給新成員：{invite_link}")
# → https://{tenant}.ai-go.app/app-login/c7c7a37d2ff0?token=xxx
```

---

## 18. Public 匿名檢視功能

> **適用場景**：當 Custom App 需要提供不需登入即可瀏覽的公開頁面，例如產品目錄、場館介紹、價格方案展示等。此模式允許訪客匿名瀏覽已發布的 App 內容，同時仍支援登入後切換為完整功能。

### 18.1 概述

Public 匿名檢視是 Custom App 的第三種存取模式，補足了 Internal（內部應用）和 External（外部應用）之外的公開瀏覽需求：

- **Internal**：僅限組織成員登入後使用
- **External**：支援獨立帳號系統的對外應用
- **Public（匿名檢視）**：任何人無需登入即可瀏覽指定的公開資料

匿名模式下，訪客只能**讀取**標記為公開的資料，無法進行新增、修改或刪除操作。若需要寫入功能，訪客可透過 Custom App Auth 登入後自動切換為認證模式。

### 18.2 啟用條件

啟用匿名公開瀏覽需同時滿足三個層級的設定：

#### 層級一：App 設定

| 欄位 | 必要值 | 說明 |
|------|--------|------|
| `status` | `"published"` | App 必須已發布 |
| `allow_anonymous_access` | `true` | 啟用匿名存取 |
| `access_mode` | `"external"` 或 `"self_built"` | 僅限外部模式 |

透過 Builder API 設定：

```http
PATCH /api/v1/builder/apps/{app_id}
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "allow_anonymous_access": true
}
```

> 也可在 Builder UI → 發布面板 → 「允許匿名存取」開關中切換。

#### 層級二：資料中心自建表

每張自建表各自控制是否對匿名 API 開放（改表 meta，**需 `datacenter.schema_write`**，`system.admin` 直通）：

```http
PATCH /api/v1/data-center/tables/{key}
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "is_public_readable": true
}
```

> 也可在資料中心 UI → 該表設定中切換「公開讀取」。`{key}` 為表實體名。

#### 層級三：SaaS 引用資料表（AppDataReference）

若 App 引用了系統 SaaS 表（如 `customers`、`products` 等），需在 Reference 上設定：

```http
PATCH /api/v1/refs/{ref_id}
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "is_public_readable": true
}
```

> 在 Builder UI → 資料引用 → 每個引用右側的「公開讀取」開關。

### 18.3 Public API 端點

以下端點**不需要任何認證 Token**，透過 App 的 `slug` 識別目標應用。

#### 自建表匿名唯讀

| 端點 | 方法 | 說明 |
|------|------|------|
| `/api/v1/pub/data-center/{slug}/tables` | GET | 列出已開放匿名唯讀的自建表（含欄位定義） |
| `/api/v1/pub/data-center/{slug}/tables/{key}/records` | GET | 列出指定自建表的記錄（分頁信封） |

**範例：列出公開資料表**

```http
GET /api/v1/pub/data-center/aeb47f756cef/tables
```

**回應**：
```json
[
  {
    "id": "uuid",
    "display_name": "場館",
    "physical_name": "venues",
    "fields": [
      { "physical_name": "name", "display_name": "名稱", "field_type": "text" },
      { "physical_name": "address", "display_name": "地址", "field_type": "text" }
    ]
  }
]
```

**範例：查詢記錄**

```http
GET /api/v1/pub/data-center/aeb47f756cef/tables/venues/records?page=1&page_size=25
```

> `{key}` 是表**實體名**（如 `venues`）。匿名端點刻意只保留最小查詢面：**不接受 `sort` / `filters`**，`page_size` 上限 100；回傳的欄位限公開白名單（系統欄位 `id` / `created_at` 恆可回）。

#### SaaS 引用匿名唯讀

| 端點 | 方法 | 說明 |
|------|------|------|
| `/api/v1/pub/proxy/{slug}/{table}` | GET | 簡單查詢 |
| `/api/v1/pub/proxy/{slug}/{table}/query` | POST | 進階查詢（filters / search / sort） |

**範例：進階查詢**

```http
POST /api/v1/pub/proxy/aeb47f756cef/products/query
Content-Type: application/json

{
  "filters": [
    { "column": "status", "op": "eq", "value": "active" }
  ],
  "order_by": [{ "column": "name", "direction": "asc" }],
  "limit": 20,
  "offset": 0
}
```

> **注意**：pub/proxy 的 `limit` 上限為 **100**。超過時自動 cap 到 100。

### 18.4 前端 SDK 整合

Custom App 的 SDK（`src/api.ts`）已內建匿名模式自動切換邏輯。當 Runtime 偵測到使用者未登入時，SDK 會自動使用 `pub/` 端點。

#### 自動切換原理

```typescript
// api.ts 內部邏輯（由 Runtime 自動生成，不需手動修改）
export async function queryTable(key: string, options = {}): Promise<any> {
  const token = (window as any).__APP_TOKEN__ || '';

  if (!token) {
    // 未登入 → 走公開 API（不需 Token，僅唯讀）
    const res = await fetch(
      `${API_BASE}/pub/data-center/${APP_SLUG}/tables/${key}/records?page=1&page_size=25`
    );
    return res.json();
  }

  // 已登入 → 走標準認證 API
  const res = await fetch(`${API_BASE}/data-center/tables/${key}/records`, {
    headers: { Authorization: `Bearer ${token}` }
  });
  return res.json();
}
```

#### Runtime 全域變數

Runtime 會在 App 啟動時注入以下全域變數：

| 變數 | 說明 | 匿名模式值 | 登入後值 |
|------|------|-----------|---------|
| `window.__APP_TOKEN__` | JWT Access Token | `""` (空字串) | `"eyJ..."` |
| `window.__APP_SLUG__` | App 的 slug | 有值 | 有值 |
| `window.__APP_ID__` | App UUID | 有值 | 有值 |
| `window.__API_BASE__` | API 基底 URL | 有值 | 有值 |
| `window.__IS_AUTHENTICATED__` | 是否已認證 | `false` | `true` |

#### 在頁面中偵測登入狀態

```tsx
import React from "react";

export default function VenueListPage() {
  const isLoggedIn = !!(window as any).__APP_TOKEN__;

  return (
    <main>
      <h1>場館列表</h1>
      {/* 所有訪客都能看到場館資料 */}
      <VenueList />

      {/* 僅登入使用者顯示預約按鈕 */}
      {!isLoggedIn && (
        <p>
          想要預約場地？
          <a href="#/login">請先登入</a>
        </p>
      )}
    </main>
  );
}
```

### 18.5 混合模式：匿名 + 登入切換

Custom App 支援「匿名瀏覽 → 登入 → 完整功能」的流暢切換：

```
訪客開啟 App 頁面
    ↓
Runtime 偵測：無 Token
    ↓
注入 __APP_TOKEN__ = ""、__IS_AUTHENTICATED__ = false
    ↓
SDK 自動使用 pub/ API（唯讀）
    ↓
訪客點擊「登入」→ Custom App Auth 登入頁
    ↓
登入成功 → Auth SDK 更新 window.__APP_TOKEN__
    ↓
SDK 自動切換為認證版 API（完整 CRUD）
```

#### Auth SDK 自動注入

對於 `access_mode = "external"` 的 App，Runtime 會自動注入 Auth SDK，提供以下全域方法：

```typescript
// 這些方法在 window.__auth__ 物件上自動可用
window.__auth__.login(email, password)    // 登入
window.__auth__.register(email, password, displayName)  // 註冊
window.__auth__.logout()                 // 登出（清除 Token）
window.__auth__.getToken()               // 取得當前 Token
```

#### 登入頁範例

```tsx
import React, { useState } from "react";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleLogin = async () => {
    try {
      await (window as any).__auth__.login(email, password);
      // 登入成功 → Token 自動更新 → 跳轉首頁
      window.location.hash = "#/";
      window.location.reload();
    } catch (err: any) {
      setError(err.message || "登入失敗");
    }
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      {error && <p className="error">{error}</p>}
      <button type="submit">登入</button>
    </form>
  );
}
```

### 18.6 Rate Limiting

為保護匿名端點免受濫用，pub/ API 設有專屬的速率限制：

| 端點範圍 | 限額 | 計量基準 |
|---------|------|---------|
| `/api/v1/pub/data/*` + `/api/v1/pub/proxy/*` | **120 次 / 分鐘** | per IP |
| `/api/v1/custom-app-auth/*`（POST） | **10 次 / 分鐘** | per IP |
| 認證版 `/api/v1/data/*` + `/api/v1/proxy/*` | 600 次 / 分鐘 | per user |

> 匿名 Rate Limit 與認證版 Rate Limit **互不影響**。登入後的使用者享有 600/min 的配額。

**回應 Header**：

每個 pub/ API 回應都會包含：

```http
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
```

**超過限額時**：

```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{
  "detail": "請求過於頻繁，請稍後再試"
}
```

### 18.7 安全機制

| 防護項目 | 機制 |
|---------|------|
| **欄位白名單** | `filters`、`search_columns`、`select_columns` 都對照 `allowed_columns` 白名單驗證，禁止查詢未授權欄位 |
| **Limit 上限** | pub/proxy 的 `limit` 最大為 100，pub/data 由 FastAPI Query 驗證 |
| **唯讀** | pub/ 端點僅允許 GET 和 POST query，不允許 INSERT / UPDATE / DELETE |
| **SQL Injection** | 所有參數透過 SQLAlchemy 綁定，不拼接 SQL 字串 |
| **App 驗證** | 每次請求驗證 `slug` 對應的 App 是否存在、已發布、且允許匿名存取 |
| **計費記錄** | 每次 pub/ API 呼叫記錄到 `usage_events`，管理員可監控用量 |

### 18.8 Builder API 自動化設定

AI Agent 或外部腳本可透過以下流程一次性啟用 Public 模式：

```python
import httpx

BASE = "https://{tenant}.ai-go.app/api/v1"  # {tenant} 代入實際租戶

# 1. 管理員登入
resp = httpx.post(f"{BASE}/auth/login", json={
    "email": "admin@company.com",
    "password": "admin_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}

APP_ID = "your-app-uuid"

# 2. 啟用匿名存取
resp = httpx.patch(f"{BASE}/builder/apps/{APP_ID}", headers=headers, json={
    "allow_anonymous_access": True
})
print(f"匿名存取：{resp.json().get('allow_anonymous_access')}")

# 3. 設定自建表為公開可讀（需 datacenter.schema_write，system.admin 直通）
resp = httpx.get(f"{BASE}/data-center/tables", headers=headers)
for table in resp.json():
    if table["key"] in ("venues", "products", "prices"):
        httpx.patch(f"{BASE}/data-center/tables/{table['key']}", headers=headers, json={
            "is_public_readable": True
        })
        print(f"  ✓ {table['key']} → public")

# 4. 設定 Reference 為公開可讀
resp = httpx.get(f"{BASE}/refs/apps/{APP_ID}", headers=headers)
for ref in resp.json():
    if ref["table_name"] in ("crm_tags",):
        httpx.patch(f"{BASE}/refs/{ref['id']}", headers=headers, json={
            "is_public_readable": True
        })
        print(f"  ✓ ref {ref['table_name']} → public")

# 5. 發布
resp = httpx.post(f"{BASE}/builder/apps/{APP_ID}/publish", headers=headers, json={
    "published_assets": {}
})
print(f"發布結果：{resp.status_code}")

# 6. 驗證匿名存取
slug = "your-app-slug"
resp = httpx.get(f"{BASE}/pub/data/{slug}/objects")
print(f"匿名存取測試：{resp.status_code} → {len(resp.json())} 個公開表")
```

### 18.9 常見問題

| 問題 | 解法 |
|------|------|
| pub/ API 回傳 404 | 確認 App 已發布（`status = published`），且 slug 正確 |
| pub/ API 回傳 403 | 確認 `allow_anonymous_access = true` 且對應資料表 `is_public_readable = true` |
| pub/data 看不到某些表 | 該表可能 `is_public_readable = false`，或 `app_id` 不匹配（只顯示屬於該 App 或 tenant 共用的表） |
| 頁面有資料但切換登入後消失 | 登入後 SDK 使用認證版 API，確認認證版的 Data Reference 也已正確設定 |
| 匿名模式下無法提交表單 | 正常行為 — pub/ API 僅允許讀取，提交功能需登入後使用 |
| Rate Limit 429 錯誤 | 匿名模式每 IP 限 120 次/分鐘，建議前端加入請求去重和快取 |
| `__IS_AUTHENTICATED__` 始終為 false | 確認 Auth SDK 是否正確注入。External App 在匿名首次載入時此值為 `false` 是正常的 |

---

## 19. External App 獨立認證系統

> **適用場景**：External 模式的 Custom App 使用獨立於主站的帳號系統，讓外部使用者（客戶、供應商、訪客）透過 Email + 密碼進行註冊、登入和身份驗證。此機制與 §17 的 Internal App（平台帳號系統）完全獨立。

### 19.1 概述

| 比較 | Internal App（§17） | External App（本章） |
|------|:---:|:---:|
| 帳號系統 | 平台帳號系統 | 獨立 `custom_app_users` 表 |
| Token 類型 | 平台 JWT | 自訂 JWT（HS256） |
| 帳號共用 | 與主站 Dashboard 共用 | 每個 App 獨立 |
| 社群登入 | 否 | LINE / Google / LIFF |
| 匿名瀏覽 | 否 | 搭配 §18 Public 模式 |

External App 的使用者資料儲存在 `custom_app_users` 表中，每個 App 各自隔離。同一個 Email 可以在不同 App 中分別註冊。

### 19.2 統一登入 / 註冊 URL

External App 在 Runtime 中會自動掛載登入/註冊頁面路由：

```
https://{tenant}.ai-go.app/externalAppRuntime/{slug}
```

頁面路由由 App 的 VFS 自行定義，通常為：

| Hash 路由 | 頁面 | 說明 |
|-----------|------|------|
| `#/login` | `LoginPage.tsx` | 登入頁 |
| `#/register` | `RegisterPage.tsx` | 註冊頁 |
| `#/` | `HomePage.tsx` | 首頁（登入後） |

> **注意**：App 開發者需自行在 VFS 中建立 `LoginPage.tsx` 和 `RegisterPage.tsx`。Runtime 提供 Auth SDK（`window.__auth__`）來呼叫後端 API。

### 19.3 Custom App Auth API

所有端點前綴為 `/api/v1/custom-app-auth/{app_slug}/`。

#### 註冊

```http
POST /api/v1/custom-app-auth/{slug}/register
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "mypassword123",
  "display_name": "王小明"
}
```

**成功回應**（201）：
```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "rt_abc123...",
  "expires_in": 900,
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "王小明",
    "is_active": true,
    "created_at": "2026-06-11T08:00:00Z"
  }
}
```

| 錯誤碼 | 說明 |
|--------|------|
| 409 | Email 已被註冊 |
| 422 | 參數驗證失敗 |

#### 登入

```http
POST /api/v1/custom-app-auth/{slug}/login
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "mypassword123"
}
```

**成功回應**（200）：與註冊回應格式相同。

| 錯誤碼 | 說明 |
|--------|------|
| 401 | 帳號或密碼錯誤 |
| 403 | 帳號已被停用 |

#### 取得當前使用者

```http
GET /api/v1/custom-app-auth/{slug}/me
Authorization: Bearer {access_token}
```

#### 更新自己的顯示名稱（2026-09 起，各租戶陸續生效）

```http
PATCH /api/v1/custom-app-auth/{slug}/me
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "display_name": "新名字"
}
```

- Payload **只收 `display_name`**：strip 後不可為空、長度 ≤ 100 字，違反回 **422**。
- 身分由 token 決定，**只能改自己**；改名不會撤銷既有 session。

#### 刷新 Token

```http
POST /api/v1/custom-app-auth/{slug}/refresh
Content-Type: application/json

{
  "refresh_token": "rt_abc123..."
}
```

> 舊的 Refresh Token 使用後即撤銷（**Token Rotation**），回應中會包含新的 Refresh Token。

#### 登出

```http
POST /api/v1/custom-app-auth/{slug}/logout
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "refresh_token": "rt_abc123..."
}
```

### 19.4 使用者管理 API（管理員）

以下端點需要 **平台帳號 JWT + `builder.access` 權限**（非 Custom App Token）：

| 操作 | 方法 | 端點 |
|------|------|------|
| 列出使用者 | GET | `/api/v1/custom-app-auth/manage/{app_id}/users` |
| 啟用/停用 | PATCH | `/api/v1/custom-app-auth/manage/{app_id}/users/{user_id}` |
| 刪除使用者 | DELETE | `/api/v1/custom-app-auth/manage/{app_id}/users/{user_id}` |

**列出使用者**：
```http
GET /api/v1/custom-app-auth/manage/{app_id}/users
Authorization: Bearer {platform_jwt}
```

**回應**：
```json
[
  {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "王小明",
    "is_active": true,
    "last_login_at": "2026-06-11T08:00:00Z",
    "created_at": "2026-06-01T00:00:00Z"
  }
]
```

**停用使用者**：
```http
PATCH /api/v1/custom-app-auth/manage/{app_id}/users/{user_id}
Authorization: Bearer {platform_jwt}
Content-Type: application/json

{
  "is_active": false
}
```

### 19.5 OAuth 社群登入（LINE、Google 等）

> **提示**：External App 支援第三方 OAuth 社群登入，讓使用者可透過 LINE、Google 等帳號直接登入，無需手動輸入 Email 和密碼。

#### 查詢可用的 Auth Provider

```http
GET /api/v1/custom-app-oauth/{slug}/auth-providers
```

**回應**：
```json
[
  { "provider": "google", "enabled": true },
  { "provider": "line", "enabled": true }
]
```

#### 發起 OAuth 授權

```http
GET /api/v1/custom-app-oauth/{slug}/google/authorize?redirect_uri=https://your-app.com/callback
```

回傳 302 重導向到 Google/LINE 的授權頁面。

#### OAuth 回調

```http
GET /api/v1/custom-app-oauth/{slug}/google/callback?code=xxx&state=xxx
```

成功後回傳 Token（與 login 端點格式相同）。

#### LINE LIFF Token 交換

適用於 LINE LIFF App 內嵌場景：

```http
POST /api/v1/custom-app-oauth/{slug}/liff-swap
Content-Type: application/json

{
  "liff_access_token": "LINE_LIFF_ACCESS_TOKEN"
}
```

### 19.6 Auth SDK 自動注入（Runtime）

對於 `access_mode = "external"` 的 App，Runtime 會自動在 `window.__auth__` 上注入以下方法：

```typescript
// 登入
const result = await window.__auth__.login(email, password);
// result: { access_token, refresh_token, expires_in, user }

// 註冊
const result = await window.__auth__.register(email, password, displayName);

// 登出（清除本地 Token + 撤銷 Refresh Token）
await window.__auth__.logout();

// 取得當前 Token（自動處理刷新）
const token = await window.__auth__.getToken();

// 檢查是否已認證
const isAuth = window.__auth__.isAuthenticated();

// 訂閱認證狀態變化（登入/登出時觸發回調）
const unsubscribe = window.__auth__.onAuthChange((isAuth) => {
  console.log('認證狀態變化:', isAuth);
});
// 取消訂閱
unsubscribe();

// 取得 OAuth 社群登入 URL
const googleUrl = window.__auth__.getOAuthUrl('google', '#/dashboard');
// → /api/v1/custom-app-oauth/{slug}/google/authorize?return_path=%23%2Fdashboard
window.location.href = googleUrl;  // 跳轉到 Google 登入
```

#### 懶人版登入觸發器

若不想自建 LoginPage，可直接呼叫 `window.__triggerLogin__()` 觸發平台統一登入頁：

```typescript
// 在任意元件中觸發登入（可選帶入登入後的返回路徑）
window.__triggerLogin__('#/booking');  // 登入成功後導向 #/booking

// 不帶路徑，登入後回到首頁
window.__triggerLogin__();
```

#### 登入成功後的路由恢復

OAuth 或 `__triggerLogin__` 登入成功後，Runtime 會自動將登入前的路徑存入 `window.__INITIAL_ROUTE__`：

```typescript
// 在 App.tsx 中檢查是否有待恢復的路徑
const initialRoute = (window as any).__INITIAL_ROUTE__;
if (initialRoute) {
  window.location.hash = initialRoute;
}
```

> **Auth SDK 會自動更新 `window.__APP_TOKEN__`**。登入成功後，所有後續的 API 呼叫（`api.ts`、`db.ts`）會自動帶入新的 Token，不需手動處理。

#### 完整 LoginPage 範例

```tsx
import React, { useState } from "react";
import toast from "react-hot-toast";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    try {
      const result = await (window as any).__auth__.login(email, password);
      toast.success(`歡迎回來，${result.user.display_name}！`);
      // Token 已自動更新，重新載入以切換到認證版 API
      window.location.hash = "#/";
      window.location.reload();
    } catch (err: any) {
      toast.error(err.message || "登入失敗");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleLogin}>
      <h1>登入</h1>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="密碼"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? "登入中..." : "登入"}
      </button>
      <p>
        還沒有帳號？<a href="#/register">立即註冊</a>
      </p>
    </form>
  );
}
```

#### 完整 RegisterPage 範例

```tsx
import React, { useState } from "react";
import toast from "react-hot-toast";

export default function RegisterPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [loading, setLoading] = useState(false);

  const handleRegister = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    try {
      await (window as any).__auth__.register(email, password, name);
      toast.success("註冊成功！");
      window.location.hash = "#/";
      window.location.reload();
    } catch (err: any) {
      toast.error(err.message || "註冊失敗");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleRegister}>
      <h1>註冊</h1>
      <input
        type="text"
        placeholder="顯示名稱"
        value={name}
        onChange={(e) => setName(e.target.value)}
        required
      />
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="密碼（至少 6 位）"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
        minLength={6}
      />
      <button type="submit" disabled={loading}>
        {loading ? "註冊中..." : "建立帳號"}
      </button>
      <p>
        已有帳號？<a href="#/login">前往登入</a>
      </p>
    </form>
  );
}
```

### 19.7 Token 機制

| 項目 | 值 |
|------|-----|
| Access Token 有效期 | **15 分鐘** |
| Refresh Token 有效期 | **7 天** |
| 簽名演算法 | HS256 |
| Token Rotation | 每次 refresh 舊 token 自動撤銷 |
| 多裝置登入 | 每個裝置獨立 session |

**Token 儲存位置**（Auth SDK 自動管理）：

```
localStorage:
  __custom_app_access_token__  → Access Token
  __custom_app_refresh_token__ → Refresh Token
```

> Auth SDK 會在 Access Token 即將過期時自動呼叫 `/refresh` 端點更新。開發者無需手動處理 Token 刷新邏輯。

---

## 20. 套件管理與第三方依賴

> **適用場景**：當 Custom App 需要使用第三方 JavaScript / TypeScript 套件（如日期處理、圖表庫等），需了解 VFS 編譯環境的套件管理機制。

### 20.1 Runtime 內建模組

以下模組由 Runtime 頁面全域提供，**不需安裝**，直接 `import` 即可：

```typescript
import React from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Routes, Route, Link } from "react-router-dom";
import { Search, Calendar, User } from "lucide-react";
import toast, { Toaster } from "react-hot-toast";
```

| 模組 | 版本 | 說明 |
|------|------|------|
| `react` | ^18.x | React 核心 |
| `react-dom` | ^18.x | DOM 渲染 |
| `react-router-dom` | ^6.x | Hash 路由 |
| `lucide-react` | latest | 圖示庫 |
| `react-hot-toast` | latest | Toast 通知 |

> **注意**：這些模組在 esbuild 編譯時會被標記為 `--external`，不會打包進 bundle。Runtime 頁面會提供全域版本。

### 20.2 package.json 依賴宣告

VFS 中的 `package.json` 用於宣告 App 的依賴。但與傳統 Node.js 專案不同，VFS 環境**不會執行 `npm install`**。套件的解析完全由 esbuild 在編譯時處理。

```json
{
  "name": "my-custom-app",
  "private": true,
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}
```

> `package.json` 主要用於 esbuild 的模組解析提示。內建模組（react 等）已在 dependencies 中預設宣告。

### 20.3 新增第三方套件

對於純 JavaScript/TypeScript 套件，您可以直接在 VFS 中使用：

**方法一：直接在原始碼中引用**

適用於小型工具函式。直接在元件中定義：

```typescript
// src/utils/date.ts — 自行實作日期格式化
export function formatDate(date: string): string {
  const d = new Date(date);
  return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}
```

**方法二：將套件原始碼放入 VFS**

適用於小型第三方庫。將壓縮版 JS 直接放入 VFS：

```
src/
├── vendor/
│   └── dayjs.min.js    ← 將套件原始碼放入 VFS
├── pages/
│   └── EventPage.tsx   ← import dayjs from '../vendor/dayjs.min'
```

> **注意**：大型套件（如 chart.js、three.js）不建議放入 VFS，因為單檔大小限制為 1MB。

### 20.4 限制與注意事項

| 限制 | 說明 |
|------|------|
| **不支援 CSS Modules** | 所有 CSS 使用全域 `App.css`，不可使用 `*.module.css` |
| **不支援 Tailwind CSS** | esbuild 不執行 PostCSS 流程 |
| **不支援 Node.js 原生模組** | `fs`、`path`、`crypto` 等無法在瀏覽器執行 |
| **不支援動態 import** | `import()` 語法不支援，所有模組須為靜態引入 |
| **單檔大小上限** | 1 MB |
| **VFS 最大檔案數** | 500 |
| **編譯超時** | 30 秒 |

### 20.5 常見套件相容性

| 套件 | 相容 | 說明 |
|------|:---:|------|
| date-fns（原始碼引入） | 是 | 純 JS、tree-shakable |
| lodash-es（原始碼引入） | 是 | ESM 版本 |
| uuid | 是 | 純 JS |
| chart.js | | 需壓縮版 < 1MB |
| three.js | 否 | 太大（> 1MB） |
| styled-components | 否 | 需 Babel 轉換 |
| @mui/material | 否 | 依賴太多、需 emotion |

### 20.6 Python Action 依賴：`actions/requirements.txt`（2026-09 起，各租戶陸續生效）

Server-Side Actions（第 8 章）可透過 `actions/requirements.txt` 宣告 pip 依賴，與 Builder 的「套件」面板雙向同步。

**宣告格式與上限**：

| 規則 | 說明 |
|------|------|
| 只收精確 pin | 每行 `name==version`（可帶 extras，如 `requests[socks]==2.32.3`）；範圍版本、URL、本地路徑一律不收 |
| 行數上限 | 最多 **20 行** |
| 體積上限 | wheel 合計 ≤ **80 MiB** |
| 目標平台 | **aarch64 / cp312、only-binary**——需要現場編譯的套件（無對應 wheel）不可用 |

**解析時機與錯誤**：

- 依賴解析只在**試跑／發布時**發生；失敗回 **422** 與 `WHEELHOUSE_*` 系列錯誤碼，**不會發出缺套件的版本**。
- 有 pin 時，試跑會走專屬的 draft runner，**冷啟最長約 60 秒**——看到「draft runner not ready」表示正在等冷啟，稍候重試即可。
- 執行期仍**禁止 pip**：Action 程式碼內不能 `pip install`，所有依賴必須事先 pin 在 `requirements.txt`。

---

## 21. 引用系統 API

> **前綴**：`/api/v1/refs`
> **認證**：主站 JWT（需 `builder.access` 權限）

引用（Reference）是 AI GO 的資料授權核心機制。每個整合必須先建立引用，指定要存取哪些表的哪些欄位，以及具備什麼操作權限。

### 21.1 列出可引用的表

```
GET /api/v1/refs/available-tables
```

**Response** `200 OK`：

```json
[
  { "name": "customers", "comment": "客戶" },
  { "name": "sale_orders", "comment": "銷售訂單" },
  { "name": "product_products", "comment": "" }
]
```

### 21.2 取得表的欄位資訊

```
GET /api/v1/refs/tables/{table_name}/columns
```

**Response** `200 OK`：

```json
[
  {
    "name": "name",
    "type": "VARCHAR",
    "nullable": false,
    "is_system": false
  },
  {
    "name": "email",
    "type": "VARCHAR",
    "nullable": true,
    "is_system": false
  },
  {
    "name": "customer_id",
    "type": "UUID",
    "nullable": true,
    "is_system": false,
    "is_fk": true,
    "fk_target": "customers.id"
  }
]
```

### 21.3 引用 CRUD

#### 列出 App 的所有引用

```
GET /api/v1/refs/apps/{app_id}
```

#### 建立引用

```
POST /api/v1/refs/apps/{app_id}
```

| 欄位 | 型別 | 必填 | 說明 |
|------|------|:---:|------|
| `table_name` | string | 必填 | 要引用的表名，如 `"customers"` |
| `columns` | string[] | 選填 | 授權的欄位清單，如 `["name", "email", "phone"]` |
| `permissions` | string[] | 選填 | 權限清單，如 `["read", "create"]` |

#### 更新引用

```
PATCH /api/v1/refs/{ref_id}
```

| 欄位 | 型別 | 說明 |
|------|------|------|
| `columns` | string[] | 新的授權欄位清單 |
| `permissions` | string[] | 新的權限清單 |

#### 刪除引用

```
DELETE /api/v1/refs/{ref_id}
```

### 21.4 權限值說明

| 權限 | 對應 Proxy 操作 | 說明 |
|------|:---:|------|
| `read` | GET / POST query | 查詢資料 |
| `create` | POST insert | 新增記錄 |
| `update` | PATCH | 更新記錄 |
| `delete` | DELETE | 刪除記錄 |

### 21.5 不可引用的表

系統核心表（如身份驗證、租戶管理、權限設定、稽核紀錄等）因安全原因永遠不可被引用。嘗試引用這些表時，API 會回傳 `403 此表不可引用`。

可引用的表清單請透過 `GET /api/v1/refs/available-tables` API 查詢。

### 21.6 系統欄位

以下欄位由系統自動管理，寫入時會被自動排除：

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | UUID | 主鍵，自動生成 |
| `created_at` | timestamptz | 建立時間，自動設定 |
| `updated_at` | timestamptz | 更新時間，自動更新 |
| `tenant_id` | UUID | 租戶 ID，自動注入（row-level 隔離） |

### 21.7 共用業務欄位：`custom_data`（JSONB）

所有功能性資料表皆包含 `custom_data` 欄位，專為第三方應用**儲存業態專屬的自訂資料**設計。

| 特性 | 說明 |
|------|------|
| 型別 | `JSONB`（PostgreSQL 原生 JSON 二進位格式） |
| 預設值 | `'{}'::jsonb`（空 JSON 物件） |
| Nullable | 是（可設為 `null`） |
| 存取方式 | 僅透過 Proxy API（Internal / External / Open） |
| 資料隔離 | 遵循現有 `tenant_id` row-level 隔離機制 |

**使用方式**：在建立引用時將 `custom_data` 加入 `columns` 清單：

```json
{
  "table_name": "customers",
  "columns": ["id", "name", "email", "custom_data"],
  "permissions": ["read", "create", "update"]
}
```

> **注意**：若引用的 `columns` 中未包含 `custom_data`，則該 App 在讀取時不會看到此欄位，寫入時也會被自動忽略。

---

## 22. 接收外部 Webhook

> **適用場景**：當 Custom App 需要接收來自第三方服務（如 LINE、Meta、綠界、物流商等）的即時通知時，可使用平台的泛用 Webhook 閘道。

### 22.1 Webhook URL

每個已發布的 Custom App 自動獲得 Webhook URL：

```
POST https://{tenant}.ai-go.app/api/v1/custom-apps/webhook/{slug}
POST https://{tenant}.ai-go.app/api/v1/custom-apps/{slug}/webhook
```

其中 `{slug}` 為 App 的 slug 或 subdomain（非 UUID）。

> **無需認證**：Webhook URL 是公開端點，不需要 JWT Token。App 必須處於 **published** 狀態。

### 22.2 receive_webhook.py Action

Webhook 閘道固定派發到 `actions/receive_webhook.py`，**名稱不可變更**。此檔案需列入 `actions/manifest.json` 中。

Action 收到的 `ctx.params` 結構如下：

```python
{
    "webhook_event": "incoming",
    "body": "<原始 HTTP Body 字串>",
    "headers": {
        "content-type": "application/json",
        "x-line-signature": "...",
        ...
    }
}
```

### 22.3 實作範例

```python
import json

def execute(ctx):
    """接收外部 Webhook 並根據事件類型分流處理"""

    # 1. 解析原始 Payload
    raw_body = ctx.params.get("body", "")
    headers = ctx.params.get("headers", {})

    try:
        payload = json.loads(raw_body)
    except json.JSONDecodeError:
        ctx.response.json({"error": "Invalid JSON"})
        return

    # 2. 根據事件類型分流
    event_type = payload.get("type") or payload.get("event")

    if event_type == "order.created":
        # 電商新訂單 → 寫入訂單表
        ctx.data.insert("orders", {
            "order_no": payload["order_id"],
            "amount": payload["total"],
            "status": "pending",
            "raw_payload": raw_body,
        })

    elif event_type == "payment.confirmed":
        # 支付確認 → 更新訂單狀態
        ctx.data.update("orders",
            filters=[{"column": "order_no", "op": "eq", "value": payload["order_id"]}],
            data={"status": "paid"}
        )

    elif event_type == "message":
        # 聊天訊息 → 寫入訊息記錄
        ctx.data.insert("messages", {
            "sender": payload.get("sender_id"),
            "content": payload.get("text"),
            "channel": headers.get("x-channel-id", "unknown"),
        })

    else:
        # 未知事件 → 記錄到日誌表供後續分析
        ctx.data.insert("webhook_logs", {
            "event_type": event_type or "unknown",
            "payload": raw_body,
            "processed": False,
        })

    ctx.response.json({"status": "accepted"})
```

> **Action 必須命名為 `receive_webhook.py`**：Webhook 閘道固定派發到此 Action，名稱不可變更。

### 22.4 限制

| 項目 | 說明 |
|------|------|
| App 狀態 | 必須為 **published** |
| 執行超時 | **45 秒** |
| 回應格式 | 閘道立即回傳 `{"status": "accepted"}`，Action 在背景非同步執行 |
| 重試機制 | 平台不主動重試，建議在 Action 中記錄錯誤 |
| 簽章驗證 | 需自行在 Action 中實作（如 LINE Signature） |

### 22.5 Meta Webhook 訂閱驗證

在 App 的 **Secrets** 中設定 `META_VERIFY_TOKEN`，平台會自動處理 Meta 的 GET 驗證請求，不觸發 Action。

> **提示**：完整的 Webhook 閘道規格（URL 格式、認證機制等），請參閱 [AI GO 系統串接指南 — Webhook Gateway](https://www.ai-go.app/docs/integration-guide#12-泛用-webhook-閘道-webhook-gateway)。

---

## 23. 簽核工作流 (Approval Workflow)

AI GO 內建**通用簽核引擎**：管理者在主站設定「哪張表、哪個動作、要誰簽」，引擎便會在該操作真正執行前攔截它，改成建立一張簽核申請單。這個引擎是**非侵入式**的——不改資料表結構、不需要您在 App 內自己寫簽核邏輯。

Custom App 與簽核系統有三個接觸面：

| 接觸面 | 說明 | 章節 |
|--------|------|------|
| **被攔截** | 您的寫入／確認操作命中簽核流程，改為建立申請單 | 23.2 / 23.3 |
| **前端 SDK** | `src/approval.ts`：在 App 裡做待簽清單、核准／駁回按鈕 | 23.4 |
| **Server Action** | `ctx.approval.*`：在 Python action 內查詢與簽核 | 23.5 |

> **簽核流程本身由租戶管理者在主站設定**（簽核流程設定頁），Custom App **不負責也無法**建立流程。您的責任是：正確處理「被攔截」的回傳，以及（選用）提供簽核介面。

### 23.1 核心概念

- **前置守衛（Pre-guard）**：業務操作在狀態變更前先問引擎「這個動作要簽核嗎？」。命中 → 建立 `ApprovalRequest`，**原始操作不執行**；未設定流程 → 直接放行（短路原則）。
- **多態關聯**：申請單以 `(res_model, res_id)`（表名 + 記錄 ID）指向任意記錄。
- **回調閉環**：**全部層級通過後，平台自動執行**當初被攔截的那個操作（例如真的去確認訂單、過帳傳票、套用暫存的 update payload）。您的程式**不需要也不應該**在簽核通過後再送一次。
- **狀態機**：

  ```
  申請單：pending → approved（全層通過）｜rejected（任一層駁回）｜cancelled（申請人取消）
  簽核層：waiting → pending → approved｜rejected｜skipped
  ```

- 駁回後原記錄**保持可編輯**，修改後重送會建立一張**全新**申請單（不是復活舊單）。

### 23.2 哪些操作會被攔截

| 來源 | 受管制的操作 | 攔截行為 |
|------|-------------|----------|
| `src/db.ts`（DB Proxy） | `insert` / `update` / `remove` 對已授權的 ERP 表 | insert = insert-then-flag；update / delete = pre-guard |
| `ctx.db`（Server Action） | `insert` / `update` / `remove` 對 ERP 表 | 同上，pre-guard 會拋 `ApprovalPendingError` |
| `ctx.erp`（Server Action） | `confirm_sale_order`、`confirm_purchase_order`、`post_move`、`confirm_payment`、`validate_picking`、`confirm_payroll_run` 等正向確認類方法 | pre-guard，拋 `ApprovalPendingError` |
| 主站 ERP 端點 | 銷售／採購確認、傳票過帳、揀貨確認、MRP 確認、HR 請假核准 | HTTP 202 `pending_approval` |

常見可設流程的表：`sale_orders`、`purchase_orders`、`account_moves`、`account_payments`、`stock_pickings`、`stock_scraps`、`mrp_productions`、`hr_leaves`，以及您授權給 App 的其他 ERP 表。

**不在簽核範圍**：資料中心自建表（`src/api.ts`、`ctx.db.*_row`）的寫入不經 ERP 表，不受簽核管制；`ctx.erp.reconcile_payment`、`cancel_payroll_run` 等修復／撤銷類方法亦不套 guard。

> **Custom App 不是簽核旁路。** 不論您走前端 SDK、`ctx.db` 還是 `ctx.erp`，同一套守衛都會生效——不要試圖用「換一條路徑寫入」來繞過租戶設定的簽核流程。

### 23.3 被攔截時，您的程式要怎麼處理

**（1）Insert — insert-then-flag：記錄照樣寫入**

為了取得關聯 ID，新增操作的記錄**會**寫進資料庫，但不觸發後續營運邏輯，並附帶 pending 標記回傳：

```typescript
const result = await insert("sale_orders", { data: { amount_total: 5000 } });

if (result.approval_status === "pending") {
  // result.approval_request_id / result.approval_message 一併回傳
  toast.info(result.approval_message || "已送出簽核申請，待核准後生效");
} else {
  toast.success("訂單已建立");
}
```

> **不要因為 pending 而重試 insert**——會重複建立記錄與申請單。

**（2）Update / Delete — pre-guard：不執行，payload 暫存**

更新／刪除**不會**實際發生；您送出的 payload 會暫存在申請單裡，等簽核全部通過後由平台自動套用。

**（3）Server Action 內：捕捉 `ApprovalPendingError`**

`ctx.db.update` / `ctx.db.remove` 與 `ctx.erp.*` 的 pre-guard 以例外形式回報：

```python
def execute(ctx):
    try:
        ctx.erp.confirm_sale_order(ctx.params["order_id"])
    except Exception as e:
        # 訊息形如「需要簽核審批：...（request_id=...），簽核全部通過後系統會自動執行此操作」
        if "簽核" in str(e) or "ApprovalPending" in type(e).__name__:
            ctx.response.json({"pending_approval": True, "message": str(e)})
            return
        raise
    ctx.response.json({"ok": True})
```

**（4）無代碼閉環：讓引擎自動改狀態欄位**

若您只是想在核准後把單據的狀態欄位翻成 `approved`，不必寫任何 Python——請管理者在建立簽核流程時填 `approved_state_field` / `approved_state_value` / `rejected_state_value`，引擎會在核准／退回時自動更新該欄位（詳見 [第 7 章 — 簽核通過/退回後的自動狀態變更](#7-內建-sdk)）。您的 App 只要查 `state === 'approved'` 即可。

### 23.4 前端 Approval SDK（`src/approval.ts`）

以「目前登入使用者」身分操作簽核。**僅 Internal App 可用**——External App 呼叫任一函式會直接拋錯（External 尚無簽核端點）。

| 函式 | 說明 |
|------|------|
| `myPending()` | 待我簽核清單（陣列，已解包 `{items,total}` 信封） |
| `recordStatus(resModel, resId)` | 某筆記錄的最新簽核申請；無簽核記錄回 `null` |
| `approve(requestLineId, comment?)` | 核准一層；**需為該層合格審核人**，否則後端回 403 |
| `reject(requestLineId, comment?)` | 駁回；整張申請變 `rejected` |
| `cancel(requestId, reason?)` | 取消申請；**僅申請人本人、僅 pending 可取消** |

`myPending()` 每個元素的欄位：

| 欄位 | 說明 |
|------|------|
| `request_id` | 申請單 ID（`cancel` 用） |
| `request_line_id` | 簽核層 ID（`approve` / `reject` 用） |
| `res_model` / `res_id` | 被簽核的表名與記錄 ID（可據此查原始單據） |
| `workflow_name` / `stage_name` | 流程名稱、當前層級名稱 |
| `requester_name` | 申請人 email；無使用者身分的來源顯示 `API` |
| `submitted_at` / `current_stage_sequence` | 提交時間、當前層級序號 |

`recordStatus()` 回傳 `{ id, status, workflow_name, requester_name, submitted_at, completed_at, lines[] }`，`lines[]` 每層含 `{ id, sequence, stage_name, status, assigned_user_id, reviewer_id, comment, reviewed_at }`——可直接畫成簽核進度時間軸。

**完整範例：App 內的待簽核頁**

```typescript
import { useEffect, useState } from "react";
import { myPending, approve, reject } from "../approval";

export default function MyApprovalsPage() {
  const [items, setItems] = useState<any[]>([]);
  const load = async () => setItems(await myPending());
  useEffect(() => { load(); }, []);

  const onApprove = async (item: any) => {
    try {
      const res = await approve(item.request_line_id, "同意");
      // res.all_approved === true 代表最後一層通過，原操作已由平台自動執行
      alert(res.all_approved ? "簽核完成，操作已執行" : "已核准，進入下一層");
      await load();
    } catch (e: any) {
      alert(e.message); // 403：您不是此層級的合格審核人
    }
  };

  return (
    <div>
      {items.map((it) => (
        <div key={it.request_line_id}>
          <span>{it.workflow_name} — {it.stage_name}（{it.requester_name}）</span>
          <button onClick={() => onApprove(it)}>核准</button>
          <button onClick={() => reject(it.request_line_id, "金額有誤").then(load)}>駁回</button>
        </div>
      ))}
    </div>
  );
}
```

> **提示**：`myPending()` 只回傳「**您**有資格簽」的項目，資格由後端即時解析（角色／部門異動立即生效），前端不需要也不應該自己判斷誰能簽。

### 23.5 Server Action：`ctx.approval.*`

在 Python action 內操作簽核，身分是**觸發這個 action 的使用者**，不是 App 自己。

| 方法 | 說明 |
|------|------|
| `ctx.approval.list_pending()` | 觸發者的待簽核清單（欄位同 `myPending()`） |
| `ctx.approval.get_record_status(res_model, res_id)` | 某筆記錄的簽核狀態（含 `lines[]`）；無則回 `None` |
| `ctx.approval.approve(request_line_id, comment=None)` | 核准一層。回傳 `{status, request_id, request_status, all_approved, callback_error}` |
| `ctx.approval.reject(request_line_id, comment=None)` | 駁回整張申請 |
| `ctx.approval.cancel(request_id, reason=None)` | 取消申請（僅申請人本人） |

```python
def execute(ctx):
    pending = ctx.approval.list_pending()
    if not pending:
        ctx.response.json({"count": 0, "items": []})
        return

    # 只自動核准 1000 元以下的小額採購（示意：條件邏輯由 App 決定，資格仍由平台把關）
    result = ctx.approval.approve(pending[0]["request_line_id"], "系統依規則核准")
    ctx.response.json({
        "all_approved": result["all_approved"],
        "callback_error": result["callback_error"],  # 非 None 代表回調失敗，可在主站重試
    })
```

**三條不可逾越的規則：**

1. **App 不能代替任何人簽核。** `approve` / `reject` 必須綁定平台使用者，且該使用者須為當前層級的**合格審核人**（與主站端點共用同一套判定）；不合格 → `PermissionError`。
2. **無使用者身分的 invocation 一律拒絕。** 排程／背景觸發沒有帶平台 User 時，`list_pending` / `approve` / `reject` / `cancel` 會拋 `PermissionError`。
3. **`cancel` 僅申請人本人**，且僅 `pending` 狀態可取消。

**Scope 授權**：`ctx.approval` 需在 App 的 Scope 設定中授權，且分級不同——

| Scope | 對應方法 | 風險級別 |
|-------|---------|---------|
| `approval.read` | `list_pending`、`get_record_status` | 低風險（免 step-up） |
| `approval.decide` | `approve`、`reject` | **高風險**（授權擴大時需擁有者密碼二次驗證） |
| `approval.cancel` | `cancel` | **高風險**（同上） |

> **提示**：`callback_error` 不為 `None` 代表簽核已完成、但自動執行原操作時出錯（例如缺會計科目）。**簽核結果不會因此回滾**，請提示使用者到主站簽核面板重試該筆回調。

### 23.6 流程規則（供您理解管理者的設定會如何影響 App）

- **多層序列**：層級依序號由小到大逐層審，前一層完成才輪到下一層（**不支援跨層平行**）。
- **層內模式**：
  - `any`：該層任一合格審核人核准即通過。
  - `all`（**會簽**）：建立申請時就把該層所有合格審核人各展開成一條 line（綁定本人），**全員核准**才推進；任一人駁回 → 整張申請 rejected。
- **非必要層（`is_required=False`）**：若建單時該層解析不到任何合格審核人，整層自動 `skipped`，流程不會卡死；必要層解析不到人則會停在該層等管理者修設定（寧可卡住也不放行）。
- **一表多流程**：同一張表可設多個啟用中的流程，依序號優先權逐一比對觸發條件（如金額級距分流），**取第一個完全匹配者**。同一筆記錄同時間只會有一張 pending 申請。
- **審核人解析時機**：`any` 層是**審核當下**解析（角色異動即時生效）；會簽層在**建單當下**就綁定人員。
- **API 旁路（`allow_api_bypass`）**：若管理者在流程上勾選此項，則**沒有使用者身分**（API Key／背景排程）的請求會被放行不攔，並寫入稽核日誌。有使用者身分的請求永遠不旁路。

### 23.7 REST 端點一覽

`src/approval.ts` 已封裝下列端點；若您需要自行呼叫（如 AI Agent 直接打 API），端點如下（皆需 `Authorization: Bearer {JWT}`）：

| 操作 | 方法 | 端點 |
|------|------|------|
| 待我簽核 | GET | `/api/v1/approvals/my-pending` |
| 記錄簽核狀態 | GET | `/api/v1/approvals/record/{res_model}/{res_id}` |
| 核准 | POST | `/api/v1/approvals/{request_line_id}/approve` |
| 駁回 | POST | `/api/v1/approvals/{request_line_id}/reject` |
| 取消申請 | POST | `/api/v1/approvals/{request_id}/cancel` |
| 重試已核准的回調 | POST | `/api/v1/approvals/{request_id}/retry` |
| 申請列表 | GET | `/api/v1/approvals/requests` |
| 流程設定 CRUD | GET/POST/PATCH/DELETE | `/api/v1/approvals/workflows[/{id}]` |

### 23.8 限制與常見問題

| 問題 | 說明 / 解法 |
|------|------------|
| 呼叫 `approve()` 回 403 | 您不是該層級的合格審核人；請確認 `request_line_id` 來自 `myPending()`（該清單已過濾資格） |
| External App 呼叫 approval SDK 拋錯 | 簽核僅支援 Internal App，External App 無簽核端點 |
| `ctx.approval` 拋 PermissionError（無使用者身分） | 該 invocation 未攜帶平台 User（如排程觸發）；簽核操作必須由使用者觸發 |
| 操作「成功」了但資料沒變 | 檢查回傳是否帶 `approval_status: "pending"`，或例外訊息是否為簽核攔截——不要把 pending 當成功 |
| 簽核通過了但原操作沒發生 | 檢查 `callback_error`；回調失敗不影響簽核結果，可在主站簽核面板 retry |
| 想在 App 內設定簽核流程 | 目前不開放；流程由租戶管理者在主站設定 |
| 沒有通知／催辦 | 簽核目前是純 pull 模式（「待我簽核」清單），平台不主動推播；如需提醒請自行以 `ctx.messaging` 實作 |
| 找不到委派／加簽／代理 | 尚未支援（含逾時升級、跨層平行簽核） |

---

## 24. 企業知識檢索 (ctx.knowledge)

`ctx.knowledge` 讓 Action 檢索**企業知識中心**（主站「知識中心 /files」裡被「設為知識」的檔案）的 per-tenant 向量庫。

### 核心語意（三點，先讀完再寫程式）

1. **純檢索、不生成**。走向量檢索回傳 chunk 原文＋相關度分數，**不呼叫任何 LLM、不消耗系統 AI Credit**。要產生回答，請在 Action 內用自己的金鑰（App Secrets 的 `OPENAI_API_KEY` 等）呼叫模型，把 `results[].content` 當 RAG context——**生成的成本與選模完全由您掌控**。
2. **檢索用系統金鑰，與 BYOK 無關**。租戶 vector store 建在平台的 OpenAI org 之下，您的 BYOK key 打不到它；檢索這一段由平台代打，用途僅限「檢索本租戶自己的知識」。
3. **與退役中的「App 專屬知識庫／智能客服 `trigger_ai_reply`」是兩套系統，零依賴，勿混用。**

### API

```python
def execute(ctx):
    hits = ctx.knowledge.search(ctx.params["question"], top_k=5, score_threshold=0.5)

    # enabled=False ＝ 本租戶尚未把任何檔案設為知識
    if not hits["enabled"]:
        return ctx.response.json({"answer": "尚未設定企業知識"})

    # results 是 chunk 級：同一檔案可有多筆，各自帶 score
    rag = "\n\n".join(f'[{r["filename"]}] {r["content"]}' for r in hits["results"])

    # 需要完整脈絡而非片段時，用 search 回傳的 file_id 取全文
    # full = ctx.knowledge.get_content(hits["results"][0]["file_id"])

    ctx.response.json({"context": rag})
```

**回傳形狀**

| 方法 | 回傳 |
|------|------|
| `search` | `{"enabled": bool, "results": [{"file_id", "filename", "score", "content"}]}` |
| `get_content` | `{"file_id", "filename", "mime_type", "content", "truncated"}` |

`file_id` 是**平台 FileNode id**（不外洩 OpenAI file id），可直接傳給 `get_content`。`truncated=True` 表示超過長度上限或來源端仍有後續分頁。

### 限制

| 項目 | 值 |
|------|-----|
| `top_k` | 上限 20 |
| query 長度 | 4,000 字元 |
| 單一 chunk 回傳 | 4,000 字元 |
| `get_content` 全文 | 200,000 字元（超過標 `truncated`） |

### 檔案級 ACL 自動生效

檢索結果會依呼叫者身分過濾，**受限檔案在結果中如同不存在**（不回片段、不回檔名）：

| 呼叫情境 | 可檢索到 |
|---------|---------|
| Internal App ＋ 有 user context | 依**觸發者的角色**過濾（同 /files UI 所見） |
| External / Self-Built App | 僅「External 開放」層級的檔案 |
| 排程等無 user context | 視同無角色成員（全公司層級＋External 層） |

已刪除、非本租戶的檔案一律排除。真相源是平台 DB——角色變更即時生效。

### 錯誤處理

- 租戶尚未建知識庫 → `search` 回 `{"enabled": False, "results": []}`（**不是錯誤**）
- `get_content` 傳到非知識檔／無權限檔 → 拋例外，請自行捕捉
- 平台未設定檢索金鑰 → 拋 `RuntimeError`（部署層問題，非您的程式碼問題）

### Scope

需要 `knowledge.read`，**高風險等級**——授權時需租戶擁有者當場密碼 step-up（見第 26 章）。

---

## 25. 外部服務閘道 (Egress)

Custom App **不能任意連外網**。所有對外呼叫都必須經過已授權的外部服務（EgressService）。規劃階段就先盤點 App 需要哪些外部服務，開發中途才發現要連外網會打斷流程。

### 授權模型：純網域白名單

外部服務 ＝ **slug + base_url 的純網域白名單**。您授權 `api.example.com`，App 就只能呼叫該網域底下的路徑；未授權網域一律打不到，不論程式碼怎麼寫。

**設定入口只有一個**：Builder（`/builder/{app_id}`）的「外部服務」tab（舊的 `/dashboard/settings/integrations` 入口已移除）。建立外部服務需為本 App 擁有者或具 `system.admin`；新建的服務預設授權給本 App。

### 認證方式：金鑰歸 App 自管

**閘道只驗域名**——不注入、不剝除 `Authorization` header、不代管任何金鑰。寫入 API 也拒絕憑證欄位：`auth_type ≠ none` 或非空 `connection_config` 一律回 400。

金鑰由 App 自己管理：存進 `ctx.secrets`，在 action 內自組 header 傳入：

```python
def execute(ctx):
    resp = ctx.http.call("logistics", "/track",
                         method="POST",
                         headers={"Authorization": f"Bearer {ctx.secrets.get('MY_KEY')}"},
                         body={"no": ctx.params["tracking_no"]})

    # 注意: call 不拋例外，務必檢查 status
    if resp["status"] != 200:
        d = resp["data"]
        return ctx.response.json({"error": d.get("fix") or d.get("error")})

    ctx.response.json(resp["data"])
```

> **raw `import httpx` / `import requests` 直連外網必定 timeout**——runner 是 default-deny egress，唯一的出口是 `ctx.http.call`。看到「對外請求逾時」先檢查是不是繞過了閘道。

**兩個最常踩的坑**

1. **第三個位置參數是 `method`（字串），不是 body。** 送 body 請用具名參數 `body=`。
2. **`call` 不拋例外**，回 `{status, headers, data}`。失敗時 `data` 帶：

| 欄位 | 內容 |
|------|------|
| `error_type` | 錯誤分類（未授權／逾時／網域未白名單⋯） |
| `error` | 技術訊息 |
| `fix` | **一句可直接轉述給使用者的修復指引** |
| `fix_url` / `required_role` / `retryable` | 補充資訊 |

`fix` 是刻意設計的欄位——讓 App 把「怎麼修」直接顯示給操作者，而不是丟技術堆疊。Builder UI 與 AI 開發助手也會讀取它協助排錯。

### 錯誤對照

| 症狀 | 意義 | 處置 |
|------|------|------|
| `egress_service_not_found` | slug 沒有同名外部服務 | 到「外部服務」tab 建立，或改用正確 slug |
| `egress_not_authorized` | 服務存在但未授權給本 App | 請有權限者在「外部服務」tab 授權 |
| 外部 API 回 **401** | **外部 API 拒絕你自帶的憑證——是 app 側問題** | 檢查自組的 `Authorization` header 與 `ctx.secrets` 內的金鑰；不用動外部服務設定 |

### Scope

需要 `http`，**高風險等級**。

---

## 26. Scope 授權模型

每個 `ctx` 模組方法都對應一個 **scope（能力群）**。App 宣告需要哪些 scope，經租戶管理者核可後才生效。

### 兩層授權，不可互相取代

| 層 | 管什麼 | 由誰決定 |
|----|--------|---------|
| **Scope** | 能用哪些**能力群**（讀資料／寫資料／觸發 ERP／讀金鑰⋯） | App 宣告 → 管理者核可 |
| **細粒度邊界** | 能碰哪些**具體資源** | ERP 表看 Data Reference；外部服務看網域白名單；金鑰看可用清單 |

對沒有細粒度層的模組（`erp.*`、`messaging.*`），**scope 就是唯一邊界**。

### 風險分級

| 等級 | Scope | 核可方式 |
|------|-------|---------|
| **低風險** | `db.read`、`data_table.read`、`messaging.read`、`approval.read`、`crypto` | 管理者核可 |
| **高風險** | `db.write`、`data_table.write`、`erp.*`、`approval.decide`、`approval.cancel`、`knowledge.read`、`messaging.write`、`messaging.channel`、`http`、`mcp`、`secret.read` | 管理者核可 **＋ 擁有者當場密碼 step-up** |

`knowledge.read` 雖是唯讀且已有檔案級 ACL 作第二層，仍列高風險——知識中心可能含 HR／財務文件，取保守值。

### 宣告與發布

- **發布時自動掃描程式碼回填** `requested_scopes`，您不需手動維護清單
- 發布會**凍結**一份 scope 快照：開發階段改 scope 設定不影響已上線版本，必須重新發布才生效
- External / Self-Built App 的 scope 需管理者顯式核可，不走 internal 的簡化流程

### 執行期行為

- 未核可的 scope → 呼叫回 **403**
- 每一次 `ctx` 呼叫都留下稽核紀錄（含 scope 與決策結果）
- Scope 授權的每一次變更都記錄執行者身分，可在主站稽核日誌查詢

> **不要把 scope 當成 UI 開關。** 它是後端的硬閘：前端藏起來的按鈕、或您在 Action 內自己寫的權限判斷，都不能取代 scope——反之亦然，scope 通過也不代表使用者本人有權限，那由 `ctx.user_permissions` 判斷。

---

## 27. 建立與刪除 App（API）

除了在 Builder UI 手動建立，App 也可以完全透過 API 建立與刪除，適合 AI Agent 自動化開場。

### 27.1 建立 App

```http
POST /api/v1/builder/apps
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "name": "訂單看板",
  "template_slug": "starter-internal",
  "subdomain": "order-board"
}
```

- 需要 `builder.access` 權限。
- 成功回 **201**，回應中的 `id` 即後續所有 Builder API 使用的 `app_id`。

| 欄位 | 必填 | 說明 |
|------|:---:|------|
| `name` | 是 | App 名稱，1–100 字 |
| `template_slug` | 是 | 模板 slug——**建立是模板驅動的，不能建純空白 App** |
| `subdomain` / `url_name` | 否 | 自訂子網域／URL 名稱 |

### 27.2 起手式模板對照

| template_slug | access_mode | 適用場景 |
|---------------|-------------|---------|
| `starter-internal` | `internal` | 租戶成員使用的內部工具 |
| `starter-external` | `external` | 對外應用，終端使用者自助註冊 |

- **`access_mode` 由模板決定，建立後不可改**——先想清楚 App 是對內還是對外。
- App 的 slug 由系統自動生成，**不可指定**。
- 模板商城的其他模板也可用其 slug 建立（清單見 `GET /api/v1/templates`）。

### 27.3 建立後的第一件事

- **起手式不是全空白**：會 seed 約 23 個檔案，含示範 action。與您的需求無關的示範內容請先清掉，再開始開發（VFS 操作見第 5 章）。
- **金鑰不在建立時收**：建立後到 Builder 的「服務」tab 設定。

### 27.4 刪除與複製

```http
DELETE /api/v1/builder/apps/{app_id}
```

> ⚠️ **沒有兩段式確認，打了就刪。** 自動化腳本請把 delete 放在明確的人工確認之後。

```http
POST /api/v1/builder/apps/{app_id}/duplicate
```

可複製一份現有 App（含 VFS），適合先複製再實驗的保守流程。

---

## 延伸閱讀

- [AI GO 系統串接指南](https://www.ai-go.app/docs/integration-guide) — 第三方自建應用 API Key 整合
- Custom App 支援 Internal（內部）、External（外部）和 Public（匿名檢視）三種模式