# portal-auth — Tasks（實作切分，每階段可獨立 PR）

> 狀態：**全部未動工——本 SDD 待總管/leo 審後才開工**（B 類流程：SDD 審 → 實作 PR → component-pr-review-standard → 總管 review → gated 部署 leo21c）
> 對應：Gitea #24/#25；design.md 各 §

依賴鏈：P1 → P2 → P3 → P4（P2 不依賴 P1 可並行起，但 P3 要兩者都齊）。

---

## P1 — KBDB「庫」filter 地基（design §3.2/§3.3）｜觸碰：`kbdb/`

- [x] 開工第一件事：實測 Vectorize metadata filter `$in` 支援與否（決定主路徑 vs fan-out fallback）
  - **核實結論（2026-07-14）**：**支援，走主路徑 `$in`，不需 fan-out fallback**。證據：① 官方文件
    developers.cloudflare.com/vectorize/reference/metadata-filtering/ 明列 8 運算子
    `$eq/$ne/$in/$nin/$lt/$lte/$gt/$gte`，「For $in and $nin, filter object values can be arrays of
    string, number, boolean, or null values」；② 本 repo `@cloudflare/workers-types@4.20260702.1`
    原生 typing `VectorizeVectorMetadataFilterCollectionOp = "$in" | "$nin"`（index.d.ts:15782），
    wrangler 4.98.0。限制：metadata filtering 只對 2023-12-06 之後建的 index 有效（本專案 index 皆是）。
    本機無 Vectorize runtime，`$in` 的**線上實跑**併入部署排練驗（見部署清單）。
- [x] `/entries/search`＋`/entries` 加 `library` 多值參數（D1 json_extract IN＋NULL→general fallback）
  - 實作註：SQL 用 `COALESCE(json_extract(metadata_json,'$.library'),'general') IN (…)`——與 design §3.3
    的 OR 形狀語意完全等價（單組佔位符較簡）。本機 miniflare＋local D1 十案例實跑全過（PR 附證據）。
- [x] embed upsert metadata 加 `library` 欄；semanticSearch filter 支援 library（**$in 主路徑**，fan-out 不需）
  - 實作註：Vectorize 端在**寫入時正規化**（未標記→`'general'`）——Vectorize filter 做不了 COALESCE，
    寫入端蓋章後查詢端單純 `$in`；與 D1 查詢端 fallback 語意對齊。
- [x] Vectorize `library` metadata index 建立步驟＋reindex backfill 寫進部署清單
  - **部署清單（待雲端排練驗——本機無 Vectorize runtime，semantic 路徑 code 完成、線上實跑未驗）**：
    1. `wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name library --type string`
    2. `POST /embed/backfill {"reindex":true}` 分批到 remaining=0（index 只收建立後 upsert 的向量）
    3. 驗收抽查：semantic 帶 `library=` 過濾命中／不帶行為不變
    4. 掛號：`cli/src/lib/deploy.ts` `ensureVectorizeMetadataIndexes()` 待補 `library`（cli/ 屬 P1
       派工範圍外「只動 kbdb」，隨 P3 或部署 PR 補——kbdb/wrangler.toml 註解已標）
- [x] cypher `kbdb-proxy` 透傳 `library` 參數（供 owner/admin 面用；portal 面走 P3 的注入，不經這）
  - ⚠️ 範圍註（2026-07-14）：P1 派工紅線「只動 kbdb、不碰 cypher-executor」與本項矛盾——照窄範圍
    執行，本項順延（一行 query 透傳，隨 P3 動 cypher 時一併）。
  - ✅ 已隨 P3 補上（2026-07-14 P3 PR）：`/kbdb/search` 與 `/kbdb/entries` 透傳白名單各加 `library`。
- [x] 測試：D1 filter 單元測、NULL fallback、多值、semantic filter（mock VECTORIZE）
  - `kbdb/tests/library-filter.test.ts` 11 項（SQL 形狀/route 解析/向後相容/降級仍 enforce/$in 構造/
    寫入端正規化）＋既有 6 項全綠（17/17）；tsc exit 0。
- **驗收**：curl `/entries/search?q=&library=finance` 只回 finance＋未標記條目歸 general 可驗；semantic 同
- **工程量**：小-中（0.5–1 個 CC 工作天）

## P2 — portal_user 模型＋認證 API（design §2/§4）｜觸碰：`cypher-executor/`（新 route 檔）

- [x] seed template `portal_user`＋`portal_library`（init-seed 慣例）
  - 實作註（2026-07-14）：種子資料 `cypher-executor/src/lib/portal-seeds.ts`（*-seeds.ts 慣例類別）；
    冪等 ensure 在 `routes/portal.ts` `ensurePortalTemplates()`，掛進 `POST /init/seed`（回應加
    `portal_templates` 區塊）＋ bootstrap 前自動 ensure（沒跑過 seed 也能 bootstrap）。
- [x] PBKDF2-SHA256 雜湊模組（600k iters、常數時間比對、`pbkdf2-sha256$…` 格式）
  - `cypher-executor/src/lib/portal-auth.ts`（WebCrypto deriveBits；verify 從儲存格式解析 iterations
    → 換 KDF/參數可共存漸進遷移；constantTimeEqual 同 mcp PR#15 實作）。
- [x] `POST /portal/admin/bootstrap`（console owner session 閘）
- [x] login/logout/session/改密碼（KV `portal_sess:`、TTL var、每請求回讀 record 驗 status）
  - `PORTAL_SESSION_TTL` 進 wrangler.toml [vars]＋types.ts（預設 604800）。
- [x] admin 用戶 CRUD＋libraries 授權＋庫目錄 CRUD（role=admin 閘）
  - 安全註：admin 操作目標 record 需過「email head entry 指回同 record_id」成員驗證（防拿任意
    record_id 改到不相干 KBDB record）；列表/回應一律剝除 password_hash；未帶密碼建帳號 →
    server 產一次性密碼隨回應回傳一次（不落地明碼）。
- [x] 登入失敗節流（5 次/15 分鐘，KV TTL）
- [x] 順手修 `record-crud.ts` updateRecord grow 路徑漏 owner_id（design §2.2 附帶）
  - 修法：首查 JOIN entries 帶回 record 既有 owner_id，grow 的新 entry 沿用。回歸測試
    `kbdb/tests/record-grow-owner.test.ts`（3 項：帶 owner/原無 owner 維持 null/非 grow 不 INSERT）。
- [x] 測試：bootstrap 閘、登入對錯、停用即拒（session 立即失效）、role 閘、雜湊格式、`{tenant}::portal` 子 namespace 隔離（**搜 email 搜不到**）
  - 單元：`cypher-executor/tests/portal-auth.test.ts` 20 項（fetchMock 假 KBDB host，絕不外連）；
    cypher 全套 133/134（唯一失敗＝executor「不存在的零件」pre-existing，stash 複驗）；kbdb 20/20；
    兩包 tsc exit 0。端到端：本機雙 worker（隔離 persist）33/33 全綠，含**搜尋隔離實測**
    （租戶 ns 搜 email/雜湊 0 筆＋對照組 leo::portal 搜得到＝非假綠）與明碼落地抽查 0 筆。
- **驗收**（=#25 驗收）：新增用戶能登入；停用登入被拒＋既有 session 失效；KBDB 無新表；密碼抽查非明碼
- **工程量**：中（約 1 個 CC 工作天）

## P3 — `/portal` UI：登入殼＋搜尋頁＋設定頁＋scope enforce（design §1/§3.3/§5/§6）｜觸碰：`cypher-executor/`

- [x] `/portal` HTML 殼（重用 console 樣式/搜尋 view 抽共用 helper；`CONSOLE_BRAND` 品牌；零 Mira 字樣）
  - 實作註（2026-07-14）：`routes/portal-ui.ts` 單檔 HTML＋原生 JS（console 同形態）。**UI 重用取捨＝
    複製 console 樣式子集而非抽共用模組**——抽共用要動 console.ts（違「console 現狀不動」鐵律），
    代價＝樣式雙份、改版要兩邊同步（PR 說明誠實記）。零 Mira 字樣由測試機械斷言。
- [x] 未登入只見登入殼；登入後兩頁：搜尋（keyword/semantic/graph 三模式＋source 溯源＋卡片詳頁）＋設定（改密碼/看自己權限/主題）
  - graph 模式按 session `graph_allowed` 顯示（D-4）；工作流頁 admin 額外可見（D-8，
    `PORTAL_SHOW_WORKFLOWS` 預設 admin，唯讀不開 trigger）——前端藏只是 UX，真閘在路由層。
- [x] `/portal/data/*` server-side enforce：session→record→注入 `owner_id`＋`library`；**前端絕不下發租戶字串**
  - `routes/portal-data.ts`（安全核心）：caller 自帶 owner_id/library 一律被 server 值靜默覆蓋
    （e2e 攻擊實測）；`["*"]`＝不注 library；HTML 殼經測試機械斷言零租戶字串/零
    X-Arcrun-API-Key/不打 /kbdb/*。
- [x] 卡片詳頁逐筆驗 library（越庫 id 直讀 → 404）
  - 雙重驗證：owner_id=CONSOLE_TENANT（防拿跨租戶 id 直讀）＋library∈用戶集合（NULL→general）；
    越庫/跨租戶/不存在**同一句 404**（不洩存在性，e2e 逐字比對）。
- [x] graph 粗閘（D-4：無 graph 來源庫權限 → 模式不顯示＋API 403）
  - 來源庫＝portal_library `graph_source='true'` 集合（seed 補 slot＋ensurePortalTemplates 冪等
    grow 舊 template 缺的 slots；admin libraries PATCH 可標記/取消），**沒有任何標記→預設
    ['general']**（D-4 定案）；無權 403、前端模式鈕不顯示；停用庫不算來源。
- [x] 測試：curl 帶 user session 直打 data API 驗 filter 繞不過（=#24 驗收 3 的 server-side 證明）
  - 單元：`cypher-executor/tests/portal-data.test.ts` 21 項（fetchMock 假 host，絕不外連）；
    cypher 全套 154/155（唯一失敗＝executor「不存在的零件」pre-existing，stash 複驗）；
    kbdb 20/20；兩包 tsc exit 0。端到端：本機隔離雙 worker（9790/9791，demo 8787/8788 不碰）
    **47/47 全綠**——含越庫 id 直讀 404、攻擊參數被覆蓋、graph 403→授 general 後放行、
    admin 全庫＋跨租戶不可見、權限 PATCH 即時生效、HTML 殼斷言＋inline JS node --check、
    改密碼全鏈。`wrangler deploy --dry-run` 打包過。
- **驗收**（=#24 驗收）：leo21c `/portal` 可開；未登入只見登入殼；三模式＋溯源可用；A 用戶（僅 general）搜不到 finance 內容——UI 與 curl 雙驗
  - 本機端到端已全數驗過（上項證據）；**leo21c 線上面＝部署後驗**（gated wrangler 直推，B 類流程；
    semantic 線上路徑仍依 P1 部署清單的 Vectorize index＋reindex）。
- **工程量**：大（1–1.5 個 CC 工作天，UI 是最大件）

## P4 — admin 頁＋工作流顯示（design §6）｜觸碰：`cypher-executor/`

- [x] 帳號管理頁（admin-only nav）：列表/新增/停用/重設密碼＋每帳號庫權限勾選
  - 實作註（2026-07-14）：`portal-ui.ts` 加「管理」頁（v-admin，nav 只對 role=admin 顯示，
    真閘＝/portal/admin/* role 閘 403）。新增未帶密碼 → server `generatePassword` 一次性
    密碼**只在畫面顯示一次**（otpbox，關閉即逝；server 只存 hash）；每帳號庫勾選含
    `["*"]` 全庫選項（勾選連動停用個別庫框）；可勾清單＝庫目錄啟用中 ∪ general ∪ 該帳號
    既有授權（目錄外舊授權不憑空消失）。admin API P2 已交付不重做；本波補
    **last-admin 鎖死保護**（總管派工明定，design 未明寫——PATCH 停用/降級「最後一個
    active admin」→ 409；只在目標是 active admin 且 patch 會使其失格時多打一次 list）。
- [x] 庫目錄管理（登記/停用庫）
  - 同頁第二區：登記（name/display_name/description）＋停用/啟用＋ `graph_source`
    圖譜來源標記（D-4，P3 已有 API，本波補 UI）。
- [x] 工作流頁（唯讀 list＋最近執行；`PORTAL_SHOW_WORKFLOWS` 預設 admin；不開 trigger)
  - **P3 已交付**（`/portal/data/workflows` route＋v-workflows 頁＋D-8 閘），P4 沿用零改動。
- **驗收**：admin 全流程「發帳號→授庫→同仁登入查詢→停用」在 UI 走通；一般用戶看不到 admin 頁與（預設下）工作流頁
  - 單元：`tests/portal-admin.test.ts` 14 項（鎖死保護含「另一 admin 是 disabled 不算數」
    「停用一般 user 不多打 list」；一次性密碼/reset 明碼不進 KBDB body；HTML 殼加 admin 頁後
    仍零租戶字串/零 /kbdb//零 API key/零 Mira）。cypher 全套 168/169（唯一失敗＝executor
    「不存在的零件」pre-existing）；kbdb 20/20；兩包 tsc 0；dry-run 打包過；inline JS
    node --check 過（2 段）。
  - 端到端：本機隔離雙 worker（9790/9791，demo 8787/8788 不碰）**41/41 全綠**——bootstrap→
    admin 建帳號拿一次性密碼→新 user 登入→勾庫後**同一 session 搜尋範圍即時變化**（1 筆→2 筆、
    越庫 404→200）→停用 session 立即失效→重設密碼舊密 401 新密 200→last-admin 409（含降級
    即時生效：原 admin session 打 admin API 變 403）→一般 user 打 admin API 403。
  - leo21c 線上面＝部署後驗（gated wrangler 直推，B 類流程）。
- **工程量**：中（約 1 個 CC 工作天）

---

## Bugfix（P1-P4 交付後）

- [x] **PBKDF2 600k → 100k（T6-cloud 部署抓到的框架蟲，2026-07-14，分支 `fix-pbkdf2-cf-limit`）**：
  CF Workers 正式 runtime PBKDF2 上限 100,000 iterations，600k 時 `crypto.subtle.deriveBits`
  直接拒絕 → `/portal/admin/bootstrap` 真雲 500（uncle6 實撞，證據 arcrun-rag
  `docs/manual/uncle6-deploy-record.md`）；miniflare 無此限制＝本機測試全綠假象。
  修：`portal-auth.ts` `PBKDF2_ITERATIONS` 100_000＋註明平台封頂；design D-5 已加修訂註。
  verify 相容性：迭代數從儲存值解析，補測試「600k 舊 hash 仍通」（miniflare 造）；誠實邊界
  ＝真雲 deriveBits 同樣封頂、600k hash 真雲仍拒，但真雲 bootstrap 從未成功過 → 無存量 hash、
  無遷移面。同 PR 順手：`.component-builds/http_request/wrangler.toml` 補
  `global_fetch_strictly_public` flag（self-hosted 同帳號 fetch 1042 正解，Arcrun#33 同族，
  官方行為不變原理同 cypher 前例 rules/03 1042 段）。

- [x] **Portal 來源回溯超連結（2026-07-18，任務層小改，分支 `feat/portal-source-link`；
  來源＝leo RAG demo 客戶測試回饋「來源回溯 gitea:// 沒辦法點」）**：
  新增可選 var `PORTAL_SOURCE_WEB_BASE`（types.ts 註解含語意）；portal-ui 卡片詳頁「來源回溯」
  與 AI 問答出處列在「var 有設＋source 為 gitea://」時渲染 `<a target=_blank rel=noopener>`
  （gitea://<path>#n → {base}/<path>，chunk 錨點捨棄、路徑逐段 encodeURIComponent、href 過
  esc()）；未設 var 行為一字不變（Mira 零影響）。驗證：tsc 0、portal-auth/portal-data 測試
  48/48 綠、渲染後 client JS new Function 語法檢查過。部署：uncle6 重部＋設 var（leo 閘）。

- [x] **搜尋濾內部型別＋總圖頁（2026-07-18 晚，任務層小改，分支 `feat/portal-library-overview`；
  來源＝leo 客戶測試回饋「無標題雜項列」＋「書庫總圖」指示（原案＝Arcrun#39 藏書地圖的 GUI 切片））**：
  ① `filterDeprecatedEntries` 加內部型別過濾（value＝slot 值外漏雜項列、workflow＝工作流定義），
  #46 上游修好後隨整段拔掉；測試補一案（49/49 綠）。
  ② 新增 `GET /portal/data/graph/overview`（全租戶 active 三元組 → nodes/edges，D-4 graph
  粗閘同 neighbors，kbdbFetch 直讀不經 plugin，邊數上限 500 誠實截斷）＋portal「總圖」頁
  （nav 隨 graph_allowed 顯示；手刻力導向佈局、固定種子確定性、紙感樣式、點節點跳圖譜搜尋；
  頁尾連 00-MAP.md＝#39「人機共用同一份地圖」的文字版）。
  ③ #39 本體（library_map Template/ingest 重算/MCP instructions+get_map）不在本次範圍，仍歸 #39 SDD。

## 第二波（不在本 SDD 動工範圍，掛號）

- MCP token 綁庫集合（design §9；PR#15 擴充，只動 `mcp/`）
- graph 逐節點/逐邊細粒度過濾
- Google OAuth2 fast-follow、審計 log、忘記密碼 email 自助、舊資料 library 回填清潔工
