diff --git a/.gitignore b/.gitignore
index ac8f3b0..7b41291 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,5 +6,6 @@ dist/
.DS_Store
coverage/
-# internal ops log — not for the public repo
+# internal ops / one-time setup notes — not for the public repo (hold owner-specific values)
docs/deploy-state.md
+docs/setup-checklist.md
diff --git a/README.md b/README.md
index a491045..420a654 100644
--- a/README.md
+++ b/README.md
@@ -104,8 +104,8 @@ Cron ─┘
### Admin Access model
-`admin.panspace.dev` is fully protected by Cloudflare Access. The SPA is served from Pages, while
-the admin API is the **same Worker** via a route on `admin.panspace.dev/api/*` (the Worker strips
+`admin.example.com` is fully protected by Cloudflare Access. The SPA is served from Pages, while
+the admin API is the **same Worker** via a route on `admin.example.com/api/*` (the Worker strips
`/api`). Because it's same-origin, the Access JWT (`Cf-Access-Jwt-Assertion`) reaches the Worker,
where `requireAccess` verifies `aud` / `iss` / `exp` and an email allow-list. Screenshots stream
through a same-origin protected endpoint, so `
` tags just work.
@@ -168,18 +168,15 @@ so DB tests seed real parents and use a distinct id-space (9001+).
> commands below are the quick reference once those resources exist.
```bash
-# 1. Apply D1 migrations
-wrangler d1 migrations apply chippot-db --remote
+# 1. Worker — applies D1 migrations, then deploys (carries the cron + admin.example.com/api route)
+pnpm --filter @chippot/worker deploy
-# 2. Worker (carries the cron trigger + the admin.panspace.dev/api route)
-cd packages/worker && wrangler deploy
-
-# 3. Frontends → Pages
+# 2. Frontends → Pages
cd packages/web && pnpm build && wrangler pages deploy dist --project-name chippot-web --branch main
cd packages/admin && pnpm build && wrangler pages deploy dist --project-name chippot-admin --branch main
-# 4. Register the guild slash commands (/繳費 · /發起繳費 · /綁定)
-DISCORD_GUILD_ID= pnpm --filter @chippot/worker register
+# 3. Register the guild slash commands (/繳費 · /發起繳費 · /綁定) — needs DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_GUILD_ID in packages/worker/.dev.vars
+pnpm --filter @chippot/worker register
```
Provision your own resources (D1, R2, an Access application) and fill in `wrangler.toml`
@@ -190,7 +187,7 @@ accordingly — `database_id`, the R2 bucket, `ACCESS_*`, and the Discord vars.
- **Secret** — `DISCORD_BOT_TOKEN` (`wrangler secret put`; locally in
`packages/worker/.dev.vars`, which is gitignored).
- **Vars** (`wrangler.toml`, non-secret) — `DISCORD_APPLICATION_ID`, `DISCORD_PUBLIC_KEY`,
- `WEB_ORIGIN`, `ADMIN_ORIGIN`, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, `ACCESS_ALLOWED_EMAILS`.
+ `WEB_ORIGIN`, `ADMIN_ORIGIN`, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`.
- **Workspace settings** (in D1, edited from the admin **Settings** page) — billing day, overdue
days, screenshot retention, Discord guild / channel ids, the admin allow-list
(`admin_discord_ids`), and the three editable notification templates.
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 9057b2e..814997d 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -97,8 +97,8 @@ Cron ─┘
### 後台 Access 模型
-`admin.panspace.dev` 整台主機受 Cloudflare Access 保護。SPA 在 Pages;後台 API 則是**同一個 Worker**,
-透過 `admin.panspace.dev/api/*` 路由(Worker 會去掉 `/api` 前綴)。因為同源,Access JWT
+`admin.example.com` 整台主機受 Cloudflare Access 保護。SPA 在 Pages;後台 API 則是**同一個 Worker**,
+透過 `admin.example.com/api/*` 路由(Worker 會去掉 `/api` 前綴)。因為同源,Access JWT
(`Cf-Access-Jwt-Assertion`)會到達 Worker,由 `requireAccess` 驗證 `aud` / `iss` / `exp` 與 email
白名單。截圖走同源的受保護端點,所以 `
` 直接可用。
@@ -158,18 +158,15 @@ pnpm --filter @chippot/admin build
> 下面的指令是「資源都建好之後」的快速參考。
```bash
-# 1. 套用 D1 migration
-wrangler d1 migrations apply chippot-db --remote
+# 1. Worker — 套用 D1 migrations 後部署(含 cron trigger 與 admin.example.com/api 路由)
+pnpm --filter @chippot/worker deploy
-# 2. Worker(含 cron trigger 與 admin.panspace.dev/api 路由)
-cd packages/worker && wrangler deploy
-
-# 3. 前端 → Pages
+# 2. 前端 → Pages
cd packages/web && pnpm build && wrangler pages deploy dist --project-name chippot-web --branch main
cd packages/admin && pnpm build && wrangler pages deploy dist --project-name chippot-admin --branch main
-# 4. 註冊 guild slash 指令(/繳費 · /發起繳費 · /綁定)
-DISCORD_GUILD_ID= pnpm --filter @chippot/worker register
+# 3. 註冊 guild slash 指令(/繳費 · /發起繳費 · /綁定)— 需在 packages/worker/.dev.vars 填入 DISCORD_BOT_TOKEN、DISCORD_APPLICATION_ID、DISCORD_GUILD_ID
+pnpm --filter @chippot/worker register
```
請自行建立資源(D1、R2、一個 Access application)並把對應值填進 `wrangler.toml`——
@@ -180,7 +177,7 @@ DISCORD_GUILD_ID= pnpm --filter @chippot/worker register
- **Secret** — `DISCORD_BOT_TOKEN`(`wrangler secret put`;本地放
`packages/worker/.dev.vars`,已 gitignore)。
- **Vars**(`wrangler.toml`,非機密)— `DISCORD_APPLICATION_ID`、`DISCORD_PUBLIC_KEY`、
- `WEB_ORIGIN`、`ADMIN_ORIGIN`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`、`ACCESS_ALLOWED_EMAILS`。
+ `WEB_ORIGIN`、`ADMIN_ORIGIN`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`。
- **Workspace 設定**(存在 D1,從後台「設定」頁編輯)— 結帳日、逾期天數、截圖保存月數、
Discord guild/頻道 id、可發起繳費的管理員白名單(`admin_discord_ids`),以及三種可自訂的通知模板。
- **Discord** — 把 app 的 Interactions Endpoint 設成 Worker 的 `/interactions`,再用上面的腳本註冊 guild 指令。
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index d8411ce..41c8999 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -2,6 +2,14 @@
這份文件帶你把 ChipPot 從零部署到**你自己的 Cloudflare 帳號**。照著做完,你會有一個跑在自己網域上的繳費代收/對帳系統。
+提供**兩條部署路徑**,按需選擇:
+
+| | 路徑一(主推)| 路徑二(CLI)|
+|---|---|---|
+| 適合 | 想快速 fork、不裝本機工具的使用者 | 喜歡全程 CLI、偏好指令控制的進階用戶 |
+| 工具鏈 | 零本機工具(GitHub 網頁編輯器 + Cloudflare 後台)| Node 20+、pnpm、wrangler CLI |
+| 更新方式 | GitHub「Sync fork」→ 自動重部署 | `git pull` + `pnpm --filter ... deploy` |
+
> 預估時間:30–60 分鐘(多數時間在等 Cloudflare/Discord 後台設定)。
---
@@ -47,8 +55,13 @@ web build 的 `VITE_API_BASE`、以及 Cloudflare Access application 的 domain
## 1. 前置需求
+所有路徑都需要:
+
- **Cloudflare 帳號**,且**至少一個網域已託管在 Cloudflare**(要綁子網域與 Worker route 都需要)。
- **Discord 帳號**,且你是某個 Discord 伺服器的管理員。
+
+僅**路徑二(CLI)**額外需要:
+
- 本機安裝 **Node 20+** 與 **[pnpm](https://pnpm.io)**。
- 安裝並登入 Wrangler:
```bash
@@ -56,42 +69,42 @@ web build 的 `VITE_API_BASE`、以及 Cloudflare Access application 的 domain
wrangler login # 互動式登入你的 Cloudflare 帳號
```
-取得程式碼並安裝相依:
-```bash
-git clone https://github.com/poterpan/ChipPot.git
-cd ChipPot
-pnpm install
-pnpm --filter @chippot/worker test # (選用)確認測試全綠
-```
+**路徑一(後台 + Git)無需本機安裝任何工具鏈**,但另需 **GitHub 帳號**(用於 fork repo 與網頁編輯器改設定)。
---
## 2. 建立 Cloudflare 資源(D1 / R2)
-```bash
-cd packages/worker
+### 路徑一(後台操作)
-# D1 資料庫 → 會回傳一個 database_id,待會要填進 wrangler.toml
+1. 進 [Cloudflare 後台](https://dash.cloudflare.com) → **Storage & Databases → D1**。
+2. 建立資料庫,名稱填 `chippot-db`。建立後頁面會顯示 **database_id**(一串 UUID)→ **記下來**,待會填 `wrangler.toml`。
+3. 進 **Storage & Databases → R2** → 建立 bucket,名稱填 `chippot-proofs`。
+
+### 路徑二(CLI)
+
+```bash
+# D1 資料庫 → 建立後輸出包含 database_id,記下來
wrangler d1 create chippot-db
# R2 bucket(存截圖,私有)
wrangler r2 bucket create chippot-proofs
```
-把 `wrangler d1 create` 輸出的 `database_id` 記下來。
-
-> Pages 兩個專案(`chippot-web`/`chippot-admin`)會在第 8 步第一次 `wrangler pages deploy` 時自動建立,這裡先不用動。
+> **兩條路徑都要記下 D1 `database_id`**——第 5 步填 `wrangler.toml` 時需要。
---
## 3. 建立 Discord 應用與 Bot
+(兩條路徑共用)
+
到 → **New Application**。
1. **General Information**:複製 **Application ID** → `DISCORD_APPLICATION_ID`,複製 **Public Key** → `DISCORD_PUBLIC_KEY`。
2. **Bot** 分頁:**Reset Token** 取得 **Bot Token** → `DISCORD_BOT_TOKEN`(這是機密,別外洩)。
3. 邀請 bot 進你的伺服器:用 OAuth2 URL Generator,scope 勾 `bot` 與 `applications.commands`,bot 權限至少給 **Send Messages**、**Mention Everyone**(要 tag 身分組)。
-4. **Interactions Endpoint URL** 先**留空**——要等第 7 步 worker 部署出網址後再回來填。
+4. **Interactions Endpoint URL** 先**留空**——要等第 6 步 worker 部署出網址後再回來填。
另外記下:你的**伺服器(guild)ID**、之後要當「繳費頻道」的**頻道 ID**、各方案對應的**身分組(role)ID**(開啟 Discord 開發者模式後右鍵複製)。這些之後在後台設定,不用現在填。
@@ -99,6 +112,8 @@ wrangler r2 bucket create chippot-proofs
## 4. 設定 Cloudflare Access(保護後台)
+(兩條路徑共用)
+
後台 `admin.<你的網域>` 整台主機放在 Cloudflare Access 後面(email 驗證)。
1. 進 **Cloudflare Zero Trust** 後台。第一次會請你取一個 **team name**(例如 `myclub`)→ 這就是 `ACCESS_TEAM_DOMAIN`(你的登入網域會是 `myclub.cloudflareaccess.com`)。
@@ -106,111 +121,221 @@ wrangler r2 bucket create chippot-proofs
- Application domain 填 `admin.<你的網域>`。
- 建立後,在該 application 的設定頁複製 **Application Audience (AUD) Tag** → `ACCESS_AUD`。
3. 設 **Policy**:Action = Allow,Include = Emails,填入**允許登入後台的 email**(你自己、其他管理員)。
- > ⚠️ 後台的管理員白名單**就是這條 Access policy**。Worker 端的 `ACCESS_ALLOWED_EMAILS` 目前是停用的(程式裡以 Cloudflare Access 為唯一來源),所以**加/移除後台管理員請改這條 policy**,改完即時生效、不用重新部署。
+ > ⚠️ 後台的管理員白名單**就是這條 Access policy**。加/移除後台管理員請改這條 policy,改完即時生效、不用重新部署。
---
-## 5. 修改 `packages/worker/wrangler.toml`
+## 5. 填寫設定(wrangler.toml 佔位值)
+
+`packages/worker/wrangler.toml` 預設是**佔位值**,需換成你自己的。
+
+### 路徑一(GitHub 網頁編輯器)
+
+1. Fork 這個 repo 到你的 GitHub 帳號。
+2. 在你的 fork 裡,點開 `packages/worker/wrangler.toml`,用 GitHub 的鉛筆圖示進入網頁編輯器,修改下表中的佔位值後 commit(直接推到 `main` 分支即可)。
+
+### 路徑二(本機直接改)
+
+`git clone` 後直接用文字編輯器修改 `packages/worker/wrangler.toml`。
-把 repo 內的值換成你自己的。對照表:
+**Owner 如果不想讓真實值進 git commit(公開 repo 適用)**:改完本機後,執行
+```bash
+git update-index --skip-worktree packages/worker/wrangler.toml
+```
+往後這個檔案的修改就不會出現在 `git status` / `git diff`,pull 上游也不會被覆寫。
+
+### 佔位值對照表
-| 欄位 | 預設(repo 內,需替換) | 換成 |
+| 欄位 | 預設佔位值 | 換成 |
|---|---|---|
-| `routes[].pattern` | `admin.panspace.dev/api/*` | `admin.<你的網域>/api/*` |
-| `routes[].zone_name` | `panspace.dev` | `<你的網域>`(Cloudflare 上的 zone) |
-| `[[d1_databases]].database_id` | 一串我們的 id | 第 2 步 `wrangler d1 create` 回傳的 id |
-| `[vars] DISCORD_APPLICATION_ID` | 我們的 | 你的(第 3 步) |
-| `[vars] DISCORD_PUBLIC_KEY` | 我們的 | 你的(第 3 步) |
-| `[vars] WEB_ORIGIN` | `https://pay.panspace.dev` | `https://pay.<你的網域>` |
-| `[vars] ADMIN_ORIGIN` | `https://admin.panspace.dev` | `https://admin.<你的網域>` |
-| `[vars] ACCESS_TEAM_DOMAIN` | `panspace` | 你的 team name(第 4 步) |
-| `[vars] ACCESS_AUD` | 我們的 | 你的 AUD(第 4 步) |
-| `[vars] ACCESS_ALLOWED_EMAILS` | 佔位 email | 可留著(目前未使用,見第 4 步說明) |
-
-`name`(worker 名稱)、`database_name`(`chippot-db`)、R2 `bucket_name`(`chippot-proofs`)、`crons`(`0 1 * * *` = 台北每天 09:00)可沿用,想改也行。
+| `routes[].pattern` | `admin.example.com/api/*` | `admin.<你的網域>/api/*` |
+| `routes[].zone_name` | `example.com` | `<你的網域>`(Cloudflare 上的 zone) |
+| `[[d1_databases]].database_id` | `your-d1-database-id` | 第 2 步取得的 D1 database_id |
+| `[vars] DISCORD_APPLICATION_ID` | `your-discord-application-id` | 你的(第 3 步) |
+| `[vars] DISCORD_PUBLIC_KEY` | `your-discord-public-key` | 你的(第 3 步) |
+| `[vars] WEB_ORIGIN` | `https://pay.example.com` | `https://pay.<你的網域>` |
+| `[vars] ADMIN_ORIGIN` | `https://admin.example.com` | `https://admin.<你的網域>` |
+| `[vars] ACCESS_TEAM_DOMAIN` | `your-team-name` | 你的 team name(第 4 步) |
+| `[vars] ACCESS_AUD` | `your-access-aud` | 你的 AUD(第 4 步) |
+
+`name`(worker 名稱 `chippot`)、`database_name`(`chippot-db`)、R2 `bucket_name`(`chippot-proofs`)、`crons`(`0 1 * * *` = 台北每天 09:00)可沿用,想改也行。
+
+> `wrangler.toml` 是**所有 Worker runtime 設定的單一真相來源**。Workers Builds / Pages 後台的「Environment variables」欄位是 build 期變數,**不會**成為 Worker 的 runtime `env`,不要把上表的值填在那裡(`VITE_API_BASE` 是例外,見第 6 步)。
---
-## 6. 設定 Secret 與 `.dev.vars`
+## 6. 部署
-```bash
-cd packages/worker
+### 路徑一(主推):Cloudflare 後台 + Git,零本機工具
-# 機密:Bot Token(存進 Cloudflare,不進 git)
-wrangler secret put DISCORD_BOT_TOKEN # 貼上第 3 步的 token
-```
+#### 6-1. 部署 Worker(Workers Builds)
+
+1. 進 Cloudflare 後台 → **Workers & Pages → Create → Workers Builds(Connect to Git)**。
+2. 選擇你的 fork,branch 選 `main`。
+3. **Build configuration** 填:
+ - Root directory:(留空,repo 根)
+ - Build command:`pnpm install`
+ - Deploy command:`pnpm --filter @chippot/worker deploy`
+4. 儲存並觸發第一次 deploy。
+
+ > **deploy 腳本做兩件事**:先用 `wrangler d1 migrations apply chippot-db --remote` 自動套用所有未套用的 migration(idempotent,冪等),再執行 `wrangler deploy`。首次部署會套入 0001–0005 的初始 schema 與示範 seed。
+ >
+ > ⚠️ **破壞性 migration**(例如刪欄位、重命名):建議在低流量時段先在 Cloudflare 後台 D1 console 或 CLI 手動套 migration,確認無誤再推 code 觸發 deploy——避免「新 schema 配舊 worker 程式」的短暫窗口期。
+
+5. 部署成功後,在 Workers Builds 頁或 Worker 的「Triggers」分頁找到 worker 網址,格式為 `https://chippot.<你的帳號子網域>.workers.dev`。**記下這個網址**,後面設 web 的 `VITE_API_BASE` 要用。
+
+6. 設定 **Worker runtime secret**:
+ 進 Worker → **Settings → Variables and Secrets** → 新增 Secret:
+ - Name:`DISCORD_BOT_TOKEN`
+ - Value:第 3 步取得的 bot token
+
+ > Secret 跨 deploy 保留,不會被後續部署覆寫。
+
+7. 回到 Discord 開發者後台,把 **Interactions Endpoint URL** 設成:`https://<你的 worker 網址>/interactions`(按 Save,Discord 會即時驗證簽章)。
+
+#### 6-2. 部署 web(繳費上傳頁,Pages)
+
+> ⚠️ **先完成 6-1 再做這步**——web 需要 worker 網址才能設 `VITE_API_BASE`。若未設,繳費頁在瀏覽器載入時會立刻報錯(白屏/console error),這是刻意的 fail-loud 設計。
+
+1. 進 Cloudflare 後台 → **Workers & Pages → Create → Pages(Connect to Git)**。
+2. 選你的 fork,branch 選 `main`,Project name 填 `chippot-web`。
+3. **Build configuration**:
+ - Root directory:(留空)
+ - Build command:`pnpm --filter @chippot/web build`
+ - Build output directory:`packages/web/dist`
+4. **Environment variables(Build variables,僅 build 期)**:
+ - 名稱:`VITE_API_BASE` 值:`https://chippot.<你的帳號子網域>.workers.dev`(6-1 第 5 步記下的網址)
+5. 儲存並部署。
+
+#### 6-3. 部署 admin(後台 SPA,Pages)
+
+1. 進 Cloudflare 後台 → **Workers & Pages → Create → Pages(Connect to Git)**。
+2. 選你的 fork,branch 選 `main`,Project name 填 `chippot-admin`。
+3. **Build configuration**:
+ - Root directory:(留空)
+ - Build command:`pnpm --filter @chippot/admin build`
+ - Build output directory:`packages/admin/dist`
+4. 無需設 build 變數(admin 與 worker 同源,用相對路徑 `/api/admin/...` 即可)。
+5. 儲存並部署。
+
+#### 6-4. 綁自訂網域
+
+**web(繳費頁):**
+
+進 `chippot-web` Pages → **Custom domains → Set up a custom domain** → 填 `pay.<你的網域>`。
+
+**admin(後台):同源兩步驟,順序很重要**
+
+admin 頁面需要「Pages SPA」與「Worker API」共存在同一個 hostname 下:
+
+1. 先進 `chippot-admin` Pages → **Custom domains** → 綁 `admin.<你的網域>`(讓 Pages 取得並簽好 SSL 憑證)。
+2. 再到 Worker → **Settings → Triggers → Routes** → 新增路由 `admin.<你的網域>/api/*`,指向這個 worker。
+
+> **為什麼這個順序?** Cloudflare 的邊緣會讓 worker route 優先於 Pages,若先加 route 再加 Pages domain,憑證簽發可能卡住。建議先讓 Pages 拿到 custom domain(含憑證),再讓 worker 接管 `/api/*`。
+>
+> 效果:`admin.<你的網域>` 的 `/api/*` 路徑由 Worker 處理(Access JWT 因同源而帶得到),其餘路徑由 Pages SPA 處理。後台 SPA 用相對路徑 `/api/admin/...` 就能打到 worker,無需另外設 CORS。
+
+#### 6-5. 之後更新
+
+在 GitHub 你的 fork 頁面按 **Sync fork**,Cloudflare Workers Builds 與兩個 Pages 會自動偵測 push、重新 build 並部署(含自動套 migration)。
+
+> 破壞性 migration 的注意事項見第 11 節。
+
+---
+
+### 路徑二(保留):純 CLI wrangler
+
+#### 6-1. 取得程式碼並安裝相依
-再建一個 `packages/worker/.dev.vars`(已被 gitignore)給「註冊 slash 指令」腳本與本地開發用。直接從範本複製再填值:
```bash
-cd packages/worker
-cp .dev.vars.example .dev.vars # 然後填入真實值
-```
-內容(鍵見 `.dev.vars.example`):
-```
-CLOUDFLARE_API_TOKEN=你的-cloudflare-api-token
-DISCORD_BOT_TOKEN=你的-bot-token
-DISCORD_APPLICATION_ID=你的-application-id
-DISCORD_GUILD_ID=你的-guild-id
+git clone https://github.com/<你的帳號>/ChipPot.git # 或原始 repo
+cd ChipPot
+pnpm install
+pnpm --filter @chippot/worker test # (選用)確認測試全綠
```
-> 註:跑測試(`pnpm test`)**不需要**這個檔——測試會自帶假 token,乾淨 clone/CI 沒有 `.dev.vars` 也能全綠。
----
+#### 6-2. 部署 Worker(含自動套 migration)
-## 7. 套用 Migration 並部署 Worker
+確認已完成第 5 步(`wrangler.toml` 填好真實值),然後:
```bash
-cd packages/worker
+pnpm --filter @chippot/worker deploy
+```
-# 套用資料庫 schema(0001…0005)到遠端 D1
-wrangler d1 migrations apply chippot-db --remote
+此 deploy 腳本等同於:
+```bash
+wrangler d1 migrations apply chippot-db --remote && wrangler deploy
+```
-# 部署 worker(含 cron 與 admin.<你的網域>/api/* route)
-wrangler deploy
+> migration 冪等,只套未套過的版本。首次會建立完整 schema 與示範 seed。破壞性 migration 請見路徑一 6-1 的注意事項。
+
+記下輸出的 worker 網址(`https://chippot.<你的帳號子網域>.workers.dev`)。
+
+#### 6-3. 設定 Worker runtime secret
+
+```bash
+wrangler secret put DISCORD_BOT_TOKEN # 貼上第 3 步的 token
```
-- 記下輸出的 worker 網址,例如 `https://chippot.<你的帳號子網域>.workers.dev` → 第 8 步的 `VITE_API_BASE` 要用。
-- 看到 route 相關的權限警告(`Authentication error code 10000`)通常**非致命**:worker 版本已上線,只是 route 重新宣告需要該 zone 的 Workers Routes 權限。若 route 沒生效,到 Cloudflare 後台手動加一條 Worker route `admin.<你的網域>/api/*` 指向這個 worker 即可。
+#### 6-4. 設定 Discord Interactions Endpoint URL
-**回到 Discord 開發者後台**,把 **Interactions Endpoint URL** 設成:`https://<你的 worker 網址>/interactions`(按 Save,Discord 會即時驗證簽章)。
+回到 Discord 開發者後台,把 **Interactions Endpoint URL** 設成:
+`https://<你的 worker 網址>/interactions`
-> migration 會帶入一份示範 seed:一個 workspace「社團 AI 訂閱」、3 個示範方案、2 個支付渠道。你可以在後台直接改成自己的。
+#### 6-5. 部署 web(繳費上傳頁)
----
+```bash
+# VITE_API_BASE 一定要指向「你的」worker,否則繳費頁載入即報錯
+VITE_API_BASE=https://<你的 worker 網址> pnpm --filter @chippot/web build
+wrangler pages deploy packages/web/dist --project-name chippot-web --branch main
+```
-## 8. 部署前端(Cloudflare Pages)
+#### 6-6. 部署 admin(後台 SPA)
```bash
-# 繳費上傳頁(公開)。VITE_API_BASE 一定要指向「你的」worker,否則會打到別人的後端!
-cd packages/web
-VITE_API_BASE=https://<你的 worker 網址> pnpm build
-wrangler pages deploy dist --project-name chippot-web --branch main
-
-# 後台 SPA(同源呼叫 /api,不需設 API base)
-cd ../admin
-pnpm build
-wrangler pages deploy dist --project-name chippot-admin --branch main
+pnpm --filter @chippot/admin build
+wrangler pages deploy packages/admin/dist --project-name chippot-admin --branch main
```
-接著在 **Cloudflare 後台 → 各 Pages 專案 → Custom domains** 綁網域:
-- `chippot-web` → `pay.<你的網域>`
-- `chippot-admin` → `admin.<你的網域>`
+#### 6-7. 綁自訂網域
-> 後台是「同一個 host 上 Pages + Worker 並存」:`admin.<你的網域>` 由 Pages 提供 SPA,而 `admin.<你的網域>/api/*` 由 Worker 接管(route 優先於 Pages)。所以後台 SPA 用相對路徑 `/api/admin/...` 就能打到 worker,Access JWT 也因同源而帶得到。
+在 Cloudflare 後台:
+- `chippot-web` Pages → Custom domains → 綁 `pay.<你的網域>`
+- `chippot-admin` Pages → Custom domains → 綁 `admin.<你的網域>`(先綁)
+- Worker → Settings → Triggers → Routes → 新增 `admin.<你的網域>/api/*`(後加,確保憑證先就緒)
---
-## 9. 註冊 Discord Slash 指令
+## 7. 註冊 Discord Slash 指令
+
+(兩條路徑)
+
+### 路徑一(後台按鈕)
+
+登入後台 → **設定** 頁 → 點「**註冊 / 更新 Discord slash 指令**」按鈕。
+
+> 需先完成第 8 步、在**設定**頁填入 **Discord Guild ID** 並儲存,按鈕才會成功(否則會回 `discord_guild_id is not set`)。
+
+### 路徑二(CLI)
+
+此腳本會讀取 `packages/worker/.dev.vars`(從 `.dev.vars.example` 複製並填入),需要 `DISCORD_BOT_TOKEN`、`DISCORD_APPLICATION_ID`、`DISCORD_GUILD_ID` 三個值:
+
+```bash
+cp packages/worker/.dev.vars.example packages/worker/.dev.vars # 首次:填入 BOT_TOKEN / APPLICATION_ID / GUILD_ID
+pnpm --filter @chippot/worker register
+```
+(或直接內聯三個變數):
```bash
-cd packages/worker
-DISCORD_GUILD_ID=<你的伺服器ID> pnpm --filter @chippot/worker register
+DISCORD_BOT_TOKEN=... DISCORD_APPLICATION_ID=... DISCORD_GUILD_ID=... pnpm --filter @chippot/worker register
```
-註冊 `/繳費`、`/發起繳費`、`/綁定` 三個 guild 指令(讀 `.dev.vars` 的 token/app id)。
+> 兩者都對相同的 Discord API 端點(`PUT .../guilds/{GUILD_ID}/commands`)做 idempotent 覆寫,效果等同。
+> 此步驟會註冊 `/繳費`、`/發起繳費`、`/綁定` 三個 guild 指令。
---
-## 10. 首次設定(登入後台)
+## 8. 首次設定(登入後台)
打開 `https://admin.<你的網域>`,會先過 Cloudflare Access(輸入你在 policy 允許的 email、收驗證碼)。進入後台後:
@@ -228,26 +353,51 @@ DISCORD_GUILD_ID=<你的伺服器ID> pnpm --filter @chippot/worker register
---
-## 11. 常見問題
+## 9. 變數 / Secret 分層速查表
+
+| 名稱 | 類型 | 設定位置(路徑一 後台 / 路徑二 CLI) | 跨 deploy |
+|---|---|---|---|
+| `DISCORD_BOT_TOKEN` | Worker runtime secret | 後台 Worker → Settings → Variables and Secrets / `wrangler secret put` | 保留 |
+| `DISCORD_APPLICATION_ID`、`DISCORD_PUBLIC_KEY`、`WEB_ORIGIN`、`ADMIN_ORIGIN`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` | Worker runtime var | `wrangler.toml [vars]`(兩路徑相同) | 由 toml 覆寫 |
+| `VITE_API_BASE` | Pages build 變數(非 secret) | web 的 Pages 專案 → 設定 → 變數(路徑一)/ build 時環境變數(路徑二) | build 當下(Pages UI 設定後對後續 build 持續生效) |
+
+> **重要**:Workers Builds / Pages 後台的「Environment variables」欄位是 **build 期**變數,**不會**注入 Worker 的 runtime `env`。Worker 的 runtime vars 只來自 `wrangler.toml [vars]`;secrets 只來自 `wrangler secret put` 或後台 Variables and Secrets 頁面。
+
+---
+
+## 10. 常見問題
- **後台顯示「未授權,請重新登入」**:多半是 `ACCESS_AUD` / `ACCESS_TEAM_DOMAIN` 與你的 Access application 對不上,或登入的 email 不在 Access policy 內。檢查第 4、5 步。
-- **繳費上傳頁送出失敗 / CORS**:`VITE_API_BASE`(build 時)要指向你的 worker,且 worker 的 `WEB_ORIGIN` 要等於 `https://pay.<你的網域>`(第 5、8 步)。
-- **Discord 指令沒出現**:確認第 9 步用對 guild id,且 bot 已在該伺服器、有 `applications.commands` scope。
-- **`/interactions` 驗證失敗**:`DISCORD_PUBLIC_KEY` 要跟你的 application 一致。
-- **這個月沒自動開帳**:cron 只在「結帳日當天」開該月帳單(台北 09:00)。想立刻開就用「發起繳費」。
-- **加/移除後台管理員**:改 Cloudflare Access 的 policy(不是 wrangler.toml)。
+- **繳費上傳頁載入即報錯(白屏 / console error)**:`VITE_API_BASE` 未設定,繳費頁會立刻 fail-loud——這是刻意設計,避免打到別人的後端。確認 web 的 Pages build 變數 `VITE_API_BASE` 已正確設為你的 worker 網址。
+- **繳費上傳頁送出失敗 / CORS**:`VITE_API_BASE`(build 期)要指向你的 worker,且 worker 的 `WEB_ORIGIN` 要等於 `https://pay.<你的網域>`(第 5、6 步)。
+- **Discord Slash 指令沒出現**:確認第 7 步用對 guild id、bot 已在伺服器且有 `applications.commands` scope;可再按一次設定頁的「註冊 / 更新」按鈕,或重跑 CLI `register`。
+- **`/interactions` 驗證失敗**:`DISCORD_PUBLIC_KEY` 要跟你的 application 一致(`wrangler.toml [vars]`)。
+- **這個月沒自動開帳**:cron 只在「結帳日當天」開該月帳單(台北 09:00 = 01:00 UTC)。想立刻開就用「發起繳費」。
+- **加/移除後台管理員**:改 Cloudflare Access 的 policy(不是 `wrangler.toml`),改完即時生效。
+- **路徑一 worker route 添加出現 `Authentication error 10000`**:通常是 Workers Builds 使用的 API token 缺 Workers Routes 權限(非致命,worker 版本已上線)。若 route 沒生效,到 Cloudflare 後台手動在 Worker → Settings → Triggers → Routes 新增即可。
---
-## 12. 之後更新版本
+## 11. 之後更新版本
+
+### 路徑一(自動)
+
+在 GitHub 你的 fork 頁面按 **Sync fork** → Cloudflare 自動偵測 push → Workers Builds 重部署 worker(含自動套 migration)→ 兩個 Pages 重 build。全程無需本機操作。
+
+> 若新版 migration 是**破壞性**的,建議先在後台 D1 console 手動套完、確認無誤,再按 Sync fork 觸發程式更新。
+
+### 路徑二(CLI)
```bash
git pull
pnpm install
-cd packages/worker && wrangler d1 migrations apply chippot-db --remote # 若有新 migration
-wrangler deploy
-cd ../web && VITE_API_BASE=https://<你的 worker 網址> pnpm build && wrangler pages deploy dist --project-name chippot-web --branch main
-cd ../admin && pnpm build && wrangler pages deploy dist --project-name chippot-admin --branch main
+pnpm --filter @chippot/worker deploy # 含自動套 migration
+
+VITE_API_BASE=https://<你的 worker 網址> pnpm --filter @chippot/web build
+wrangler pages deploy packages/web/dist --project-name chippot-web --branch main
+
+pnpm --filter @chippot/admin build
+wrangler pages deploy packages/admin/dist --project-name chippot-admin --branch main
```
-有需要可重新 `register` slash 指令(指令有變動時才需要)。
+指令有新增變動時,重新執行第 7 步的 `register` 更新 guild 指令。
diff --git a/docs/setup-checklist.md b/docs/setup-checklist.md
deleted file mode 100644
index fb84ef3..0000000
--- a/docs/setup-checklist.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# ChipPot — Inputs I need from you (fill in, then I wire it up)
-
-You're resting; I'll keep building everything that doesn't need these. The items below are
-the things only you can provide. **Secrets** (marked 🔒) should go into a gitignored file
-`packages/worker/.dev.vars` (one `KEY=value` per line) — NOT committed, NOT pasted in chat
-if you can avoid it. Non-secret IDs are fine to paste in chat.
-
-Legend: ⛔ = blocks finishing that phase · 🟡 = optional / has a sensible default.
-
----
-
-## A. Hosting URLs — choose one (🟡, default needs nothing from you)
-
-I can ship entirely on **free Cloudflare URLs** (no domain/DNS needed):
-- Worker API: `chippot..workers.dev`
-- Upload page: `chippot-web.pages.dev`
-- Admin UI: `chippot-admin.pages.dev` (still protected by Access)
-
-- [x] **Use free *.pages.dev / *.workers.dev** (default — leave blank), **OR**
-- [ ] **Use my custom domain:** `__________________` (apex/zone, e.g. `club.example.org`)
- - desired upload subdomain: `pay.____________` · admin subdomain: `admin.____________`
-
-## B. Cloudflare API token (🟡 strongly recommended for unattended Access/Pages/DNS)
-
-wrangler is already logged in (works for Workers/D1/R2). But configuring **Access** and
-possibly **Pages/DNS** unattended may exceed the interactive login's scopes. To let me do
-it all without prompts, create a token and put it in `.dev.vars`:
-
-- [x] 🔒 `CLOUDFLARE_API_TOKEN=...` — scopes: Account → *Workers Scripts:Edit, D1:Edit,
- Workers R2 Storage:Edit, Cloudflare Pages:Edit, Access: Apps and Policies:Edit,
- Account Settings:Read*; Zone → *DNS:Edit, Zone:Read* (only if using a custom domain).
-- (If you skip this, I'll use the current login and tell you exactly what it couldn't do —
- likely the Access app, which you'd then click-create from a recipe I'll provide.)
-
-## C. Cloudflare Access (admin UI protection) — ⛔ for Phase 6
-
-- [x] Zero Trust is enabled on the account? (free plan is fine) — yes / no: `yes free`
-- [x] Team/org domain: `panspace.cloudflareaccess.com`
-- [x] Admin emails allowed into the admin UI (owner + 網管):
- 1. `owner@example.com` (owner)
- 2. `__________________` (網管)
-
-## D. Discord — ⛔ for Phase 4 (the big one)
-
-From the Discord Developer Portal (your existing app) + your test server:
-
-- [x] Application ID (public): `1510355256498978917`
-- [x] Public Key (public): `f322b974d23880e58e830ed8ac9b587ee48d1beb16887efb0bad6617b914e2de`
-- [x] 🔒 Bot Token → `.dev.vars` as `DISCORD_BOT_TOKEN=...`
-- [x] Test Guild (server) ID: `1305872150015639623`
-- [x] #ai-訂閱 (billing) channel ID — where the persistent 「繳費」 button message lives: `1510368202541236335`
-- [ ] Per-plan role IDs (for tagging 身分組 in 開繳 notifications):
- - ChatGPT role id: `__________________`
- - Claude Standard role id: `__________________`
- - Claude Premium role id: `__________________`
- (If these roles don't exist yet, create them — or tell me to create them via API.)
-- [x] Bot is invited to the test guild with scopes `bot` + `applications.commands` and
- permissions: Send Messages, Embed Links, Read Message History, Mention Roles.
- (If not, I'll generate an invite URL from the Application ID for you to click once.)
-
-## E. Test data — ⛔ to exercise the end-to-end flow
-
-So I can seed a realistic subscription and test button → upload → admin verify:
-
-- [ ] Your Discord user ID (to act as a test member): `__________________`
-- [ ] display name: `____________` · email (optional): `____________`
-- [ ] which plan(s) you subscribe to for testing: `ChatGPT / Claude Standard / Claude Premium`
-- [ ] subscription start_date (YYYY-MM-DD): `____________`
-- (Add more test members the same way if you want multi-subscription routing tested.)
-
-## F. Confirmations (🟡 defaults already applied — change only if wrong)
-
-- [ ] Resource names `chippot-db`, `chippot-proofs` — OK? (already created)
-- [ ] billing_day = **5**, overdue_days = **3**, proof_retention_months = **24** — OK?
-- [ ] Cron time 01:00 UTC = **09:00 Asia/Taipei** daily — OK?
-
-## G. Admin-UI auth architecture (⛔ decision needed before Phase 6)
-
-Cloudflare Access protects a **hostname**. With no custom domain, the admin SPA
-(`*.pages.dev`) and the worker API (`*.workers.dev`) are **different origins**, so the
-Access session won't flow between them. The worker's Access JWT verification is built and
-fails closed, but something must hand it a valid token. Pick one (B is my recommendation
-for the no-custom-domain path):
-
-- [ ] **A. Use a custom domain after all** — `admin.` for the SPA and
- `admin./api/*` to the worker. One Access app covers both; `
` proofs just
- work. Cleanest, but needs the domain you opted to skip.
-- [ ] **B. Pages + Access, API proxied through the same origin (Recommended)** — admin SPA
- on `admin.pages.dev` (Pages-native Access). Its `/api/*` calls hit a **Pages Function
- on the same origin** holding the D1/R2 bindings (I'd run the admin handlers there).
- Same-origin ⇒ Access header present ⇒ no cross-domain issue.
-- [ ] **C. Service-token / shared-secret** SPA↔worker — simplest, weaker; not recommended
- for a payments admin.
-
-Only affects the **admin** UI (Phase 6). Public upload + Discord flows are unaffected.
-
----
-
-### How to hand it back
-1. Fill the blanks above (edit this file or paste answers in chat).
-2. Put 🔒 secrets in `packages/worker/.dev.vars` (I'll `wrangler secret put` them to prod).
-3. I take it from there: deploy, register the Discord command + interaction endpoint,
- configure Access, and run a live end-to-end smoke test.
diff --git a/docs/superpowers/plans/2026-06-07-fork-friendly-deploy.md b/docs/superpowers/plans/2026-06-07-fork-friendly-deploy.md
new file mode 100644
index 0000000..a128a3c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-07-fork-friendly-deploy.md
@@ -0,0 +1,711 @@
+# Fork 友善化部署 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 讓 fork 者能用「本機零工具鏈」的 Cloudflare 後台 + Git 流程部署 ChipPot,同時 100% 保留純 CLI `wrangler deploy`,並清除 repo 內所有 owner 正式值。
+
+**Architecture:** `wrangler.toml` 維持 runtime 設定的單一真相來源(改佔位值,owner 用 skip-worktree 保留本機真值);移除前端兩處硬編 owner 網域;slash 指令註冊改由 Access 保護的後台按鈕觸發(沿用既有 `registerGuildCommands` helper);worker 加 `deploy` script 把 migration 併進部署;DEPLOY.md 重寫為雙路徑。
+
+**Tech Stack:** Cloudflare Workers + D1 + R2 + Pages,pnpm monorepo,TypeScript,Vitest(`@cloudflare/vitest-pool-workers`),React SPA(Vite)。
+
+**Spec:** `docs/superpowers/specs/2026-06-07-fork-friendly-deploy-design.md`
+
+**前置:** 本計畫的分支為 `deploy/fork-friendly-deploy`(已 off 最新 main)。每個 Task 結束 commit。全程基準:`pnpm -r typecheck`、`pnpm --filter @chippot/worker test`、`pnpm -r build` 須維持綠燈;worker 測試在「無 `.dev.vars`」下也須全綠(CI 條件)。
+
+---
+
+## File Structure
+
+| 檔案 | 責任 | Task |
+|---|---|---|
+| `packages/worker/wrangler.toml` | runtime 設定來源 → 佔位值 | 1 |
+| `packages/worker/src/index.ts` | 註解去 owner 網域 | 1 |
+| `packages/worker/test/index.test.ts` | 測試標題去 owner 網域 | 1 |
+| `packages/worker/src/routes/admin.ts` | upload-link 回傳 `url`;新增 register-commands 路由 | 2, 4 |
+| `packages/worker/test/routes/admin.test.ts` | upload-link `url`、register-commands 測試 | 2, 4 |
+| `packages/admin/src/api.ts` | `uploadLink` 型別加 `url`;新增 `registerCommands` | 2, 4 |
+| `packages/admin/src/views/Payments.tsx` | 改用後端 `url`,去硬編 | 2 |
+| `packages/admin/src/views/Settings.tsx` | 新增「註冊 slash 指令」按鈕 | 4 |
+| `packages/web/src/api.ts` | 移除 owner URL fallback,改 fail-loud | 3 |
+| `packages/worker/package.json` | 新增 `deploy` script(migrations && deploy) | 5 |
+| `docs/DEPLOY.md` | 重寫雙路徑 | 6 |
+
+---
+
+## Task 1: `wrangler.toml` 佔位值 + owner 值清除
+
+**Files:**
+- Modify: `packages/worker/wrangler.toml`
+- Modify: `packages/worker/src/index.ts:31`(註解)
+- Modify: `packages/worker/test/index.test.ts:21`(測試標題)
+
+- [ ] **Step 1: 改 `wrangler.toml` 為佔位值**
+
+把 `packages/worker/wrangler.toml` 全文改成(移除 `ACCESS_ALLOWED_EMAILS`、route/vars/database_id 改佔位、加指引註解):
+
+```toml
+name = "chippot"
+main = "src/index.ts"
+compatibility_date = "2025-11-01"
+compatibility_flags = ["nodejs_compat"]
+workers_dev = true
+
+# 後台 API 與 admin SPA 同源(同 hostname 下 worker route 優先於 Pages,對 /api/* 生效)。
+# 換成你自己的網域與 Cloudflare zone:
+routes = [
+ { pattern = "admin.example.com/api/*", zone_name = "example.com" },
+]
+
+[[d1_databases]]
+binding = "DB"
+database_name = "chippot-db"
+# 執行 `wrangler d1 create chippot-db`(或在 Cloudflare 後台建 D1)後,把回傳的 id 填這裡:
+database_id = "your-d1-database-id"
+migrations_dir = "migrations"
+
+[[r2_buckets]]
+binding = "BUCKET"
+bucket_name = "chippot-proofs"
+
+# 非機密的 Discord / Access 設定(公開值)。Bot token 是 secret,用 `wrangler secret put DISCORD_BOT_TOKEN`
+# 或後台 Worker → Settings → Variables and Secrets 設定,不寫在這裡。
+[vars]
+DISCORD_APPLICATION_ID = "your-discord-application-id"
+DISCORD_PUBLIC_KEY = "your-discord-public-key"
+WEB_ORIGIN = "https://pay.example.com"
+ADMIN_ORIGIN = "https://admin.example.com"
+ACCESS_TEAM_DOMAIN = "your-team-name"
+ACCESS_AUD = "your-access-aud"
+
+# 01:00 UTC = 09:00 Asia/Taipei,每天。
+[triggers]
+crons = ["0 1 * * *"]
+```
+
+- [ ] **Step 2: 去除 `src/index.ts` 註解內的 owner 網域**
+
+`packages/worker/src/index.ts:31` 註解 `admin.panspace.dev/api/*` → 改為 `admin.example.com/api/*`:
+
+找到該行(內容類似):
+```ts
+ // admin.panspace.dev/api/* routes to this worker; strip /api so the same routers match.
+```
+改成:
+```ts
+ // admin.example.com/api/* routes to this worker; strip /api so the same routers match.
+```
+
+- [ ] **Step 3: 去除測試標題內的 owner 網域**
+
+`packages/worker/test/index.test.ts:21` 標題:
+```ts
+ it("strips /api prefix (admin.panspace.dev/api/* -> /admin/*)", async () => {
+```
+改成:
+```ts
+ it("strips /api prefix (admin.example.com/api/* -> /admin/*)", async () => {
+```
+
+- [ ] **Step 4: 確認 repo 內已無 owner 正式值殘留(wrangler.toml 範圍)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+grep -rn "panspace\|poterpan5466\|chippot.poterpan.workers.dev" packages/worker/wrangler.toml packages/worker/src packages/worker/test
+```
+Expected: 無輸出(前端的兩處 panspace 在 Task 2/3 處理,這裡只確認 worker 範圍乾淨)。
+
+- [ ] **Step 5: 跑 typecheck + 測試(含無 .dev.vars 情境)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker typecheck
+mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true
+pnpm --filter @chippot/worker test 2>&1 | grep -E "Test Files|Tests "
+mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true
+```
+Expected: typecheck 無錯;`Test Files 31 passed (31)`、`Tests 158 passed (158)`。
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/worker/wrangler.toml packages/worker/src/index.ts packages/worker/test/index.test.ts
+git commit -m "chore(worker): replace owner values in wrangler.toml with placeholders
+
+Make the repo fork-safe: routes/vars/database_id become placeholders with
+guidance comments; remove ACCESS_ALLOWED_EMAILS (the code path is disabled —
+Access policy is the real allowlist — so it only leaked an email). Also
+genericize two cosmetic panspace.dev references (index.ts comment, test title).
+Owner keeps real values locally via git update-index --skip-worktree."
+```
+
+---
+
+## Task 2: upload-link 回傳完整 `url`(去 admin 硬編)
+
+**Files:**
+- Modify: `packages/worker/src/routes/admin.ts:479`(`createUploadLink` 回傳)
+- Test: `packages/worker/test/routes/admin.test.ts:70-75`(既有測試加斷言)
+- Modify: `packages/admin/src/api.ts:60`(`uploadLink` 回傳型別加 `url`)
+- Modify: `packages/admin/src/views/Payments.tsx:234-235`(用 `r.url`)
+
+- [ ] **Step 1: 在既有測試加 `url` 斷言(先失敗)**
+
+`packages/worker/test/routes/admin.test.ts`,在第 70-75 區塊(取得 `link` 後)加入兩行斷言。原本:
+```ts
+ const link = (await lRes!.json()) as any;
+ const tok = await findValidUploadToken(env.DB, await hashToken(link.token), nowUtcIso());
+ expect(tok?.user_id).toBe(userId);
+ expect(tok?.period).toBe("2026-07");
+```
+改成(新增 url 斷言):
+```ts
+ const link = (await lRes!.json()) as any;
+ // url is a full absolute link built from WEB_ORIGIN, ending in the token path (no hardcoded domain).
+ expect(link.url).toMatch(/^https?:\/\/.+\/u\/.+$/);
+ expect(link.url.endsWith(link.path)).toBe(true);
+ const tok = await findValidUploadToken(env.DB, await hashToken(link.token), nowUtcIso());
+ expect(tok?.user_id).toBe(userId);
+ expect(tok?.period).toBe("2026-07");
+```
+
+- [ ] **Step 2: 跑測試確認失敗**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker test test/routes/admin.test.ts 2>&1 | grep -E "url|FAIL|Tests "
+```
+Expected: 失敗(`link.url` 為 undefined,`toMatch` 失敗)。
+
+- [ ] **Step 3: 讓 `createUploadLink` 回傳 `url`**
+
+`packages/worker/src/routes/admin.ts`,`createUploadLink` 結尾的 return(第 479 行)。原本:
+```ts
+ return json({ token: raw, path: `/u/${raw}`, expires_at: expiresAt }, { status: 201 });
+```
+改成(用 `WEB_ORIGIN` 組完整 URL;去尾斜線避免雙斜線):
+```ts
+ const path = `/u/${raw}`;
+ const webOrigin = (env.WEB_ORIGIN ?? "").replace(/\/$/, "");
+ return json({ token: raw, path, url: `${webOrigin}${path}`, expires_at: expiresAt }, { status: 201 });
+```
+
+- [ ] **Step 4: 跑測試確認通過**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker test test/routes/admin.test.ts 2>&1 | grep -E "Tests "
+```
+Expected: `Tests ... passed`(全綠)。
+
+- [ ] **Step 5: admin client 型別加 `url`**
+
+`packages/admin/src/api.ts:60`,原本:
+```ts
+ uploadLink: (b: unknown) => req<{ token: string; path: string; expires_at: string }>("POST", "/upload-link", b),
+```
+改成:
+```ts
+ uploadLink: (b: unknown) => req<{ token: string; path: string; url: string; expires_at: string }>("POST", "/upload-link", b),
+```
+
+- [ ] **Step 6: Payments.tsx 改用 `r.url`,移除硬編**
+
+`packages/admin/src/views/Payments.tsx:234-235`,原本:
+```tsx
+ const r = await api.uploadLink({ user_id: Number(userId), period });
+ setLink(`https://pay.panspace.dev${r.path}`);
+```
+改成:
+```tsx
+ const r = await api.uploadLink({ user_id: Number(userId), period });
+ setLink(r.url);
+```
+
+- [ ] **Step 7: typecheck(worker + admin)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker typecheck && pnpm --filter @chippot/admin typecheck
+```
+Expected: 兩者皆無錯。
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/worker/src/routes/admin.ts packages/worker/test/routes/admin.test.ts packages/admin/src/api.ts packages/admin/src/views/Payments.tsx
+git commit -m "fix(admin): build upload link from WEB_ORIGIN, drop hardcoded owner domain
+
+The admin 'copy upload link' feature hardcoded https://pay.panspace.dev. Have the
+worker return a full url built from WEB_ORIGIN; the admin SPA uses it verbatim, so
+a fork's links point at the fork's own domain."
+```
+
+---
+
+## Task 3: web `api.ts` fail-loud(移除 owner URL fallback)
+
+**Files:**
+- Modify: `packages/web/src/api.ts:1-3`
+
+- [ ] **Step 1: 移除 owner fallback,改成未設即拋錯**
+
+`packages/web/src/api.ts` 第 1-3 行,原本:
+```ts
+const API =
+ (import.meta.env.VITE_API_BASE as string | undefined) ??
+ "https://chippot.poterpan.workers.dev";
+```
+改成:
+```ts
+const API = import.meta.env.VITE_API_BASE as string | undefined;
+if (!API) {
+ // Fail loud instead of silently calling someone else's backend: a fork that forgot to set
+ // VITE_API_BASE at build time would otherwise post payments to the upstream worker.
+ throw new Error(
+ "VITE_API_BASE is not set. Build the web app with it pointing at your worker, e.g. " +
+ "VITE_API_BASE=https://chippot..workers.dev pnpm --filter @chippot/web build"
+ );
+}
+```
+
+- [ ] **Step 2: typecheck(確認 narrowing 後 `API` 為 string,後續 `${API}` 不報錯)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/web typecheck
+```
+Expected: 無錯(控制流narrowing 後 `API: string`)。
+
+- [ ] **Step 3: build 驗證(有設 VITE_API_BASE 應成功)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+VITE_API_BASE=https://example.workers.dev pnpm --filter @chippot/web build 2>&1 | tail -3
+```
+Expected: build 成功(`✓ built in ...`)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/web/src/api.ts
+git commit -m "fix(web): fail loud when VITE_API_BASE is unset
+
+Remove the hardcoded fallback to the owner's worker URL. A fork that forgets to set
+VITE_API_BASE at build time now errors immediately instead of silently routing the
+public payment page to the upstream backend."
+```
+
+---
+
+## Task 4: slash 指令註冊後台按鈕(Access 保護路由)
+
+**Files:**
+- Modify: `packages/worker/src/routes/admin.ts`(imports、新 handler、router 註冊)
+- Test: `packages/worker/test/routes/admin.test.ts`(新 describe 區塊)
+- Modify: `packages/admin/src/api.ts`(新 `registerCommands`)
+- Modify: `packages/admin/src/views/Settings.tsx`(新 `RegisterCommands` 元件 + 渲染)
+
+- [ ] **Step 1: 寫失敗測試(新路由)**
+
+`packages/worker/test/routes/admin.test.ts`,在 `describe("admin notifications", ...)` 區塊之後(檔案靠後處、最後一個 top-level `describe` 前)新增:
+```ts
+describe("admin discord slash registration", () => {
+ it("registers the three guild commands via the Discord API", async () => {
+ // guild id lives in workspace settings; bot token is a runtime secret.
+ await call("PATCH", "/admin/workspace", { settings: { discord_guild_id: "guild-777" } });
+ const prevToken = (env as any).DISCORD_BOT_TOKEN;
+ (env as any).DISCORD_BOT_TOKEN = "test-bot-token";
+ let captured: { url: string; body: any } | null = null;
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init: any) => {
+ captured = { url, body: JSON.parse(init.body) };
+ return new Response("[]", { status: 200 });
+ }));
+ const res = await call("POST", "/admin/discord/register-commands");
+ vi.unstubAllGlobals();
+ (env as any).DISCORD_BOT_TOKEN = prevToken;
+
+ expect(res!.status).toBe(200);
+ expect(((await res!.json()) as any).registered).toBe(3);
+ expect(captured!.url).toContain("/guilds/guild-777/commands");
+ const names = captured!.body.map((c: any) => c.name);
+ expect(names).toHaveLength(3);
+ expect(new Set(names)).toEqual(new Set(["繳費", "發起繳費", "綁定"])); // order-independent
+ });
+
+ it("400s when the bot token is not configured", async () => {
+ await call("PATCH", "/admin/workspace", { settings: { discord_guild_id: "guild-777" } });
+ const prevToken = (env as any).DISCORD_BOT_TOKEN;
+ delete (env as any).DISCORD_BOT_TOKEN;
+ const res = await call("POST", "/admin/discord/register-commands");
+ (env as any).DISCORD_BOT_TOKEN = prevToken;
+ expect(res!.status).toBe(400);
+ });
+});
+```
+
+- [ ] **Step 2: 跑測試確認失敗**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker test test/routes/admin.test.ts 2>&1 | grep -E "slash|FAIL|register|Tests "
+```
+Expected: 失敗(路由不存在 → 404,斷言 200 失敗)。
+
+- [ ] **Step 3: 加 imports(admin.ts 頂部)**
+
+`packages/worker/src/routes/admin.ts:11-12`,原本:
+```ts
+import { createChannelMessage, editChannelMessage } from "../adapters/discord/api";
+import { payButtonRow } from "../adapters/discord/commands";
+```
+改成:
+```ts
+import { createChannelMessage, editChannelMessage, registerGuildCommands } from "../adapters/discord/api";
+import { payButtonRow, PAY_COMMAND, INITIATE_COMMAND, BIND_COMMAND } from "../adapters/discord/commands";
+```
+
+- [ ] **Step 4: 新增 handler(接在 `discordPaymentMessage` 之後,約第 510 行)**
+
+在 `discordPaymentMessage` 函式結束 `}` 之後、`// ── Router ──` 之前插入:
+```ts
+async function discordRegisterCommands(_req: Request, env: Env, ctx: RouteCtx): Promise {
+ const ws = wsId(ctx);
+ const row = await env.DB.prepare("SELECT settings FROM workspaces WHERE id = ?").bind(ws).first<{ settings: string }>();
+ if (!row) return errorResponse(404, "not found");
+ const settings = parseSettings(row.settings);
+ const guildId = settings.discord_guild_id;
+ if (!guildId) return errorResponse(400, "discord_guild_id is not set");
+ if (!env.DISCORD_APPLICATION_ID) return errorResponse(400, "DISCORD_APPLICATION_ID is not set");
+ if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "bot token not configured");
+
+ const commands = [PAY_COMMAND, INITIATE_COMMAND, BIND_COMMAND];
+ const res = await registerGuildCommands(env.DISCORD_BOT_TOKEN, env.DISCORD_APPLICATION_ID, guildId, commands);
+ if (!res.ok) return errorResponse(502, "failed to register commands");
+
+ await writeAudit(env.DB, { workspaceId: ws, actor: actorOf(ctx), action: "discord.register_commands", entityType: "workspace", entityId: ws, after: { guild_id: guildId, count: commands.length } });
+ return json({ ok: true, registered: commands.length });
+}
+```
+
+- [ ] **Step 5: 註冊路由**
+
+`packages/worker/src/routes/admin.ts:543`,原本最後一行:
+```ts
+ .post("/admin/discord/payment-message", discordPaymentMessage);
+```
+改成:
+```ts
+ .post("/admin/discord/payment-message", discordPaymentMessage)
+ .post("/admin/discord/register-commands", discordRegisterCommands);
+```
+
+- [ ] **Step 6: 跑測試確認通過(含無 .dev.vars)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true
+pnpm --filter @chippot/worker test 2>&1 | grep -E "Test Files|Tests "
+mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true
+```
+Expected: `Test Files 31 passed (31)`、`Tests 160 passed (160)`(新增 2 個測試)。
+
+- [ ] **Step 7: admin client 加 `registerCommands`**
+
+`packages/admin/src/api.ts`,在 `rebuildPaymentMessage`(第 47 行)之後加一行:
+```ts
+ registerCommands: () => req<{ ok: boolean; registered: number }>("POST", "/discord/register-commands"),
+```
+
+- [ ] **Step 8: Settings.tsx 新增 `RegisterCommands` 元件**
+
+`packages/admin/src/views/Settings.tsx`,在 `RebuildMessage` 函式(結束於第 230 行 `}`)之後新增:
+```tsx
+function RegisterCommands() {
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState(null);
+ const [err, setErr] = useState(null);
+ async function run() {
+ setBusy(true); setErr(null); setMsg(null);
+ try { const r = await api.registerCommands(); setMsg(`✓ 已註冊 ${r.registered} 個 slash 指令`); }
+ catch (e) { setErr((e as Error).message); }
+ setBusy(false);
+ }
+ return (
+ <>
+ {err && {err}
}
+ {msg && {msg}
}
+
+ >
+ );
+}
+```
+
+- [ ] **Step 9: 在設定頁渲染新按鈕**
+
+`packages/admin/src/views/Settings.tsx:122-124`,原本:
+```tsx
+
+ 常駐繳費訊息
+
+```
+改成(在 RebuildMessage 後加一段 slash 指令區塊):
+```tsx
+
+ 常駐繳費訊息
+
+
+
+ Discord slash 指令
+
+```
+
+- [ ] **Step 10: typecheck(worker + admin)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker typecheck && pnpm --filter @chippot/admin typecheck
+```
+Expected: 兩者皆無錯。
+
+- [ ] **Step 11: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/worker/src/routes/admin.ts packages/worker/test/routes/admin.test.ts packages/admin/src/api.ts packages/admin/src/views/Settings.tsx
+git commit -m "feat(admin): register Discord slash commands from a dashboard button
+
+Add an Access-protected POST /admin/discord/register-commands that PUTs the three
+guild commands via the existing registerGuildCommands helper (app id from vars,
+guild id from settings, bot token from the runtime secret), plus a Settings-page
+button. Lets zero-CLI forkers register slash commands; the pnpm register script
+stays for CLI users."
+```
+
+---
+
+## Task 5: worker `deploy` script(migration 併進部署)
+
+**Files:**
+- Modify: `packages/worker/package.json`
+
+- [ ] **Step 1: 加 `deploy` script**
+
+`packages/worker/package.json` 的 `scripts` 區塊,在 `"deploy": "wrangler deploy"` 這行(若已存在則取代)設為先套 migration 再部署。把:
+```json
+ "deploy": "wrangler deploy",
+```
+改成:
+```json
+ "deploy": "wrangler d1 migrations apply chippot-db --remote && wrangler deploy",
+```
+
+- [ ] **Step 2: 確認 script 語法正確(dry-check,不實際部署)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+node -e "const s=require('./packages/worker/package.json').scripts.deploy; console.log(s); if(!s.includes('migrations apply')||!s.includes('wrangler deploy'))process.exit(1)"
+```
+Expected: 印出 `wrangler d1 migrations apply chippot-db --remote && wrangler deploy`,exit 0。
+
+> 注意:實際 `--remote` 部署需 Cloudflare 認證,於部署階段在 owner 帳號驗證(見 spec §7:確認 Workers Builds 的 CI token 具 D1 寫權限;若不足則退回 migration 偶發手動)。本步驟只驗證 script 字串。
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/worker/package.json
+git commit -m "build(worker): fold D1 migrations into the deploy script
+
+deploy now runs 'wrangler d1 migrations apply chippot-db --remote && wrangler deploy'
+so both CLI and Workers Builds apply pending migrations before deploying (idempotent:
+wrangler tracks applied migrations)."
+```
+
+---
+
+## Task 6: 重寫 `docs/DEPLOY.md`(雙路徑)
+
+**Files:**
+- Modify: `docs/DEPLOY.md`(重寫)
+
+> 沿用現有 DEPLOY.md 的共用素材(架構圖、子網域 SSL 說明、Access、Discord app、首次設定、FAQ)與 spec §4D/E。重寫成「兩條部署路徑 + 共用前置/後置」結構。
+
+- [ ] **Step 1: 重寫 DEPLOY.md 成下列結構(保留既有共用章節的內容,調整部署章節)**
+
+新結構(章節標題與要點;共用章節沿用現有文字,僅把 owner 範例網域改成 `example.com` 佔位):
+
+```
+# ChipPot 部署指南
+
+## 0. 架構與你會建立的東西 ← 沿用現有(worker + 2 Pages + D1 + R2 + Access + Discord)
+## 1. 前置需求 ← Cloudflare 帳號 + 已託管網域;Discord 帳號
+ (路徑一不需本機 Node/pnpm/wrangler;路徑二才需要)
+
+## 2. 建立 Cloudflare 資源(D1 / R2)
+ - 路徑一(後台):Storage & Databases → D1 建 chippot-db;R2 建 chippot-proofs
+ - 路徑二(CLI):wrangler d1 create / wrangler r2 bucket create
+ - 兩者皆把 D1 id 記下,稍後填進 wrangler.toml
+
+## 3. 建立 Discord 應用與 Bot ← 沿用現有(共用,兩路徑都要)
+## 4. 設定 Cloudflare Access ← 沿用現有(共用,兩路徑都要)
+
+## 5. 填寫設定(wrangler.toml 佔位值)
+ - 路徑一:在 GitHub fork 上用網頁編輯器改 packages/worker/wrangler.toml 的佔位值
+ (routes/zone、DISCORD_APPLICATION_ID、DISCORD_PUBLIC_KEY、WEB_ORIGIN、
+ ADMIN_ORIGIN、ACCESS_TEAM_DOMAIN、ACCESS_AUD、database_id),commit 到 fork
+ - 路徑二:本機改同一檔;owner 用 `git update-index --skip-worktree packages/worker/wrangler.toml`
+ 讓真值不進 commit
+ - 對照表(沿用 spec §4A 表格)
+
+## 6. 部署
+### 路徑一(主推):Cloudflare 後台 + Git,零本機工具
+ 1. Fork repo 到自己的 GitHub
+ 2. Worker(Workers Builds):後台 Workers & Pages → Create → 連到 fork;
+ deploy command = `pnpm --filter @chippot/worker deploy`(會自動套 migration 再部署)
+ 3. 設 Worker runtime secret:Worker → Settings → Variables and Secrets → 加 DISCORD_BOT_TOKEN
+ 4. 部署後記下 worker 網址(chippot.<子網域>.workers.dev)
+ 5. web(Pages):Create → Pages → 連 fork;
+ build command = `pnpm --filter @chippot/web build`,output = packages/web/dist;
+ build 變數 VITE_API_BASE = 第 4 步的 worker 網址
+ 6. admin(Pages):build command = `pnpm --filter @chippot/admin build`,output = packages/admin/dist
+ 7. 綁自訂網域:web→pay.<域>、admin→admin.<域>;admin 需同時存在 Pages 自訂網域
+ 與 worker route admin.<域>/api/*(worker 對 /api/* 優先)
+ 8. 之後同步 fork(GitHub「Sync fork」)即自動重部署
+### 路徑二(保留):純 CLI wrangler
+ 1. git clone、pnpm install
+ 2. cd packages/worker && wrangler d1 migrations apply chippot-db --remote(或直接 pnpm deploy)
+ 3. wrangler secret put DISCORD_BOT_TOKEN
+ 4. pnpm --filter @chippot/worker deploy
+ 5. web:VITE_API_BASE= pnpm --filter @chippot/web build && wrangler pages deploy ...
+ 6. admin:pnpm --filter @chippot/admin build && wrangler pages deploy ...
+
+## 7. 註冊 Discord Slash 指令
+ - 路徑一:登入後台 → 設定頁 → 按「註冊 / 更新 Discord slash 指令」
+ - 路徑二:cd packages/worker && DISCORD_GUILD_ID= pnpm --filter @chippot/worker register
+ - 兩者皆對同一 PUT 端點操作(idempotent)
+
+## 8. 首次設定(登入後台) ← 沿用現有
+## 9. 變數 / Secret 分層(速查表) ← 新增,用 spec §4D-4 表格
+## 10. 常見問題 ← 沿用現有 + 補:VITE_API_BASE 未設→繳費頁白屏/報錯;
+ slash 指令沒出現→按設定頁按鈕或檢查 guild id;
+ 破壞性 migration 建議低流量時段先手動套
+## 11. 之後更新版本 ← 路徑一:Sync fork 自動部署;路徑二:git pull + pnpm deploy + pages build
+```
+
+落筆原則:所有 owner 範例網域一律用 `example.com`;指令一律用 `pnpm --filter @chippot/`;
+migration 說明強調「deploy script 已自動套、idempotent、破壞性 migration 要謹慎」。
+
+- [ ] **Step 2: 確認 DEPLOY.md 內無 owner 正式值**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+grep -n "panspace\|poterpan5466\|chippot.poterpan.workers.dev" docs/DEPLOY.md || echo "(乾淨,無 owner 正式值)"
+```
+Expected: `(乾淨,無 owner 正式值)`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add docs/DEPLOY.md
+git commit -m "docs(deploy): rewrite DEPLOY.md as dual-path (dashboard+Git / CLI)
+
+Primary path: fork + edit wrangler.toml placeholders in the GitHub web editor +
+connect Workers Builds & two Pages projects + set secrets in the dashboard — no
+local toolchain. Secondary path: the existing wrangler CLI flow, preserved. Adds a
+secret/var layering table, migration-in-deploy notes, and the same-origin route ordering."
+```
+
+---
+
+## Task 7: genericize README owner 網域引用(計畫補強)
+
+> 由 Task 1 程式品質審查發現:根目錄 README 也以 `admin.panspace.dev` 當架構範例,屬 spec §1 目標「清掉所有 owner 正式值」範圍。`docs/setup-checklist.md`(owner 內部一次性建置檔)的處置另行請 owner 定奪(刪除/gitignore),不在本任務。
+
+**Files:**
+- Modify: `README.md`(行 107、108、174 的 `admin.panspace.dev` / `admin.panspace.dev/api`)
+- Modify: `README.zh-TW.md`(行 100、101、164 同上)
+
+- [ ] **Step 1: 取代 owner 網域為範例網域**
+
+把 `README.md` 與 `README.zh-TW.md` 中所有 `admin.panspace.dev` → `admin.example.com`(含 `/api/*` 與括號內路由說明),語意不變、只換網域。用 Grep 找出各行後逐一 Edit(或 `replace_all` 該字串)。
+
+- [ ] **Step 2: 確認兩個 README 已無 owner 值**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+grep -n "panspace\|poterpan5466\|chippot.poterpan.workers.dev" README.md README.zh-TW.md || echo "(README 乾淨)"
+```
+Expected: `(README 乾淨)`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add README.md README.zh-TW.md
+git commit -m "docs(readme): genericize owner domain in architecture examples
+
+Replace admin.panspace.dev with admin.example.com in the README architecture
+sections so the public repo carries no owner production domain."
+```
+
+## Final verification(全部 Task 完成後)
+
+- [ ] **Step 1: 全 monorepo 綠燈(無 .dev.vars = CI 條件)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true
+pnpm -r typecheck && pnpm -r test 2>&1 | grep -E "Test Files|Tests " && VITE_API_BASE=https://example.workers.dev pnpm -r build 2>&1 | tail -3
+mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true
+```
+Expected: typecheck 全過;`Tests 160 passed (160)`;web/admin build 成功。
+
+> 注意:`pnpm -r build` 時 web 需要 `VITE_API_BASE`,故上面帶入。實務 CI(GitHub Actions `ci.yml`)的 `pnpm -r build` 若未帶 VITE_API_BASE,web build 會在載入期才報錯而非 build 期失敗(見 Task 3 設計)——若希望 CI 也涵蓋,於後續評估是否在 web build 加 build 期檢查(本計畫不含,YAGNI)。
+
+- [ ] **Step 2: 全 repo owner 值掃描**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+# 排除 docs/superpowers(設計/計畫文件本身合理地引用 panspace 來描述 before/after)
+# 與 docs/setup-checklist.md(owner 內部檔,處置另議)。
+grep -rn "panspace\|poterpan5466\|chippot.poterpan.workers.dev" README.md README.zh-TW.md packages docs \
+ --include="*.ts" --include="*.tsx" --include="*.toml" --include="*.md" \
+ | grep -v "docs/superpowers/" | grep -v "docs/setup-checklist.md" \
+ || echo "(全乾淨)"
+```
+Expected: `(全乾淨)`。
+
+---
+
+## Self-Review 對照(spec → task)
+
+- spec §4A(wrangler.toml 佔位值、移除 ACCESS_ALLOWED_EMAILS) → Task 1 ✓
+- spec §4B(api.ts fail-loud、Payments.tsx 用後端 url) → Task 3、Task 2 ✓
+- spec §4C(register-commands 路由 + 後台按鈕、import commands.ts payload、guild id 取自 settings) → Task 4 ✓
+- spec §4D-2(migration 併進 deploy script) → Task 5 ✓
+- spec §4D-1/D-3/D-4/D-5(Git 整合、VITE_API_BASE 順序、secret 分層、同源 route) → Task 6(DEPLOY.md)✓
+- spec §4E(DEPLOY.md 雙路徑重寫) → Task 6 ✓
+- spec §5(檔案異動清單) → 全 Task 覆蓋 ✓
+- spec §6(測試:register-commands、upload-link url、api.ts 行為) → Task 2/3/4 測試 ✓
+- spec §7(D1 token 實機驗證) → Task 5 Step 2 註記 + 部署階段確認 ✓
+
+> 註:spec §7 的 Workers Builds D1 token 權限屬「部署實機驗證」,非程式碼可斷言,故列為部署階段確認項而非自動化測試。
diff --git a/docs/superpowers/specs/2026-06-07-fork-friendly-deploy-design.md b/docs/superpowers/specs/2026-06-07-fork-friendly-deploy-design.md
new file mode 100644
index 0000000..2b77271
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-07-fork-friendly-deploy-design.md
@@ -0,0 +1,191 @@
+# Fork 友善化部署 — 設計
+
+> 日期:2026-06-07 狀態:設計定稿,待寫實作計畫
+> 相關:`docs/DEPLOY.md`、`packages/worker/wrangler.toml`、Codex 複核(2026-06-07)
+
+## 1. 背景與目標
+
+ChipPot 是公開 repo,目標讓**其他開發者 fork 後能輕鬆部署到自己的 Cloudflare**。現行 `docs/DEPLOY.md`
+是全手動 wrangler CLI 流程(`d1 create` / `secret put` / `deploy` / `pages deploy` ×2 / 本機 `register`),
+對 fork 者門檻高。團隊也偏好用 Cloudflare 網頁介面部署。
+
+參考兩個熱門 Cloudflare 開源專案:
+- **miantiao-me/Sink**:Fork → 後台 Workers「Connect to Git」→ 設 build/deploy 指令 → 後台填 env var/secret →
+ 同步 fork 自動重部署。零本機 CLI。(注意:Sink 仍要 forker 在 `wrangler.jsonc` 填自己的 KV id —— 即
+ 「改設定檔」本就是參考做法的一部分,不是負分。)
+- **cmliu/edgetunnel**:單檔 worker,貼程式碼/上傳 zip/Fork+Pages 連 Git。面積極小。
+
+**目標**:
+1. fork 者可走「**本機零工具鏈**」路徑:GitHub 網頁改少量設定 + Cloudflare 後台連 Git 自動部署 + 後台填 secret。
+2. **100% 保留**現有純 CLI `wrangler deploy` 全程(owner / 進階使用者用)。
+3. 清掉 repo 內所有 owner 正式值(隱私 + 避免 fork 者誤連到 owner 後端)。
+
+**明確非目標**:達到 edgetunnel 等級「真一鍵」。Cloudflare Access、Discord app/bot、自訂子網域 + DNS
+是本質性外部設定,任何部署機制都無法自動化——兩條路徑都需手動完成這些。
+
+## 2. 關鍵技術前提(已查證 Cloudflare 文件 2026-06-07)
+
+- **Workers Builds / Pages 後台的「Environment variables」是 _build 期_ 變數**,不會自動成為 Worker 的
+ runtime `env`。Worker runtime 變數仍來自 `wrangler.toml [vars]`(由 `wrangler deploy` 套用)。
+ → 因此「把 vars 全搬到後台」到不了 runtime,且會與 CLI 流程衝突。**結論:`wrangler.toml` 是 runtime 設定的單一真相來源。**
+- **Runtime secret**(`DISCORD_BOT_TOKEN`):`wrangler secret put`(CLI)或後台 Worker → Settings →
+ Variables and Secrets(Git 流程)。**deploy 不會洗掉 secret**。
+- **Workers Builds 支援自訂 deploy 指令**(如 `pnpm run deploy`),在 CI 內帶 Cloudflare 認證執行,
+ 可在其中跑 `wrangler d1 migrations apply --remote`。
+- `wrangler d1 migrations apply --remote` **idempotent**:用 `d1_migrations` 表追蹤,只套未套用的 migration。
+
+## 3. 範圍
+
+**In scope**(單一 spec,兩大塊):
+- 讓 repo 對 fork 安全:A(wrangler.toml 佔位值)、B(前端去硬編)、C(slash 註冊後台按鈕)
+- 部署機制與文件:D(Git 整合流程 + migration + secret 分層)、E(重寫 DEPLOY.md 雙路徑)
+
+**Out of scope**:CD 到 owner 自己的 prod(維持現狀)、Deploy-to-Cloudflare 按鈕(monorepo + Pages 限制,不適用全棧)、
+變更 Access / Discord / DNS 的本質手動性。
+
+---
+
+## 4. 設計
+
+### A. `wrangler.toml` 改佔位值(設定單一真相來源)
+
+`packages/worker/wrangler.toml` 內建 owner 正式值 → 改佔位符 + 清楚註解。
+
+| 欄位 | 現況(owner 正式值,需替換) | 改成 |
+|---|---|---|
+| `routes[].pattern` / `zone_name` | `admin.panspace.dev/api/*` / `panspace.dev` | `admin.example.com/api/*` / `example.com` |
+| `[vars] DISCORD_APPLICATION_ID` | 真實 id | `your-discord-application-id` |
+| `[vars] DISCORD_PUBLIC_KEY` | 真實 key | `your-discord-public-key` |
+| `[vars] WEB_ORIGIN` | `https://pay.panspace.dev` | `https://pay.example.com` |
+| `[vars] ADMIN_ORIGIN` | `https://admin.panspace.dev` | `https://admin.example.com` |
+| `[vars] ACCESS_TEAM_DOMAIN` | `panspace` | `your-team-name` |
+| `[vars] ACCESS_AUD` | 真實 AUD | `your-access-aud` |
+| `[vars] ACCESS_ALLOWED_EMAILS` | `poterpan5466@gmail.com` | **移除整行**(程式已停用此路徑,見 `middleware/access.ts:164-173` 註解;留著只是洩個資) |
+| `[[d1_databases]].database_id` | 真實 id | `your-d1-database-id` + 註解「`wrangler d1 create` 或後台建 D1 後填」 |
+
+- 不洩密欄位(`name`、`database_name=chippot-db`、`bucket_name=chippot-proofs`、`crons`、binding 名)沿用。
+- **Owner 自己的正式值**:在本機工作目錄把佔位值改回真實值,並用
+ `git update-index --skip-worktree packages/worker/wrangler.toml` 讓本機修改不進 commit
+ (owner 現已對該檔採此做法——見專案記憶)。CLI 部署照舊,repo 公開乾淨。
+ > 注意權衡:skip-worktree 後,若上游改動 `wrangler.toml` 結構,`git pull` 可能需要先暫時取消
+ > skip-worktree 再手動併入;這是 owner 端既有的操作成本,可接受。
+
+### B. 移除前端兩處硬編 owner 網域
+
+Codex 揪出、已 grep 驗證:
+
+1. **`packages/web/src/api.ts:1-3`**:`VITE_API_BASE` 未設時**靜默** fallback 到
+ `https://chippot.poterpan.workers.dev`(fork 者忘了設就默默打到 owner 後端,不報錯)。
+ → **移除 owner URL fallback,改 fail-loud**:未設 `VITE_API_BASE` 時明確報錯(build 期或啟動時),
+ 不再有 owner 預設值。web 與 worker 跨網域,`VITE_API_BASE` 本就必填。
+
+2. **`packages/admin/src/views/Payments.tsx:235`**:`setLink(\`https://pay.panspace.dev${r.path}\`)` 寫死 owner 網域。
+ → **改由 worker 後端產生完整 URL**:`/admin/upload-link`(`routes/admin.ts:~479`,現回傳 `{ token, path, expires_at }`)
+ 改為同時回傳 `url = \`${env.WEB_ORIGIN}${path}\``(worker 已有 `WEB_ORIGIN` 在 `[vars]`);前端直接用 `r.url`,
+ 不再拼網域。一處修正、根除來源。
+
+### C. Slash 註冊:新增後台按鈕(Access 保護路由)
+
+現況:本機 `scripts/register-commands.mjs`(CLI),與「零本機 CLI」衝突。
+
+- **新增** `POST /admin/discord/register-commands`(與其他 `/admin/*` 同樣經 Cloudflare Access 保護)。
+ - **import** `PAY_COMMAND` / `INITIATE_COMMAND` / `BIND_COMMAND`(已 export 於 `adapters/discord/commands.ts`)
+ 作為單一真相,避免與 .mjs 的 inline 副本漂移。
+ - 來源齊全:`DISCORD_APPLICATION_ID` 來自 `[vars]`、`DISCORD_BOT_TOKEN` 來自 runtime secret、
+ guild id 來自 workspace settings 的 `discord_guild_id`。
+ - 對 `PUT https://discord.com/api/v10/applications/{APP_ID}/guilds/{GUILD_ID}/commands` 送出三個 payload(idempotent 覆寫)。
+ - 缺 token / guild id 時回明確錯誤(沿用 `discordPaymentMessage` 在 `admin.ts:491` 的 `bot token not configured` 模式)。
+- **後台「設定」頁加一顆按鈕**,沿用現有「建立繳費按鈕訊息」的 UI / 錯誤處理。
+- **保留** `scripts/register-commands.mjs` 給 CLI 使用者(兩條路並存;兩者都對同一 PUT 端點操作,idempotent)。
+
+### D. 部署流程(兩條路並存)
+
+#### D-1. 三個可部署單位連 Git
+
+| 單位 | 機制 | 指令 | 輸出 |
+|---|---|---|---|
+| worker | Workers Builds 連 fork | deploy:`pnpm --filter @chippot/worker deploy` | — |
+| web(繳費頁,public) | Pages 連 Git | build:`pnpm --filter @chippot/web build` | `packages/web/dist` |
+| admin(後台 SPA) | Pages 連 Git | build:`pnpm --filter @chippot/admin build` | `packages/admin/dist` |
+
+- root directory = repo 根(pnpm monorepo;以 `--filter` 指定 package)。
+- D1 / R2 可在 Cloudflare 後台點建(Storage & Databases),再用 GitHub 網頁編輯器把 D1 id 填進 `wrangler.toml`。
+- fork 者:fork → 後台建 3 個專案連到自己 fork → 填變數 → 同步 fork 自動重部署。本機零工具鏈。
+
+#### D-2. Migration(決議:併進 deploy 自動跑)
+
+- `packages/worker/package.json` 新增 `deploy` script:
+ **`wrangler d1 migrations apply chippot-db --remote && wrangler deploy`**。
+- Workers Builds 的 deploy 指令指向它(`pnpm --filter @chippot/worker deploy`)。
+- 行為:首次部署自動套 0001–0005;之後同步 fork 有新 migration 也自動套;無新 migration 時 no-op(idempotent)。
+- CLI 路徑跑同一支 script,行為一致。
+- **已知權衡**(Codex):「migration 成功但 deploy 失敗」會有短暫「新 schema 配舊碼」窗口。對 ChipPot 影響小
+ (多為加欄位、可回溯,重 push 即修復)。DEPLOY.md 註明「破壞性 migration 要謹慎、必要時先在低流量時段手動套」。
+
+#### D-3. VITE_API_BASE 雞生蛋 + 順序
+
+- web 需要 worker URL,但 worker URL 部署後才知道 → **部署順序**:先部署 worker → 取得
+ `chippot.<子網域>.workers.dev` → 設為 **web 的 Pages build 變數 `VITE_API_BASE`** → 再 build web。
+- worker 需要的 `WEB_ORIGIN` / `ADMIN_ORIGIN` 是 fork 者自選網域,開頭就填進 `wrangler.toml`,無雞生蛋。
+
+#### D-4. Secret / 變數分層(文件附此表)
+
+| 名稱 | 類型 | 設定位置(Git 流程 / CLI 流程) | 跨 deploy |
+|---|---|---|---|
+| `DISCORD_BOT_TOKEN` | Worker runtime secret | 後台 Worker → Variables and Secrets / `wrangler secret put` | ✅ 保留 |
+| `DISCORD_APPLICATION_ID`、`DISCORD_PUBLIC_KEY`、`WEB_ORIGIN`、`ADMIN_ORIGIN`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` | Worker runtime var | `wrangler.toml [vars]`(兩流程同) | 由 toml 覆寫 |
+| `VITE_API_BASE` | Pages build 變數(非 secret) | web 的 Pages 專案 → 設定 → 變數 / build 時環境變數 | build 當下 |
+
+#### D-5. 同源 admin route
+
+- `admin.` 需同時有 **Pages 自訂網域**(提供 SPA)+ **worker route `admin./api/*`**
+ (worker 對 `/api/*` 優先於 Pages)。
+- DEPLOY.md 講明設定順序(Pages custom domain 與 worker route 都要存在),否則容易只生效一邊。
+
+### E. 重寫 `docs/DEPLOY.md`(雙路徑)
+
+- **路徑一(主推):Cloudflare 後台 + Git,零本機工具**
+ fork → 網頁編輯器改 `wrangler.toml` 佔位值(含 D1 id)→ 後台建 D1/R2 → 連 3 個 Git 專案(1 Workers Builds + 2 Pages)→
+ 填 build 變數(`VITE_API_BASE`)+ runtime secret(`DISCORD_BOT_TOKEN`)→ 設 Access + Discord app/bot →
+ 後台按鈕註冊 slash → 首次設定。
+- **路徑二(保留):純 CLI `wrangler`**
+ 現有全程流程,幾乎不變(值改用本機 overlay)。
+- **共用章節**:Cloudflare Access、Discord application/bot、自訂網域/DNS(本質手動,兩路徑都要)。
+- 補上:secret 分層表、migration 自動化說明、同源 route 設定順序、`.dev.vars.example`(已存在)指引。
+
+---
+
+## 5. 介面 / 檔案異動清單
+
+| 檔案 | 異動 |
+|---|---|
+| `packages/worker/wrangler.toml` | `[vars]`/route 改佔位值;移除 `ACCESS_ALLOWED_EMAILS`;`database_id` 佔位 + 註解 |
+| `packages/web/src/api.ts` | 移除 owner URL fallback,改未設 `VITE_API_BASE` 即 fail-loud |
+| `packages/admin/src/views/Payments.tsx` | 改用後端回傳的 `r.url`,移除硬編 `pay.panspace.dev` |
+| `packages/worker/src/routes/admin.ts` | `/admin/upload-link` 回傳新增 `url`;新增 `POST /admin/discord/register-commands` |
+| `packages/admin/src/views/`(設定頁) | 新增「註冊 slash 指令」按鈕 |
+| `packages/worker/package.json` | 新增 `deploy` script(migrations apply && deploy) |
+| `docs/DEPLOY.md` | 重寫為雙路徑 |
+| `scripts/register-commands.mjs` | 保留(CLI 用),不動 |
+
+## 6. 測試與驗證
+
+- **既有測試**:worker 測試套件須維持全綠(含「無 `.dev.vars`」的 CI 情境)。新路由
+ `register-commands` 比照現有 admin route 加測試(stub fetch、驗證 PUT payload 與授權);
+ `upload-link` 測試斷言回傳含正確 `url`。
+- **前端**:`api.ts` fail-loud 行為加測試 / 型別檢查;`web` build 在缺 `VITE_API_BASE` 時應明確失敗。
+- **文件驗證**:DEPLOY.md 兩條路徑各走一遍(owner 實機 dry-run 或 checklist 核對)。
+
+## 7. 風險與權衡
+
+- migration 自動套的失敗窗口(見 D-2)——接受,文件警示。
+- Workers Builds CI 跑 `--remote` migration 需該 token 具 D1 寫權限——實作時於 owner 帳號實測確認;
+ 若不足,退路是把 migration 拆成偶發手動步驟(後台 D1 console 或一次性 CLI)。
+- 三個 Git 專案連同一 repo——Workers Builds(worker)+ 2×Pages(web/admin),各自 root/build 指令不同,文件需清楚標示。
+
+## 8. 決議摘要(brainstorming)
+
+1. 設定位置:**wrangler.toml 佔位值、直接改**(與 Sink 一致,CLI 完全保留)。
+2. slash 註冊:**後台按鈕**(Access 保護路由),保留 CLI 腳本。
+3. migration:**併進 deploy 自動跑**(idempotent)。
+4. 範圍:A–E 單一 spec。
diff --git a/packages/admin/src/api.ts b/packages/admin/src/api.ts
index dc0156f..e790175 100644
--- a/packages/admin/src/api.ts
+++ b/packages/admin/src/api.ts
@@ -45,6 +45,7 @@ export const api = {
workspace: () => req("GET", "/workspace"),
updateWorkspace: (b: unknown) => req("PATCH", "/workspace", b),
rebuildPaymentMessage: () => req<{ message_id: string }>("POST", "/discord/payment-message"),
+ registerCommands: () => req<{ ok: boolean; registered: number }>("POST", "/discord/register-commands"),
reconcile: (period: string) => req("GET", `/reconcile${qs({ period })}`),
notifications: (period: string) => req<{ billing_opened: { sent_at: string } | null; overdue: { sent_at: string } | null }>("GET", `/notifications${qs({ period })}`),
resendNotification: (type: string, period: string) => req<{ sent?: boolean; count?: number }>("POST", "/notifications/resend", { type, period }),
@@ -57,7 +58,7 @@ export const api = {
overrideAmount: (id: number, amount: number) => req("POST", `/payments/${id}/amount`, { amount }),
deleteProof: (id: number) => req("POST", `/payments/${id}/delete-proof`),
manualPayment: (b: unknown) => req("POST", "/payments/manual", b),
- uploadLink: (b: unknown) => req<{ token: string; path: string; expires_at: string }>("POST", "/upload-link", b),
+ uploadLink: (b: unknown) => req<{ token: string; path: string; url: string; expires_at: string }>("POST", "/upload-link", b),
users: () => req<{ users: User[] }>("GET", "/users"),
createUser: (b: unknown) => req("POST", "/users", b),
updateUser: (id: number, b: unknown) => req("PATCH", `/users/${id}`, b),
diff --git a/packages/admin/src/views/Payments.tsx b/packages/admin/src/views/Payments.tsx
index 25329bb..c180b1b 100644
--- a/packages/admin/src/views/Payments.tsx
+++ b/packages/admin/src/views/Payments.tsx
@@ -232,7 +232,7 @@ function LinkModal({ onClose }: { onClose: () => void }) {
setBusy(true); setErr(null);
try {
const r = await api.uploadLink({ user_id: Number(userId), period });
- setLink(`https://pay.panspace.dev${r.path}`);
+ setLink(r.url);
} catch (e) { setErr((e as Error).message); }
setBusy(false);
}
diff --git a/packages/admin/src/views/Settings.tsx b/packages/admin/src/views/Settings.tsx
index e21c5e2..787d806 100644
--- a/packages/admin/src/views/Settings.tsx
+++ b/packages/admin/src/views/Settings.tsx
@@ -123,6 +123,10 @@ export function Settings() {
常駐繳費訊息
+
+ Discord slash 指令
+
+
@@ -228,3 +232,22 @@ function RebuildMessage() {
>
);
}
+
+function RegisterCommands() {
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState(null);
+ const [err, setErr] = useState(null);
+ async function run() {
+ setBusy(true); setErr(null); setMsg(null);
+ try { const r = await api.registerCommands(); setMsg(`✓ 已註冊 ${r.registered} 個 slash 指令`); }
+ catch (e) { setErr((e as Error).message); }
+ setBusy(false);
+ }
+ return (
+ <>
+ {err && {err}
}
+ {msg && {msg}
}
+
+ >
+ );
+}
diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts
index 1e548c5..228beaf 100644
--- a/packages/web/src/api.ts
+++ b/packages/web/src/api.ts
@@ -1,6 +1,12 @@
-const API =
- (import.meta.env.VITE_API_BASE as string | undefined) ??
- "https://chippot.poterpan.workers.dev";
+const API = import.meta.env.VITE_API_BASE as string | undefined;
+if (!API) {
+ // Fail loud instead of silently calling someone else's backend: a fork that forgot to set
+ // VITE_API_BASE at build time would otherwise post payments to the upstream worker.
+ throw new Error(
+ "VITE_API_BASE is not set. Build the web app with it pointing at your worker, e.g. " +
+ "VITE_API_BASE=https://chippot..workers.dev pnpm --filter @chippot/web build"
+ );
+}
export interface SubscriptionChoice {
id: number;
diff --git a/packages/worker/package.json b/packages/worker/package.json
index 909a23d..9fc0a3e 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -7,7 +7,7 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"dev": "wrangler dev",
- "deploy": "wrangler deploy",
+ "deploy": "wrangler d1 migrations apply chippot-db --remote && wrangler deploy",
"migrate:local": "wrangler d1 migrations apply chippot-db --local",
"migrate:remote": "wrangler d1 migrations apply chippot-db --remote",
"register": "node scripts/register-commands.mjs"
diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts
index 10f92f9..439761a 100644
--- a/packages/worker/src/index.ts
+++ b/packages/worker/src/index.ts
@@ -28,7 +28,7 @@ function corsOptions(env: Env): CorsOptions {
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise {
let url = new URL(req.url);
- // admin.panspace.dev/api/* routes to this worker; strip /api so the same routers match.
+ // admin.example.com/api/* routes to this worker; strip /api so the same routers match.
if (url.pathname.startsWith("/api/")) {
url = new URL(req.url);
url.pathname = url.pathname.slice(4);
diff --git a/packages/worker/src/routes/admin.ts b/packages/worker/src/routes/admin.ts
index afc00a7..97ca723 100644
--- a/packages/worker/src/routes/admin.ts
+++ b/packages/worker/src/routes/admin.ts
@@ -8,8 +8,8 @@ import { writeAudit } from "../core/audit";
import { getPayment, verifyPayment, rejectPayment, overrideAmount, InvalidPaymentTransition } from "../core/payments";
import { ensureFirstPayment, initiateBillingOpened } from "../core/billing";
import { reconcilePeriod } from "../core/reconcile";
-import { createChannelMessage, editChannelMessage } from "../adapters/discord/api";
-import { payButtonRow } from "../adapters/discord/commands";
+import { createChannelMessage, editChannelMessage, registerGuildCommands } from "../adapters/discord/api";
+import { payButtonRow, PAY_COMMAND, INITIATE_COMMAND, BIND_COMMAND } from "../adapters/discord/commands";
import { discordNotifier } from "../adapters/discord/notify";
import { parseRosterCsv, importRoster } from "../core/import";
import { sendOverdueForPeriod } from "../core/scheduled";
@@ -471,12 +471,16 @@ async function createUploadLink(req: Request, env: Env, ctx: RouteCtx): Promise<
const ws = wsId(ctx);
const user = await env.DB.prepare("SELECT id FROM users WHERE id = ? AND workspace_id = ?").bind(b.user_id, ws).first();
if (!user) return errorResponse(400, "invalid user");
+ // Fail loud rather than mint a token only to hand back a broken relative link.
+ if (!env.WEB_ORIGIN) return errorResponse(500, "WEB_ORIGIN is not configured");
const { raw, expiresAt } = await issueUploadToken(env.DB, {
workspaceId: ws, userId: b.user_id, period: b.period, subscriptionId: b.subscription_id ?? null,
ttlMs: UPLOAD_TOKEN_TTL_MS,
});
await writeAudit(env.DB, { workspaceId: ws, actor: actorOf(ctx), action: "upload_link.create", entityType: "user", entityId: b.user_id, after: { period: b.period, subscription_id: b.subscription_id ?? null, expires_at: expiresAt } });
- return json({ token: raw, path: `/u/${raw}`, expires_at: expiresAt }, { status: 201 });
+ const path = `/u/${raw}`;
+ const webOrigin = env.WEB_ORIGIN.replace(/\/$/, "");
+ return json({ token: raw, path, url: `${webOrigin}${path}`, expires_at: expiresAt }, { status: 201 });
}
// ── Discord persistent payment message (spec §11.4) ──────────────────────────
@@ -509,6 +513,24 @@ async function discordPaymentMessage(_req: Request, env: Env, ctx: RouteCtx): Pr
return json({ ok: true, message_id: messageId });
}
+async function discordRegisterCommands(_req: Request, env: Env, ctx: RouteCtx): Promise {
+ const ws = wsId(ctx);
+ const row = await env.DB.prepare("SELECT settings FROM workspaces WHERE id = ?").bind(ws).first<{ settings: string }>();
+ if (!row) return errorResponse(404, "not found");
+ const settings = parseSettings(row.settings);
+ const guildId = settings.discord_guild_id;
+ if (!guildId) return errorResponse(400, "discord_guild_id is not set");
+ if (!env.DISCORD_APPLICATION_ID) return errorResponse(400, "DISCORD_APPLICATION_ID is not set");
+ if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "bot token not configured");
+
+ const commands = [PAY_COMMAND, INITIATE_COMMAND, BIND_COMMAND];
+ const res = await registerGuildCommands(env.DISCORD_BOT_TOKEN, env.DISCORD_APPLICATION_ID, guildId, commands);
+ if (!res.ok) return errorResponse(502, "failed to register commands");
+
+ await writeAudit(env.DB, { workspaceId: ws, actor: actorOf(ctx), action: "discord.register_commands", entityType: "workspace", entityId: ws, after: { guild_id: guildId, count: commands.length } });
+ return json({ ok: true, registered: commands.length });
+}
+
// ── Router ───────────────────────────────────────────────────────────────────
export function buildAdminRouter(): Router {
@@ -540,5 +562,6 @@ export function buildAdminRouter(): Router {
.post("/admin/payments/:id/amount", overrideAmountHandler)
.post("/admin/payments/:id/delete-proof", deleteProof)
.post("/admin/upload-link", createUploadLink)
- .post("/admin/discord/payment-message", discordPaymentMessage);
+ .post("/admin/discord/payment-message", discordPaymentMessage)
+ .post("/admin/discord/register-commands", discordRegisterCommands);
}
diff --git a/packages/worker/test/index.test.ts b/packages/worker/test/index.test.ts
index feab182..3d7f713 100644
--- a/packages/worker/test/index.test.ts
+++ b/packages/worker/test/index.test.ts
@@ -18,7 +18,7 @@ describe("worker fetch", () => {
expect(res.status).toBe(403);
});
- it("strips /api prefix (admin.panspace.dev/api/* -> /admin/*)", async () => {
+ it("strips /api prefix (admin.example.com/api/* -> /admin/*)", async () => {
const res = await SELF.fetch("https://x/api/admin/users");
expect(res.status).toBe(403); // routed to /admin/users, Access still required
const health = await SELF.fetch("https://x/api/health");
diff --git a/packages/worker/test/routes/admin.test.ts b/packages/worker/test/routes/admin.test.ts
index b80e6b7..37b6fe4 100644
--- a/packages/worker/test/routes/admin.test.ts
+++ b/packages/worker/test/routes/admin.test.ts
@@ -70,6 +70,9 @@ describe("admin API", () => {
const lRes = await call("POST", "/admin/upload-link", { user_id: userId, period: "2026-07", subscription_id: subId });
expect(lRes!.status).toBe(201);
const link = (await lRes!.json()) as any;
+ // url is a full absolute link built from WEB_ORIGIN, ending in the token path (no hardcoded domain).
+ expect(link.url).toMatch(/^https?:\/\/.+\/u\/.+$/);
+ expect(link.url.endsWith(link.path)).toBe(true);
const tok = await findValidUploadToken(env.DB, await hashToken(link.token), nowUtcIso());
expect(tok?.user_id).toBe(userId);
expect(tok?.period).toBe("2026-07");
@@ -84,6 +87,16 @@ describe("admin API", () => {
expect(await auditCount("payment.manual", mId)).toBe(1);
});
+ it("upload-link 500s when WEB_ORIGIN is not configured", async () => {
+ const u = await call("POST", "/admin/users", { display_name: "NoOrigin" });
+ const uid = ((await u!.json()) as any).id as number;
+ const prev = (env as any).WEB_ORIGIN;
+ delete (env as any).WEB_ORIGIN;
+ const res = await call("POST", "/admin/upload-link", { user_id: uid, period: "2027-05" });
+ (env as any).WEB_ORIGIN = prev;
+ expect(res!.status).toBe(500);
+ });
+
it("creates/rebuilds the persistent Discord payment message", async () => {
await call("PATCH", "/admin/workspace", { settings: { discord_billing_channel_id: "chan-1" } });
// Supply the bot token locally (CI has no .dev.vars), then restore it — the later
@@ -193,6 +206,39 @@ describe("admin notifications", () => {
});
});
+describe("admin discord slash registration", () => {
+ it("registers the three guild commands via the Discord API", async () => {
+ // guild id lives in workspace settings; bot token is a runtime secret.
+ await call("PATCH", "/admin/workspace", { settings: { discord_guild_id: "guild-777" } });
+ const prevToken = (env as any).DISCORD_BOT_TOKEN;
+ (env as any).DISCORD_BOT_TOKEN = "test-bot-token";
+ let captured: { url: string; body: any } | null = null;
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init: any) => {
+ captured = { url, body: JSON.parse(init.body) };
+ return new Response("[]", { status: 200 });
+ }));
+ const res = await call("POST", "/admin/discord/register-commands");
+ vi.unstubAllGlobals();
+ (env as any).DISCORD_BOT_TOKEN = prevToken;
+
+ expect(res!.status).toBe(200);
+ expect(((await res!.json()) as any).registered).toBe(3);
+ expect(captured!.url).toContain("/guilds/guild-777/commands");
+ const names = captured!.body.map((c: any) => c.name);
+ expect(names).toHaveLength(3);
+ expect(new Set(names)).toEqual(new Set(["繳費", "發起繳費", "綁定"])); // order-independent
+ });
+
+ it("400s when the bot token is not configured", async () => {
+ await call("PATCH", "/admin/workspace", { settings: { discord_guild_id: "guild-777" } });
+ const prevToken = (env as any).DISCORD_BOT_TOKEN;
+ delete (env as any).DISCORD_BOT_TOKEN;
+ const res = await call("POST", "/admin/discord/register-commands");
+ (env as any).DISCORD_BOT_TOKEN = prevToken;
+ expect(res!.status).toBe(400);
+ });
+});
+
describe("admin billing/initiate + declared channel", () => {
it("POST /admin/billing/initiate updates plan price + pending amounts", async () => {
const pRes = await call("POST", "/admin/plans", { name: "InitPlan", provider: "openai", monthly_amount: 500 });
diff --git a/packages/worker/wrangler.toml b/packages/worker/wrangler.toml
index f3cbe15..57b84db 100644
--- a/packages/worker/wrangler.toml
+++ b/packages/worker/wrangler.toml
@@ -4,34 +4,33 @@ compatibility_date = "2025-11-01"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
-# Admin API: same-origin with the admin SPA (Pages) so the Access JWT reaches the worker.
-# Worker routes take precedence over Pages on the same hostname for matching paths.
+# 後台 API 與 admin SPA 同源(同 hostname 下 worker route 優先於 Pages,對 /api/* 生效)。
+# 換成你自己的網域與 Cloudflare zone:
routes = [
- { pattern = "admin.panspace.dev/api/*", zone_name = "panspace.dev" },
+ { pattern = "admin.example.com/api/*", zone_name = "example.com" },
]
[[d1_databases]]
binding = "DB"
database_name = "chippot-db"
-database_id = "adf93584-5bfa-4376-b08d-b0847709ecfe"
+# 執行 `wrangler d1 create chippot-db`(或在 Cloudflare 後台建 D1)後,把回傳的 id 填這裡:
+database_id = "your-d1-database-id"
migrations_dir = "migrations"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "chippot-proofs"
-# Non-secret Discord config (public values). Bot token is a secret (wrangler secret put).
+# 非機密的 Discord / Access 設定(公開值)。Bot token 是 secret,用 `wrangler secret put DISCORD_BOT_TOKEN`
+# 或後台 Worker → Settings → Variables and Secrets 設定,不寫在這裡。
[vars]
-DISCORD_APPLICATION_ID = "1510355256498978917"
-DISCORD_PUBLIC_KEY = "f322b974d23880e58e830ed8ac9b587ee48d1beb16887efb0bad6617b914e2de"
-WEB_ORIGIN = "https://pay.panspace.dev"
-ADMIN_ORIGIN = "https://admin.panspace.dev"
-ACCESS_TEAM_DOMAIN = "panspace"
-ACCESS_AUD = "6682958aadf8ca528792922ff3c7a0756ae8a15976180343ce85cb09cbf6f508"
-# ⚠️ Set this to YOUR Cloudflare Access email before deploying — it's the admin allow-list.
-# Deploying with the placeholder will lock you out of the admin UI.
-ACCESS_ALLOWED_EMAILS = "owner@example.com"
+DISCORD_APPLICATION_ID = "your-discord-application-id"
+DISCORD_PUBLIC_KEY = "your-discord-public-key"
+WEB_ORIGIN = "https://pay.example.com"
+ADMIN_ORIGIN = "https://admin.example.com"
+ACCESS_TEAM_DOMAIN = "your-team-name"
+ACCESS_AUD = "your-access-aud"
-# 01:00 UTC = 09:00 Asia/Taipei, daily.
+# 01:00 UTC = 09:00 Asia/Taipei,每天。
[triggers]
crons = ["0 1 * * *"]