Files
Arcrun/cypher-executor/src/routes/portal-data.ts
T
uncle6me-web b223a69884 fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:58 +08:00

956 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RAG Portal 查詢面 — P3/portal/data/* server-side enforceportal-auth design §3.3/§3.4/§5
* Gitea #24/#25)。本檔是本 SDD 的**安全核心**。
*
* 安全模型(design §3.3,與 console 的關鍵差異):
* - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/***portal 前端絕不持有租戶字串**,
* 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search
* 所以 enforce 全在 serversession → 回讀 user record(唯一真相源)→ 取 libraries →
* server 注入 owner_idlibrary 後轉發 KBDB。
* - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成
* 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。
* - ["*"]=全庫:只注 owner_id、不注 librarydesign §3.3)。
*
* 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**,
* 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403D-4)。
*
* 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB baseP1 的 library filter),
* graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、
* /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。
*
* log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。
*/
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
// ── tenant workflow in-process 執行(portal-demo-suite)───────────────────────
//
// 為什麼:RAG self-hosted 實例不部署 kbdb-graph-plugin workergraphBase 直連會 1042
// Arcrun#57);graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在
//WEBHOOKS KV `{tenant}:wf:{name}`,與 /webhooks/named 同一資料源)。
// 怎麼接:直接 import executeWebhookGraphwebhooks-named.ts trigger/query 端點同一個
// 執行入口)在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname
//global_fetch_strictly_public 下打自己=self-loop,見 /portal/data/workflows 同款註解)。
/** 讀 tenant 的 named workflow graph`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
// #108workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`),
// 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。
const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
if (!raw) return null;
try {
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
return rec.graph && typeof rec.graph === 'object' ? rec.graph : null;
} catch {
return null; // 壞 record 視同不存在(呼叫端各自誠實回報)
}
}
/** workflow 最終節點輸出常見兩形:本體即結果,或再包一層 data(http_request 類零件慣例)。取有目標欄位的那層。 */
function unwrapWorkflowData(data: unknown, key: string): Record<string, unknown> {
const outer = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
if (key in outer) return outer;
const inner = outer.data;
if (inner && typeof inner === 'object' && key in (inner as Record<string, unknown>)) {
return inner as Record<string, unknown>;
}
return outer;
}
/**
* graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count}
*(前端 doGraphSearch 讀 neighbors/edgescountneighbors 數,重算不信 workflow 自報)。
* 純函式(單測用 export)。
*/
export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } {
const layer = unwrapWorkflowData(data, 'neighbors');
const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : [];
const edges = Array.isArray(layer.edges) ? layer.edges : [];
return { neighbors, edges, count: neighbors.length };
}
/**
* 出處清單按 page_name 去重(t129):
* rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。
* 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。
* page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。
* 純函式,單測用 export。
*/
export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
const seen = new Map<string, { item: Record<string, unknown>; count: number }>();
for (const s of sources) {
if (!s || typeof s !== 'object') continue;
const item = s as Record<string, unknown>;
const page = typeof item.page_name === 'string' ? item.page_name :
typeof item.page === 'string' ? item.page : '';
const existing = seen.get(page);
if (existing) {
existing.count += 1;
} else {
seen.set(page, { item, count: 1 });
}
}
return [...seen.values()].map(({ item, count }) =>
count > 1 ? { ...item, hit_count: count } : item,
);
}
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
return c.json({ error: '找不到這筆資料' }, 404);
}
/** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'design §3.2 fallback,與 KBDB P1 同語意)。 */
export function entryLibrary(entry: { metadata_json?: string | null }): string {
try {
const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null;
if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library;
} catch {
/* metadata 壞掉 → 視同未標記 */
}
return 'general';
}
/** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */
function canReadLibrary(userLibraries: string[], library: string): boolean {
return userLibraries.includes('*') || userLibraries.includes(library);
}
/**
* 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在
* KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。
* 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry、
* 內部型別 entryvalueslot 值外漏的無標題雜項列、workflow=工作流定義——都是系統內部件,
* 不是給搜尋用戶看的內容;2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」)。
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
* 純函式(單測用 export)。
*/
// execution_log/execution_log_usageKV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
entries: T[],
): T[] {
return entries.filter((e) => {
if (e.entry_type && INTERNAL_ENTRY_TYPES.has(e.entry_type)) return false;
try {
const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null;
if (meta && meta.status === 'deprecated') return false;
} catch {
/* parse 失敗 → 保留 */
}
if (String(e.content ?? '').startsWith('(舊管線產物')) return false;
return true;
});
}
/**
* CJK/ASCII 邊界插空白正規化(t95):
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
* 只動查詢端,不動索引端。純函式,單測用 export。
*/
export function normalizeCjkQuery(q: string): string {
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
const isCjk = (c: string) => /[぀-鿿豈-﫿]/.test(c);
const isAsciiAlnum = (c: string) => /[぀-鿿豈-﫿]/.test(c);
let result = '';
for (let i = 0; i < q.length; i++) {
const ch = q[i];
if (result.length > 0) {
const prev = result[result.length - 1];
if (prev !== ' ' && ch !== ' ' &&
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
result += ' ';
}
}
result += ch;
}
return result;
}
/**
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
*/
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
const term = normalizeCjkQuery(searchTerm).toLowerCase();
if (!term) return null;
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/**
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
*/
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
try {
// owner=null 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== 'number') return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
/**
* 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。
*
* leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』
* 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾
* 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑**
* (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」:
* owned>0 → 有資料
* owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0)
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
* owned=null → 讀不到 → 畫面說讀不到
*/
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
const owned = await tripletCount(env, tenant);
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
return { owned, any: await tripletCount(env, null) };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
const nodeNames = new Set<string>();
for (const r of body.records) {
const v = r?.values;
if (!v || typeof v !== 'object') continue;
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
}
return findBestNodeMatch(searchTerm, [...nodeNames]);
} catch {
return null; // fallback 失敗靜默略過,原本 0 結果直接回
}
}
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//graph 走 /portal/data/graph/*)。server 注入 owner_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
// semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。
portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const qRaw = c.req.query('q');
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
// 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢)
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
if (c.req.query('mode') === 'semantic') {
params.set('mode', 'semantic');
// 🔴 t183leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」
// ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答):
// Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
// kbdb 早就支援 min_score`kbdb/src/embed.ts:225`issue #67),
// 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。
//
// 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」):
// 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關
// ─────────────────────── 斷崖 ───────────────────────
// 0.742 姨媽說故事 0.740 ax-academy 0.739×8 n8n 版本比較表 ← 全是雜訊
// 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。
//
// 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數**
// ——那等於關掉閾值,正是 t183 要修的病本身。
//
// 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。
// 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後
// 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。
// 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。
// ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL),
// portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。**
const msRaw = Number(c.req.query('min_score'));
if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
}
const entryType = c.req.query('entry_type');
if (entryType) params.set('entry_type', entryType);
const limit = c.req.query('limit');
if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);
const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
if (!res.ok) {
// 錯誤回應照原樣透傳(誠實,不加工)
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
// Arcrun#46 治標:server-side 濾掉舊管線 deprecated 殘影再回(count 重算)。
// 上游修好(清資料/重建索引)後,這段連同 filterDeprecatedEntries 一起拔掉。
const body = (await res.json().catch(() => null)) as
| { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number }
| null;
if (!body || !Array.isArray(body.entries)) {
// 回應不是預期形狀 → 照原樣回(不因治標把正常錯誤形狀吃掉)
return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502);
}
const entries = filterDeprecatedEntries(body.entries);
return c.json({ ...body, entries, count: entries.length });
}),
);
// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**design §5):
// ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀;
// ② entry 的 libraryNULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。
// 兩者不符與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/entries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
const entry = body.entry;
if (!entry) return notFound(c);
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
}),
);
// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘):
// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。
// 放行後的資料源二選一(Arcrun#57):
// ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它(RAG self-hosted
// 實例不部署 kbdb-graph-plugin,直連會 1042),輸出映射成與 plugin 相同形狀;
// ② 沒有 → fallback 原本 graphBase 直連 pluginMira/leo21c 實例相容,行為一字不變)。
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = knowledgeOwner(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
if (wfGraph) {
const depthRaw = c.req.query('depth') ?? '';
const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2;
const result = await executeWebhookGraph(
c.env,
wfGraph,
// t116: 補傳 kbdb_baset128: 補傳 templateworkflow fetch_triplets.url 用 {{input.template}}
{ node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
'graph_neighbors',
tenant,
c.executionCtx,
);
if (!result.success) {
// workflow 執行失敗 → 誠實 502(不假裝無關聯)
return c.json({ error: `graph_neighbors workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
return c.json(mapGraphWorkflowOutput(result.data));
}
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
const resText = await res.text().catch(() => '');
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
Array.isArray(data.edges) && data.edges.length === 0) {
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
if (fallbackName && fallbackName !== nodeName) {
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
}
}
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
}
}),
);
// GET /portal/data/graph/overview — 書庫總圖(Arcrun#39 藏書地圖的 GUI 資料切片):
// 全租戶 active 三元組 → {nodes:[{name,degree}], edges:[{subject,predicate,object}]}。
// 權限=與 neighbors 同一道 D-4 graph 粗閘;資料直讀 KBDB records(與 search 同 kbdbFetch 路徑,
// 不經 graph plugin、不撞 1042)。邊數上限 500(demo 量級遠低於此;超限誠實截斷並回 truncated)。
portalDataRouter.get('/portal/data/graph/overview', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = knowledgeOwner(c.env);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
tripletCensus(c.env, tenant),
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown> }[] }
| null;
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
if (!body || !Array.isArray(body.records)) {
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = new Set<string>();
const edges: { subject: string; predicate: string; object: string }[] = [];
const degree = new Map<string, number>();
let truncated = false;
for (const r of records) {
const v = r && typeof r.values === 'object' && r.values ? r.values : null;
if (!v) continue;
if (v.status === 'deprecated') continue;
const s = typeof v.subject === 'string' ? v.subject.trim() : '';
const o = typeof v.object === 'string' ? v.object.trim() : '';
if (!s || !o) continue;
const p = typeof v.predicate === 'string' ? v.predicate : '';
const key = `${s}${p}${o}`;
if (seen.has(key)) continue;
seen.add(key);
if (edges.length >= EDGE_CAP) { truncated = true; break; }
edges.push({ subject: s, predicate: p, object: o });
degree.set(s, (degree.get(s) ?? 0) + 1);
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
// unreadable :連條數都讀不到 → 只能說讀不到
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = 'unreadable';
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
else if (census.any === null) emptyReason = 'unreadable';
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
empty_reason: emptyReason,
});
}),
);
// GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面,
// 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。
// 機制同 graph_neighborsin-process 執行 tenant 的 rag_chat workflowexecuteWebhookGraph
// 絕不 fetch 自己 hostname);workflow 不存在 → 誠實 404,不假裝這實例有問答能力。
portalDataRouter.get('/portal/data/chat', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const question = c.req.query('question');
if (!question) return c.json({ error: 'question 必填' }, 400);
const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat');
if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404);
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ question },
'rag_chat',
knowledgeOwner(c.env),
c.executionCtx,
);
if (!result.success) {
// workflow 執行失敗 → 誠實 502(不把錯誤編成答案)
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
// 回 workflow 回應內層 data{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
// t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
const inner = unwrapWorkflowData(result.data, 'answer');
const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
return c.json({
answer: typeof inner.answer === 'string' ? inner.answer : '',
sources: dedupeSourcesByPage(rawSources),
graph_facts: inner.graph_facts ?? null,
});
}),
);
// ── 上傳(portal-demo-suite)────────────────────────────────────────────────
/**
* 上傳檔名驗證(純函式,單測用 export):
* - 去路徑分隔(/ \)只取最後一段(擋 ../ 穿越)、去控制字元;
* - 空名/以 . 開頭(隱藏檔/./..)→ 無效(null);
* - 強制 .md 結尾(.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件,一律當 markdown 收件);
* - 最終長度限 100(含副檔名),超過 → 無效。
*/
export function sanitizeUploadFilename(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
let name = (raw.split(/[/\\]/).pop() ?? '').trim();
// eslint-disable-next-line no-control-regex
name = name.replace(/[\u0000-\u001f\u007f]/g, '');
if (!name || name.startsWith('.')) return null;
if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md');
if (!/\.md$/i.test(name)) name = `${name}.md`;
if (name.length > 100) return null;
return name;
}
// base64 內容上限(收 .md/.txt 知識文件,2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘;防拿上傳面塞大檔)
const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024;
// POST /portal/data/upload — body {filename, content_b64}portal-demo-suite)。
// requirePortalUser 閘;server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的
// docs/{filename}——token 只在 server 側,前端永遠拿不到(同 kbdb-proxy token 慣例)。
// 未設 upload bindings(三者任一缺)→ 404「未啟用上傳」(Mira 零影響:功能不存在)。
portalDataRouter.post('/portal/data/upload', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404);
const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null;
const filename = sanitizeUploadFilename(body?.filename);
if (!filename) return c.json({ error: 'filename 無效(不可含路徑、不可空、長度限 100)' }, 400);
const contentB64 = body?.content_b64;
if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400);
if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大(上限約 2 MB' }, 413);
const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, '');
const repo = c.env.PORTAL_UPLOAD_REPO ?? '';
let res: Response;
try {
res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`,
},
body: JSON.stringify({
content: contentB64,
// commit 訊息帶上傳者(display_name 非機密),收件溯源用;不進任何內容
message: `portal 上傳:docs/${filename}${auth.user.values.display_name ?? 'portal user'}`,
}),
});
} catch (e) {
return c.json({ error: `知識庫收件服務不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
}
if (res.status === 409 || res.status === 422) {
// Gitea 同 path 已存在(版本差異回 409 或 422)→ 統一誠實回 409
return c.json({ error: '同名文件已存在,請改檔名後重傳' }, 409);
}
if (!res.ok) {
return c.json({ error: `知識庫收件失敗(HTTP ${res.status}` }, 502);
}
return c.json({ success: true, filename, path: `docs/${filename}` });
}),
);
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
//trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403/ all / off(整頁不存在 → 404)。
portalDataRouter.get('/portal/data/workflows', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
if (setting === 'off') return notFound(c);
if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) {
return c.json({ error: '需要 admin 權限' }, 403);
}
// 資料源與 /webhooks/named /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
const tenant = knowledgeOwner(c.env);
const prefix = `${tenant}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
list.keys.map(async (k) => {
const name = k.name.slice(prefix.length);
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
let description = '';
let created_at = '';
let cron_expr: string | undefined;
if (raw) {
try {
const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string };
description = rec.description ?? '';
created_at = rec.created_at ?? '';
cron_expr = rec.cron_expr;
} catch {
/* 壞 record 誠實留空 */
}
}
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
let last_execution: { timestamp: string; verdict?: string } | null = null;
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
);
const execBody = await execRes.json().catch(() => null) as {
success?: boolean;
execution?: { verdict: string; recorded_at: number } | null;
} | null;
if (execRes.ok && execBody?.success && execBody.execution) {
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
}
return { name, description, created_at, cron_expr, last_execution };
}),
);
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
}),
);
// ═══════════════════════════════════════════════════════════════════════════
// 授權的 AIarcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
// ═══════════════════════════════════════════════════════════════════════════
//
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
//
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
// 於是查詢時只好去找一把**服務內部金鑰**KBDB_INTERNAL_TOKEN)直打 KBDB——
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
//
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
// ① 一律 requirePortalUsersession → 回讀 user record → 停用即時生效),
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
// 呼叫端自己帶租戶字串=繞過庫過濾),
// ③ 越權與不存在同回 404(不洩存在性)。
//
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
/**
* record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。
*
* 為什麼不比照 entry 用 'general' fallbackentry 是知識內容(庫是它的第一屬性,沒標就歸
* general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…),
* 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback
* 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。
* 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。
*/
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
const lib = values?.library;
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
}
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: TenantId,
libraries: string[],
): boolean {
if (!isOwnedBy(rec.owner_id, tenant)) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
//
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
//
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
portalDataRouter.get('/portal/data/map', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'no_library_grant',
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
});
}
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
if (allowed.length > 0) {
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
}
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
if (body.libraries.length > 0) {
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'filtered_out',
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
});
}
const census = await tripletCensus(c.env, tenant);
if (census.owned === null || (census.owned === 0 && census.any === null)) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'unreadable',
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
});
}
if (census.owned === 0 && (census.any ?? 0) > 0) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'scope_mismatch',
instance_triplet_count: census.any,
note:
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
});
}
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'confirmed_empty',
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
});
}),
);
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/map/:library', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param('library');
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/templates — template 清單。
// template=虛擬表定義(schema),**全域共享不分租戶**kbdb-proxy 同一裁定,leo 2026-06-14):
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
portalDataRouter.get('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, '/templates');
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// POST /portal/data/templates — 建 templatename + slots)。
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
// created_by 記租戶(溯源),template 本身全域可見可用。
portalDataRouter.post('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as
| { name?: unknown; slots?: unknown; description?: unknown }
| null;
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: 'name 與 slots[] 必填' }, 400);
}
const res = await kbdbFetch(c.env, '/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: knowledgeOwner(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
| null;
if (!body || !Array.isArray(body.records)) {
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
return c.json({ success: true, records, count: records.length });
}),
);
// GET /portal/data/records/:recordId — 單筆 record。
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
// POST /portal/data/records — 依 template 填一筆 record。
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
portalDataRouter.post('/portal/data/records', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
}
const body = (await c.req.json().catch(() => null)) as
| { template?: unknown; values?: unknown }
| null;
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
return c.json({ error: 'template 與 values 必填' }, 400);
}
const values = body.values as Record<string, unknown>;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
}
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
//
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
//
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。
//
// 🔴 t2132026-08-08InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題
// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/
// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的
// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時
// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`X-Arcrun-API-Key
// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見
// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon
// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出
// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。
//
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics()
// portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。
portalDataRouter.get('/portal/data/diagnostics', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant = knowledgeOwner(c.env);
const core = await buildDiagnostics(c.env, tenant);
return c.json({
generated_at: new Date().toISOString(),
instance_url: new URL(c.req.url).origin,
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
...core,
});
}),
);