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 * * *"]