AI GO Custom App — 開發者指南

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


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 後端腳本,在安全沙箱中執行

Internal vs External

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

2. 認證與連線

取得 JWT Token

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

POST https://ai-go.app/api/v1/auth/login
Content-Type: application/json

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

回應

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "...",
  "expires_in": 3600,
  "token_type": "bearer"
}

使用 JWT

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 → Custom Data 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:Custom Data CRUD
│   ├── db.ts                       # SDK:DB Proxy
│   ├── action.ts                   # SDK:Server-Side Action
│   ├── data.json                   # Custom Table 定義(自動注入)
│   ├── db.json                     # Data Reference 定義(自動注入)
│   ├── pages/
│   │   ├── _manifest.json          # 頁面清單
│   │   ├── DashboardPage.tsx       # 頁面元件
│   │   └── NotFoundPage.tsx        # 404 頁面
│   └── components/
│       ├── AppLayout.tsx           # 主 Layout
│       ├── AppSidebar.tsx          # 側邊欄
│       └── AppHeader.tsx           # 頂部欄
└── actions/
    ├── manifest.json               # Action 註冊清單
    └── example_action.py           # Action 實作

不可修改的 SDK 檔案

檔案說明
src/api.tsCustom Data CRUD SDK
src/db.tsDB Proxy SDK
src/action.tsServer Action SDK
src/data.jsonRuntime 自動注入
src/db.jsonRuntime 自動注入

5. 程式碼注入 API

API 端點一覽

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

局部更新(推薦)

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
}

刪除檔案

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

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

成功回應

{
  "success": true,
  "html": "<!DOCTYPE html>...",
  "bundle_js": "...",
  "css": "..."
}

失敗回應

{
  "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

Custom Data(src/api.ts

操作 App 自建的動態資料表:

import { listRecords, submitRecord, updateRecord, deleteRecord } from "../api";

const records = await listRecords("my_table");
await submitRecord("my_table", { name: "新記錄" });
await updateRecord("my_table", recordId, { name: "更新" });
await deleteRecord("my_table", recordId);

DB Proxy(src/db.ts

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

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 呼叫:

// ❌ 目前 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,前端開發者應攔截此狀態並給予使用者對應的提示,而不是單純顯示「操作成功」。

// 範例:處理新增操作的簽核攔截
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.insertdb.update 操作,您可以在建立 ApprovalWorkflow 時,配置以下三個欄位,核心引擎會在主管核准或退回時,自動更新該紀錄的狀態:

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

運作原理

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

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

Server Action(src/action.ts

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

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

程式碼格式

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.db.query(table, **kwargs)查詢資料。支援進階參數如 order_by, search, limit,範例:
ctx.db.query("clients", limit=50, order_by=[{"column": "id", "direction": "desc"}])
ctx.db.insert(table, data)新增記錄
ctx.http.call(service, endpoint)呼叫外部 API
ctx.crypto.hash(alg, data)雜湊計算
ctx.secrets.get(key)取得金鑰
ctx.response.json(data)JSON 回應
ctx.csv.export(rows)CSV 匯出

安全限制

  • 僅允許白名單模組(json、math、re、datetime、httpx 等)
  • 禁止 exec/eval/open 等危險操作
  • 執行超時 30 秒、記憶體上限 256 MB

9. 驗證與發布

標準開發迴圈

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

發布 API

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.tsximport "./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 回傳 401Token 可能過期或 SDK 變數未正確注入。確認 SDK 未被手動修改,且 Runtime 已正確啟動
409 ConflictVFS 被同時修改,重新 GET 後合併重試
423 Locked有待審核的發布申請,等待或取消
Action 超時優化邏輯,控制在 30 秒內
禁止匯入模組使用白名單模組或 ctx.http.call()
pub/ API 回傳 403確認 allow_anonymous_access=true 且資料表 is_public_readable=true(詳見第 18 章)
pub/ API 回傳 429超過匿名 Rate Limit(120/min per IP),稍後重試

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 雙選擇器:

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

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

同樣適用於 HTML 元素重設:

/* ✅ 正確 */
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 正常、部署後無反應」:

APIShadow DOM 行為替代方案
confirm()靜默回傳 falseReact useState 二階段確認
alert()不顯示react-hot-toast 或自訂 Toast
prompt()回傳 nullReact 自訂 input modal
// ✅ 正確:React state 確認
const [showConfirm, setShowConfirm] = useState(false);

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

可正常使用的 API:localStoragefetchwindow.location.reload()

容器滾動限制

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

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

// ✅ 正確:明確設定高度與滾動
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 上下文缺失導致白屏:

// ✅ 單頁 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


12. VFS 注入腳本開發規範

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

字串操作風險

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

推薦做法

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

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

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

編譯防禦

部署腳本在呼叫 Compile API 後,必須檢查 success 欄位:

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,而非使用列表端點。


13. Custom Table 結構管理 API

💡 適用場景:外部 AI Agent 需要透過 API 動態建立、修改和刪除 Custom App 的自訂資料表(Custom Table),而非僅操作記錄。

概述

Custom App 除了可在 Builder UI 手動建立資料表外,也可透過 API 完成相同操作。認證方式與其他 Builder API 相同:平台帳號 JWT + builder.access 權限

此功能等同於 Reference 的 API 建立方式(/refs/apps/{id}),但操作的是 Custom Table(/data/objects)。

API 端點一覽

操作HTTP 方法端點說明
列出資料表GET/api/v1/data/objects?app_id={app_id}含欄位定義
建立資料表POST/api/v1/data/objects單一建表
建立資料表 + 欄位POST/api/v1/data/objects/batch推薦:一次建表 + 定義欄位
刪除資料表DELETE/api/v1/data/objects/{obj_id}Cascade 刪除 fields + records
新增欄位POST/api/v1/data/objects/{obj_id}/fields
列出欄位GET/api/v1/data/objects/{obj_id}/fields
修改欄位PATCH/api/v1/data/fields/{field_id}name / field_type / is_required / sequence
刪除欄位DELETE/api/v1/data/fields/{field_id}

Batch 建表 + 欄位(推薦)

一次 API 呼叫完成建表和所有欄位定義,減少 AI Agent 的往返次數:

POST /api/v1/data/objects/batch
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "app_id": "your-app-uuid",
  "name": "訂單管理",
  "api_slug": "orders",
  "fields": [
    { "name": "訂單號", "field_key": "order_no", "field_type": "text", "is_required": true, "sequence": 1 },
    { "name": "金額", "field_key": "amount", "field_type": "number", "is_required": false, "sequence": 2 },
    { "name": "日期", "field_key": "order_date", "field_type": "date", "is_required": false, "sequence": 3 }
  ]
}

回應(含完整欄位定義):

{
  "id": "uuid",
  "tenant_id": "uuid",
  "app_id": "uuid",
  "name": "訂單管理",
  "api_slug": "orders",
  "fields": [
    { "id": "uuid", "object_id": "uuid", "name": "訂單號", "field_key": "order_no", "field_type": "text", "is_required": true, "sequence": 1 },
    { "id": "uuid", "object_id": "uuid", "name": "金額", "field_key": "amount", "field_type": "number", "is_required": false, "sequence": 2 },
    { "id": "uuid", "object_id": "uuid", "name": "日期", "field_key": "order_date", "field_type": "date", "is_required": false, "sequence": 3 }
  ]
}

fields 為可選:傳空陣列或省略時,僅建立空表。

單一建表

POST /api/v1/data/objects
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "app_id": "your-app-uuid",
  "name": "客戶管理",
  "api_slug": "customers"
}

新增欄位

POST /api/v1/data/objects/{obj_id}/fields
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "name": "狀態",
  "field_key": "status",
  "field_type": "text",
  "is_required": false,
  "sequence": 10
}

修改欄位

PATCH /api/v1/data/fields/{field_id}
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "name": "新名稱",
  "field_type": "number"
}

刪除資料表

DELETE /api/v1/data/objects/{obj_id}
Authorization: Bearer {JWT}

⚠️ Cascade 刪除:刪除資料表會同時刪除所有欄位定義和記錄資料。

欄位類型

field_type說明範例值
text文字"Hello"
number數字42, 3.14
date日期"2026-04-23"
relation關聯其他表的 record ID

api_slug 命名規則

  • 僅允許 小寫英文字母數字底線
  • 正規表達式:^[a-z0-9]([a-z0-9_]*[a-z0-9])?$
  • 範例:orders, customer_info, product_v2
  • ❌ 不合法:Orders(大寫)、my-table(連字號)、_start(底線開頭)

數量限制

限制
每個 App 最多資料表數20
每張表最多欄位數50

超過限制時回傳 409 Conflict

AI Agent 完整工作流範例

import httpx

BASE = "https://ai-go.app/api/v1"

# Step 1: 登入取得 Token
resp = httpx.post(f"{BASE}/auth/login", json={
    "email": "developer@example.com",
    "password": "your_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}

# Step 2: Batch 建表 + 欄位
resp = httpx.post(f"{BASE}/data/objects/batch", headers=headers, json={
    "app_id": "your-app-uuid",
    "name": "訂單管理",
    "api_slug": "orders",
    "fields": [
        {"name": "客戶名", "field_key": "customer", "field_type": "text", "is_required": True, "sequence": 1},
        {"name": "金額", "field_key": "amount", "field_type": "number", "sequence": 2},
    ]
})
obj_id = resp.json()["id"]

# Step 3: 寫入記錄
resp = httpx.post(f"{BASE}/data/objects/{obj_id}/records", headers=headers, json={
    "data": {"customer": "台積電", "amount": 500000}
})

# Step 4: 查詢記錄
resp = httpx.get(f"{BASE}/data/objects/{obj_id}/records", headers=headers)
print(resp.json())

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

💡 適用場景:當多個 Custom App 共用相同的 SaaS 標準表(如 product_templatessale_orderscustomers),如何防止資料互相污染。

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

核心策略:app_domain 標籤

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

實作步驟

  1. 注入標籤(Insert): 在 SDK db.ts 或 Server-Side Action 執行 insert 時,強制將 custom_data.app_domain 寫入。

    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 欄位的過濾條件。

    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 共表 的選擇

  • 使用自訂表 (Custom Data):適用於該業務用戶專屬、與核心金流/產品無交集的高度客製化欄位(如:客戶滿意度問卷、排班表)。
  • 共用標準核心表 (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
取得檔案 URLGET/api/v1/ext/storage/url?path={path}
刪除檔案DELETE/api/v1/ext/storage/file?path={path}
列出檔案GET/api/v1/ext/storage/list?folder={folder}

上傳檔案

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

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

回應

{
  "path": "tenant-id/app-id/receipts/invoice.pdf",
  "bucket": "files",
  "size": 102400,
  "mime_type": "application/pdf"
}

取得 Signed URL

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

回應

{
  "url": "https://xxx.supabase.co/storage/v1/object/sign/files/...",
  "expires_in": 3600
}

列出檔案

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

回應

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

刪除檔案

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

限制與安全

限制
單檔大小上限100 MB
儲存 Bucketfiles(共用,路徑隔離)
路徑格式{tenant_id}/{app_id}/{folder}/{filename}
跨 App 存取❌ 403 Forbidden

在前端使用

// 上傳檔案
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 沙箱的除錯成本將高於本地開發。

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

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

17.1 獨立登入頁 URL

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

https://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 資訊(不需認證)

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

回應

{
  "name": "後廚訂單管理",
  "slug": "c7c7a37d2ff0",
  "subdomain": null,
  "tenant_logo_url": "https://..."
}

若 App 不存在或未發布,回傳 404

權限檢查(需認證)

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

回應

{
  "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 登入頁完成註冊,無需進入主站:

POST /api/v1/invitations
Authorization: Bearer {JWT}
Content-Type: application/json

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

回應

{
  "token": "U1HjLnpLHgAM8hZE...",
  "chat_invite_link": "https://ai-go.app/app-login/{slug}?token=U1HjLnpLHgAM8hZE..."
}

關鍵:當 redirect_url/app-login/ 開頭時,系統會自動產生 App 專用邀請連結,受邀者直接在 App 登入頁完成註冊。

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 的登出為 純前端操作,不需呼叫後端端點。Custom App 開發者可在自己的應用 UI 中加入登出按鈕,呼叫 Supabase SDK:

import { supabase } from './api';  // Runtime 內建的 Supabase 實例

// 登出並導回 App 登入頁
async function handleLogout() {
  await supabase.auth.signOut();
  window.location.href = `/app-login/${APP_SLUG}`;
}

說明:Internal App 共用主站的 Supabase Auth,signOut() 會清除 localStorage 中的 session 並撤銷 refresh token。JWT 無狀態,後端不需額外處理。登出後使用者可在 /app-login/{slug} 重新登入。

注意:此登出會同時影響主站 Dashboard 的 session。若需要「僅登出 App 但保留主站」的行為,可改為手動清除 App 專屬的 localStorage key,但一般場景不需要此區分。

17.9 AI Agent 整合範例

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

import httpx

BASE = "https://ai-go.app/api/v1"

# 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 登入頁)
resp = httpx.post(f"{BASE}/invitations", 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://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_accesstrue啟用匿名存取
access_mode"external""self_built"僅限外部模式

透過 Builder API 設定:

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

{
  "allow_anonymous_access": true
}

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

層級二:Custom Data 資料表

每張 Custom Data 表各自控制是否對匿名 API 開放:

PATCH /api/v1/data/objects/{obj_id}
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "is_public_readable": true
}

在 Builder UI → 資料管理 → 每個資料表右側的「公開讀取」開關。

層級三:SaaS 引用資料表(AppDataReference)

若 App 引用了系統 SaaS 表(如 customersproducts 等),需在 Reference 上設定:

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 識別目標應用。

Custom Data 匿名唯讀

端點方法說明
/api/v1/pub/data/{slug}/objectsGET列出公開資料表(含欄位定義)
/api/v1/pub/data/{slug}/objects/{obj}/recordsGET列出指定資料表的記錄

範例:列出公開資料表

GET /api/v1/pub/data/aeb47f756cef/objects

回應

[
  {
    "id": "uuid",
    "name": "場館",
    "api_slug": "venues",
    "is_public_readable": true,
    "fields": [
      { "id": "uuid", "name": "名稱", "field_key": "name", "field_type": "text" },
      { "id": "uuid", "name": "地址", "field_key": "address", "field_type": "text" }
    ]
  }
]

範例:查詢記錄

GET /api/v1/pub/data/aeb47f756cef/objects/venues/records?limit=10&offset=0

{obj} 可以是 api_slug(如 venues)或 UUID。

SaaS 引用匿名唯讀

端點方法說明
/api/v1/pub/proxy/{slug}/{table}GET簡單查詢
/api/v1/pub/proxy/{slug}/{table}/queryPOST進階查詢(filters / search / sort)

範例:進階查詢

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/ 端點。

自動切換原理

// api.ts 內部邏輯(由 Runtime 自動生成,不需手動修改)
export async function listRecords(slug: string): Promise<any[]> {
  const token = (window as any).__APP_TOKEN__ || '';
  
  if (!token) {
    // 未登入 → 走公開 API(不需 Token)
    const res = await fetch(
      `${API_BASE}/pub/data/${APP_SLUG}/objects/${slug}/records?limit=100`
    );
    return res.json();
  }
  
  // 已登入 → 走標準認證 API
  const res = await fetch(`${API_BASE}/data/objects/${slug}/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__是否已認證falsetrue

在頁面中偵測登入狀態

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,提供以下全域方法:

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

登入頁範例

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 回應都會包含:

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

超過限額時

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

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

18.7 安全機制

防護項目機制
欄位白名單filterssearch_columnsselect_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 模式:

import httpx

BASE = "https://ai-go.app/api/v1"

# 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. 設定 Custom Data 表為公開可讀
resp = httpx.get(f"{BASE}/data/objects?app_id={APP_ID}", headers=headers)
for obj in resp.json():
    if obj["api_slug"] in ("venues", "products", "prices"):
        httpx.patch(f"{BASE}/data/objects/{obj['id']}", headers=headers, json={
            "is_public_readable": True
        })
        print(f"  ✓ {obj['api_slug']} → 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(Supabase Auth)完全獨立。

19.1 概述

比較Internal App(§17)External App(本章)
帳號系統主站 Supabase Auth獨立 custom_app_users
Token 類型Supabase 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://ai-go.app/externalAppRuntime/{slug}

頁面路由由 App 的 VFS 自行定義,通常為:

Hash 路由頁面說明
#/loginLoginPage.tsx登入頁
#/registerRegisterPage.tsx註冊頁
#/HomePage.tsx首頁(登入後)

⚠️ App 開發者需自行在 VFS 中建立 LoginPage.tsxRegisterPage.tsx。Runtime 提供 Auth SDK(window.__auth__)來呼叫後端 API。

19.3 Custom App Auth API

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

註冊

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

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

成功回應(201):

{
  "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"
  }
}
錯誤碼說明
409Email 已被註冊
422參數驗證失敗

登入

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

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

成功回應(200):與註冊回應格式相同。

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

取得當前使用者

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

刷新 Token

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

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

舊的 Refresh Token 使用後即撤銷(Token Rotation),回應中會包含新的 Refresh Token。

登出

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}

列出使用者

GET /api/v1/custom-app-auth/manage/{app_id}/users
Authorization: Bearer {platform_jwt}

回應

[
  {
    "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"
  }
]

停用使用者

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

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

回應

[
  { "provider": "google", "enabled": true },
  { "provider": "line", "enabled": true }
]

發起 OAuth 授權

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

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

OAuth 回調

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

成功後回傳 Token(與 login 端點格式相同)。

LINE LIFF Token 交換

適用於 LINE LIFF App 內嵌場景:

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__ 上注入以下方法:

// 登入
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__() 觸發平台統一登入頁:

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

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

登入成功後的路由恢復

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

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

Auth SDK 會自動更新 window.__APP_TOKEN__。登入成功後,所有後續的 API 呼叫(api.tsdb.ts)會自動帶入新的 Token,不需手動處理。

完整 LoginPage 範例

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 範例

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 即可:

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.xReact 核心
react-dom^18.xDOM 渲染
react-router-dom^6.xHash 路由
lucide-reactlatest圖示庫
react-hot-toastlatestToast 通知

⚠️ 這些模組在 esbuild 編譯時會被標記為 --external,不會打包進 bundle。Runtime 頁面會提供全域版本。

20.2 package.json 依賴宣告

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

{
  "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 中使用:

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

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

// 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 CSSesbuild 不執行 PostCSS 流程
不支援 Node.js 原生模組fspathcrypto 等無法在瀏覽器執行
不支援動態 importimport() 語法不支援,所有模組須為靜態引入
單檔大小上限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

延伸閱讀

  • AI GO 系統串接指南 — 第三方自建應用 API Key 整合
  • Custom App 支援 Internal(內部)、External(外部)和 Public(匿名檢視)三種模式