Compare commits

...

2 Commits

Author SHA1 Message Date
uncle6me-web 9cc29f5dfb docs(wiki): Mira Console 完整版收尾——mistakes #24 三層跳脫驗證法+DELETE 假綠缺口、status 更新 (Arcrun#3 console 系) 2026-07-04 17:51:25 +08:00
uncle6me-web f9e44abc59 feat(console): Mira Console 完整版——8 頁紙感定稿視覺 SPA (Arcrun#3 console 系)
依 claude design 定稿(紙感暖黑 2a)+ brief 8 頁資訊架構重寫 /console:
- 單檔 HTML+原生 JS hash routing,零外部資源;紙紋底/明體標題/琥珀強調/
  呼吸球嵌「安/趕/滯」;手機優先 390px + ≥1024px 側欄 + 底部五 tab
- 頁1 登入/首次設定(沿 console-auth v1)|頁2 駕駛艙(dashboard-data,60s 刷新;
  /console/dashboard 免登入獨立頁同步換視覺)|頁3 總庫搜尋(entry_type chips=
  KBDB 真能篩的欄位、共 N 筆中命中 M(誠實 total)、語意未啟用誠實降級 banner)|
  頁4 卡片詳頁+關聯(Markdown 渲染 + kbdb-graph-plugin 鄰居放射圖,經新 proxy
  /kbdb/graph/neighbors/:name;無 triplet →「尚無關聯資料」)|頁5 工作流
  (/webhooks/named list+手動觸發+最近執行;釘選 localStorage;v0 零件/recipes
  查詢收進本頁折疊區不砍功能)|頁6 憑證(新 GET /credentials/catalog D1 目錄
  唯讀,絕不回密文;新增/替換走既有端點;刪除未接 D1(T9)標「即將開通」)|
  頁7 收件匣(新 GET /console/inbox-data,session 鎖——訊息原文屬機敏)|
  頁8 設定(vectorize 狀態探測讀 search 降級訊號、開關標「即將開通」、
  /console/setup/reset 換帳密、系統資訊)

驗證:tsc exit 0;vitest 26/27(1 失敗 stash 複驗 pre-existing);wrangler
deploy --dry-run 打包過;inline JS 求值後 node --check + esc/mdRender/parseAtMs
純函式行為測試全過(含 XSS escape)。未部署,留總管接手。
2026-07-04 17:49:49 +08:00
8 changed files with 1392 additions and 377 deletions
@@ -29,6 +29,17 @@ const CREDS_KEY = 'console:credentials';
const SESSION_PREFIX = 'console_sess:';
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
/**
* 驗證 console session(給其他 route 共用,如 /console/inbox-data)。
* authHeader 形如 "Bearer <token>";有效回 true。
*/
export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise<boolean> {
const token = (authHeader ?? '').match(/^Bearer\s+(\S+)/i)?.[1];
if (!token) return false;
const sess = await env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
return !!sess;
}
interface StoredCredentials {
email: string;
salt: string; // hex
+147 -55
View File
@@ -29,6 +29,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -164,97 +165,188 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
});
});
// GET /console/inbox-data — 收件匣清單(Mira Console 頁 7)。**需 console session**Bearer):
// dashboard-data 只吐計數可免登入;這裡吐訊息原文(leo 丟給 Telegram bot 的指令內容)屬機敏,鎖登入。
// 資料源同 dashboard-data 的 inbox entriesentry_type=inboxcontent JSON {"text","status","result"?})。
consoleDashboardRouter.get('/console/inbox-data', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const tenant = c.env.CONSOLE_TENANT || 'leo';
const entries = await fetchEntries(c.env, tenant, 'inbox', 200);
const items = entries.map((e) => {
const j = parseJsonContent(e);
return {
id: e.id,
text: typeof j?.text === 'string' ? (j.text as string) : (e.content ?? ''),
status: j && j.status === 'done' ? 'done' : 'new',
result: typeof j?.result === 'string' ? (j.result as string) : '',
at: e.created_at,
};
});
return c.json({
items,
new_count: items.filter((i) => i.status !== 'done').length,
total: items.length,
});
});
function renderDashboardHtml(): string {
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arcrun 駕駛艙</title>
<title>Mira 駕駛艙</title>
<style>
:root { color-scheme: dark; }
/* Mira Console 定稿視覺(紙感暖黑「2a」,Mira Style Guide 2026-07-04):
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀 #e8b45a 強調、呼吸狀態球嵌單字。 */
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115; color: #e6e6e6; }
main { padding: 14px; display: grid; gap: 14px; max-width: 560px; margin: 0 auto; }
.card { background: #161922; border: 1px solid #262a33; border-radius: 12px; padding: 16px; }
.light-row { display: flex; align-items: center; gap: 12px; }
.light-emoji { font-size: 44px; line-height: 1; }
.light-word { font-size: 20px; font-weight: 700; }
.beat { color: #9aa0aa; font-size: 13px; margin-top: 8px; }
.bar-wrap { margin-top: 12px; }
.bar-label { font-size: 12px; color: #9aa0aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
.bar { height: 10px; background: #0f1115; border: 1px solid #262a33; border-radius: 999px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: #3a5bfd; border-radius: 999px; transition: width .4s; }
.subhead { font-size: 12px; color: #9aa0aa; margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.subhead:first-child { margin-top: 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
li { font-size: 14px; line-height: 1.5; }
.muted { color: #6b7280; font-size: 13px; }
.inbox { margin-top: 12px; font-size: 13px; color: #9aa0aa; }
.err { color: #f06565; font-size: 13px; }
.stamp { text-align: center; color: #4b5563; font-size: 11px; }
html, body { margin: 0; background: repeating-linear-gradient(0deg,#191410 0px,#191410 3px,#1b1611 3px,#1b1611 4px); color: #ede4d3;
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(232,180,90,.4); display: flex; justify-content: space-between; align-items: baseline; }
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(237,228,211,.5); }
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(237,228,211,.55); }
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
.orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(237,228,211,.6); line-height: 1.55; }
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(63,190,120,.35); } 50% { box-shadow: 0 0 42px 14px rgba(63,190,120,.55); } }
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(232,180,90,.35); } 50% { box-shadow: 0 0 42px 14px rgba(232,180,90,.6); } }
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(217,95,76,.4); } 50% { box-shadow: 0 0 44px 16px rgba(217,95,76,.65); } }
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.brick { padding: 16px; border-radius: 12px; }
.brick.amber { background: rgba(232,180,90,.07); border: 1px solid rgba(232,180,90,.22); }
.brick.plain { background: rgba(237,228,211,.04); border: 1px solid rgba(237,228,211,.14); }
.brick .lbl { font-size: 13.5px; color: rgba(237,228,211,.55); margin-bottom: 6px; }
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: #e8b45a; }
.brick .num small { font-size: 15px; color: rgba(237,228,211,.5); }
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.08); }
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(63,190,120,.3); background: rgba(63,190,120,.05); }
.wait-box.has { border-color: rgba(232,180,90,.45); background: rgba(232,180,90,.05); }
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(237,228,211,.6); margin-bottom: 10px; text-align: center; }
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: #7fe0a8; letter-spacing: .08em; text-align: center; }
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(232,180,90,.1); border: 1px solid rgba(232,180,90,.3); font-size: 16px; line-height: 1.5; }
.wait-item .dm { color: #e8b45a; font-size: 17px; flex: none; }
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(237,228,211,.6); }
.subhead .m { font-size: 13px; color: rgba(237,228,211,.4); }
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(237,228,211,.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
ul.route li.doing { background: rgba(232,180,90,.09); border-color: rgba(232,180,90,.3); }
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
ul.route li.done { color: rgba(237,228,211,.65); }
ul.route li.done .ic { color: #7fe0a8; }
ul.route li.doing .ic { color: #e8b45a; }
ul.route li.todo { color: rgba(237,228,211,.6); }
ul.route li.todo .ic { color: rgba(237,228,211,.35); }
ul.route li.blocked .ic { color: #e58575; }
.muted { color: rgba(237,228,211,.45); font-size: 14px; }
.err { color: #e58575; font-size: 14px; }
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(237,228,211,.35); line-height: 1.8; }
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(232,180,90,.75); text-decoration: none; margin-top: 6px; }
</style>
</head>
<body>
<main>
<div class="card" id="card-light">
<div class="light-row"><span class="light-emoji" id="light-emoji">⏳</span><span class="light-word" id="light-word">載入中</span></div>
<div class="beat" id="beat-line"></div>
<div class="bar-wrap">
<div class="bar-label"><span>今日完成度</span><span id="bar-num"></span></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
<div class="pagehead">
<div class="title serif">Mira<small> 駕駛艙</small></div>
<div class="date serif" id="date-str"></div>
</div>
<div class="orb-row">
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char">…</span></div>
<div>
<div class="orb-title" id="orb-title">載入中</div>
<div class="orb-sub" id="orb-sub"></div>
</div>
</div>
<div class="card">
<div class="subhead">今日路線</div>
<ul id="today-list"><li class="muted">載入中...</li></ul>
<div class="subhead" id="week-head" style="display:none">本週</div>
<ul id="week-list"></ul>
<div class="subhead" style="margin-top:14px">等你的事</div>
<ul id="wait-list"></ul>
<div class="inbox" id="inbox-line"></div>
<div class="bricks">
<div class="brick amber">
<div class="lbl">今日完成</div>
<div class="num"><span id="done-n"></span><small> / <span id="total-n"></span> 件</small></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
</div>
<div class="brick plain">
<div class="lbl">收件匣未處理</div>
<div class="num"><span id="inbox-n"></span><small> 條</small></div>
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
</div>
</div>
<div class="stamp" id="stamp"></div>
<div class="wait-box" id="wait-box">
<div class="wait-head">等你的事</div>
<div id="wait-body" class="wait-none">載入中…</div>
</div>
<div class="subhead"><span class="t">今日路線</span><span class="m">狀態即時同步</span></div>
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
<ul class="route" id="week-list"></ul>
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
<a class="enter" href="/console">進入完整控制台 </a>
</main>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const LIGHT = { green: ['🟢', '系統運轉中'], yellow: ['🟡', '落後趕工中'], red: ['🔴', '卡住'] };
const STATUS_EMOJI = { done: '', doing: '🔄', todo: '⬜', blocked: '⛔' };
const LIGHT = {
green: { ch: '', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
};
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function taskLine(t) {
return '<li>' + (STATUS_EMOJI[t.status] || '⚠️') + ' ' + esc(t.title) + '</li>';
const cls = ICONS[t.status] ? t.status : 'blocked';
const ic = ICONS[t.status] || '●';
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
}
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
const now = new Date();
$('date-str').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
async function load() {
try {
const res = await fetch('/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const [emoji, word] = LIGHT[d.light] || ['⚪', d.light];
$('light-emoji').textContent = emoji;
$('light-word').textContent = word;
$('beat-line').textContent = d.last_beat
? '最後心跳 ' + d.last_beat.actor + ' ' + d.last_beat.ago_minutes + ' 分鐘前'
: '尚無心跳';
const cfg = LIGHT[d.light] || LIGHT.green;
const orb = $('orb');
orb.style.background = cfg.grad;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('orb-char').textContent = cfg.ch;
$('orb-title').textContent = cfg.title;
$('orb-sub').textContent = d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料';
const done = d.today_done || 0, total = d.today_total || 0;
$('bar-num').textContent = done + ' / ' + total;
$('done-n').textContent = done; $('total-n').textContent = total;
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
$('inbox-n').textContent = d.inbox_new || 0;
const wb = $('wait-box'), body = $('wait-body');
if (d.waiting && d.waiting.length) {
wb.classList.add('has');
body.className = '';
body.innerHTML = d.waiting.map((w) => '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>').join('');
} else {
wb.classList.remove('has');
body.className = 'wait-none';
body.textContent = '無,你不用做任何事';
}
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="muted">今日無排定項目</li>';
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
$('week-head').style.display = week.length ? '' : 'none';
$('week-list').innerHTML = week.map(taskLine).join('');
$('wait-list').innerHTML = (d.waiting && d.waiting.length)
? d.waiting.map((w) => '<li>🙋 ' + esc(w.title) + '</li>').join('')
: '<li class="muted">無,你不用做任何事</li>';
$('inbox-line').textContent = '📥 收件匣未處理:' + (d.inbox_new || 0) + ' 件';
$('stamp').textContent = '更新於 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
} catch (e) {
$('light-emoji').textContent = '';
$('light-word').textContent = '讀不到狀態';
$('beat-line').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
$('orb-char').textContent = '';
$('orb-title').textContent = '讀不到狀態';
$('orb-sub').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
}
}
load();
File diff suppressed because it is too large Load Diff
+25
View File
@@ -205,6 +205,31 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
return c.json({ success: true, name });
});
// GET /credentials/catalog — D1 目錄唯讀 listMira Console 完整版,Arcrun#3 console 系)。
// 只回 metadataname/service/sensitivity/created_at/last_used_at),**絕不回密文值**——
// 密文在 Workers Secrets,本 worker 自己也讀不回(D19「擁有目錄,不擁有內容物」)。
// 與舊 GET /credentialsKV 名稱清單)並存:這裡是新制 D1 目錄;舊 KV 寫入的看不到(T5 已知誠實缺口)。
// 註冊須在 GET /credentials/:name 類 route 之前?本檔無 :name GET route,無攔截問題。
credentialsRouter.get('/credentials/catalog', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
try {
const rows = await c.env.CREDENTIALS_DB
.prepare(
`SELECT name, service, sensitivity, created_at, last_used_at
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
)
.bind(apiKey)
.all<{ name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null }>();
return c.json({ success: true, credentials: rows.results ?? [], total: (rows.results ?? []).length });
} catch (e) {
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍)
credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
+27
View File
@@ -189,6 +189,33 @@ kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// ── graphkbdb-graph-plugin proxyMira Console 卡片詳頁「關聯視圖」)───────────────
//
// 純轉發(rule 07):圖能力真身在 kbdb-graph-plugin workerGET /graph/neighbors/:name
// 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN
// 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。
function graphBase(env: Bindings): string {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, '');
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin worker 沒部署 / 不可達 → 誠實回報(前端顯示「關聯服務未部署」而非假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
}
});
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
+4
View File
@@ -78,6 +78,10 @@ export type Bindings = {
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
CONSOLE_TENANT?: string;
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
KBDB_GRAPH_URL?: string;
};
// 重新 export Cloudflare Workers ExecutionContext 以便其他 module 用
+20
View File
@@ -512,6 +512,26 @@ array-of-tables`[[x]]`)不用 inline array。改 toml 後用 `wrangler --dr
---
## 24. Worker 內嵌單檔 HTML 的三層跳脫=隱形破版源;驗證要「求值後」做(2026-07-04Mira Console 完整版)
**情境**console 頁是 TS template literal 包整份 HTMLinline JS。字面上要穿三層:
TS template`\\d` 才輸出 `\d`、內層禁裸 backtick`${`)→ inline JSregex、字串拼接)→ HTML。
肉眼/tsc 都看不出內層 JS 是否被跳脫層弄壞——tsc 只驗 TS 合法,內嵌 JS 語法錯了照樣綠。
**防呆法(本次固化的驗證鏈,比 tsc 多三步)**
1. 用 node `new Function` 把 template literal **求值成真 HTML**(模擬 worker 輸出);
2. 抽出 `<script>` 內容跑 `node --check`inline JS 語法層);
3. 對 esc/mdRender/parseAtMs 這類純函式抽出來跑**行為斷言**(含 XSS escape、epoch 秒/毫秒/ISO 容錯)——
regex 跳脫錯誤只有這層抓得到(語法對但行為錯)。
腳本模式見 2026-07-04 session scratchpadcheck-html.js / check-fns.js),下次改 console 直接複刻。
**連帶誠實缺口(憑證頁)**`DELETE /credentials/:name` 仍是舊 KV 路徑(T9 範圍未動),
但目錄已改 D1(新 `GET /credentials/catalog`)——UI 若直接接 DELETE 會假綠(KV 刪了、D1 目錄還在、
Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,**不要接舊端點裝有效**。
同型判準:介面接動作前先問「這端點操作的儲存層,跟我列表讀的是同一層嗎」。
---
## 快速檢查清單(做新功能前)
- [ ] 這是工作流還是零件?問「有必要嗎?」
+8 -1
View File
@@ -15,7 +15,14 @@ metadata:
## 📍 當前位置
> **2026-07-02 本 session雲端工人 T-loop1dArcrun#3 搜尋/控制台頁 v0,跨多 repo 第二次壓測**
> **2026-07-04 本 sessionMira Console 完整版 UIArcrun#3 console 系,總管派工**
> - **實作(commit f9e44ab**`/console` 依 claude design 定稿(紙感暖黑「2a」,`system-dev/docs/6-user/Mira Console 設計規劃/` 於頂層 repo)+ brief 8 頁資訊架構全面重寫成單檔 SPA(hash routing、零外部資源、手機優先+≥1024px 側欄+底部五 tab)。`/console/dashboard` 免登入獨立頁同步換視覺(呼吸球「安/趕/滯」)。
> - **後端小補(全唯讀/薄殼)**:① `GET /credentials/catalog`D1 目錄 metadata list,絕不回密文,D19)② `GET /console/inbox-data`inbox 清單,session 鎖——訊息原文屬機敏,計數才免登入)③ `GET /kbdb/graph/neighbors/:name`cypher 代轉 kbdb-graph-plugintoken 只在 server 側;plugin base = `KBDB_GRAPH_URL``kbdb-graph-plugin.<subdomain>.workers.dev` 現算)④ types 加 `KBDB_GRAPH_URL?`
> - **誠實分層**:語意搜尋未啟用→banner 讀 search 降級訊號(不假結果);搜尋 chips 用 entry_typeKBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」分類);憑證「刪除」標即將開通(DELETE 仍舊 KV 路徑,接了會假綠,見 mistakes #24);設定頁 vectorize「開關」標即將開通(需部署端 kbdb_embed);關聯查無 triplet →「尚無關聯資料」。v0 零件/recipes 查詢區收進工作流頁折疊區(不砍功能)。
> - **驗證(本機,未部署)**tsc exit 0vitest 26/271 失敗 stash 複驗=pre-existing「不存在的零件回傳失敗」);`wrangler deploy --dry-run` 打包過;inline JS 求值後 `node --check` + 純函式行為測試全過(XSS escape/時間容錯,驗證法固化 mistakes #24)。
> - **部署注意(留總管)**:不跑 acr updatemistakes #23 codeload 陷阱),走 leo21c wrangler 直推 cypher-executor 單 worker;關聯視圖需 leo21c 有部署 kbdb-graph-plugin(沒部署→前端誠實顯示不可達,不擋其他頁);`CONSOLE_TENANT`/`WORKER_SUBDOMAIN` 沿既有注入。SDD 缺口沿 07-02 未解(search-console SDD 未建,rule 4.3 需 richblack 確認,本次照派工先實作、誠實記錄)。
>
> **2026-07-02 上個 session(雲端工人 T-loop1dArcrun#3 搜尋/控制台頁 v0,跨多 repo 第二次壓測)**
> - **實作**`cypher-executor/src/routes/console.ts`GET /console,單檔 HTML+原生 JS 薄殼)+ `index.ts` 掛載。三查詢區全打既有端點:`/kbdb/search``/workflows/search`(同源,X-Arcrun-API-Key)、components 打「同帳號」`arcrun-registry.<subdomain>.workers.dev`WORKER_SUBDOMAIN 現算,不硬打官方 registry.arcrun.dev)、recipes 打 `/public-recipes`(公庫免 auth)。vectorize 狀態直接讀 KBDB search 回應的 `mode`/`capability_hint`,沒開新端點。tsc/vitest 綠(既有 1 個無關失敗,`不存在的零件回傳失敗` 測試,stash 驗證 pre-existing)。commit `981dc25` 已 push Gitea main。
> - **框架級發現(mistakes #23**`acr update` 部署源永遠是 GitHub codeload tarball`uncle6me-web/Arcrun` main),跟本地/Gitea checkout 完全脫鉤——雲端工人只能碰 Gitea(D20),照 issue 字面指示跑 `acr update` 會部署到蓋掉自己剛做的改動的舊版,回報成功卻是假綠。本次改用手工複刻 `injectWranglerConfig` 注入邏輯(CF API 查真實 KV id 對齊 dry-run)、直接 `wrangler deploy` 單一 workercypher-executor),避免碰 GitHub 又讓改動真的上線;部署後立刻 restore `wrangler.toml`,不留帳號 id 進 git。**此路只適合單 worker 小改動**;牽動多 worker 時的正解懸而未決,留 leo 裁(部署繞開 GitHub 鐵律 vs acr CLI 硬綁 codeload.github.com 的結構性衝突)。
> - **端到端驗證(leo21ccurl 證據)**`GET /console` 200`GET /kbdb/search?mode=semantic` 200(誠實降級 keyword + capability_hint,該帳號 KBDB 目前 0 entries,真實狀態非 bug);`GET /workflows/search` 2000 筆,該帳號無 named workflow);`GET /public-recipes?q=telegram` 200 真回 `telegram_send` 紀錄;`GET arcrun-registry.leo21c.workers.dev/components/search` 200。Playwright 瀏覽器點擊測試因沙盒 outbound proxy policy 擋 registry.arcrun.dev/workers.dev 連線未能跑通(環境限制,非部署問題),改以 curl 逐端點驗證 + 手動覆閱 JS 邏輯替代。