Compare commits
2 Commits
5174ed6821
...
9cc29f5dfb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cc29f5dfb | |||
| f9e44abc59 |
@@ -29,6 +29,17 @@ const CREDS_KEY = 'console:credentials';
|
|||||||
const SESSION_PREFIX = 'console_sess:';
|
const SESSION_PREFIX = 'console_sess:';
|
||||||
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
|
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 {
|
interface StoredCredentials {
|
||||||
email: string;
|
email: string;
|
||||||
salt: string; // hex
|
salt: string; // hex
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { kbdbBase } from './kbdb-proxy';
|
import { kbdbBase } from './kbdb-proxy';
|
||||||
|
import { validateConsoleSession } from './console-auth';
|
||||||
|
|
||||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
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 entries(entry_type=inbox,content 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 {
|
function renderDashboardHtml(): string {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="zh-Hant">
|
<html lang="zh-Hant">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>arcrun 駕駛艙</title>
|
<title>Mira 駕駛艙</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
/* Mira Console 定稿視覺(紙感暖黑「2a」,Mira Style Guide 2026-07-04):
|
||||||
|
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀 #e8b45a 強調、呼吸狀態球嵌單字。 */
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115; color: #e6e6e6; }
|
html, body { margin: 0; background: repeating-linear-gradient(0deg,#191410 0px,#191410 3px,#1b1611 3px,#1b1611 4px); color: #ede4d3;
|
||||||
main { padding: 14px; display: grid; gap: 14px; max-width: 560px; margin: 0 auto; }
|
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
|
||||||
.card { background: #161922; border: 1px solid #262a33; border-radius: 12px; padding: 16px; }
|
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
|
||||||
.light-row { display: flex; align-items: center; gap: 12px; }
|
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
|
||||||
.light-emoji { font-size: 44px; line-height: 1; }
|
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(232,180,90,.4); display: flex; justify-content: space-between; align-items: baseline; }
|
||||||
.light-word { font-size: 20px; font-weight: 700; }
|
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
|
||||||
.beat { color: #9aa0aa; font-size: 13px; margin-top: 8px; }
|
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(237,228,211,.5); }
|
||||||
.bar-wrap { margin-top: 12px; }
|
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(237,228,211,.55); }
|
||||||
.bar-label { font-size: 12px; color: #9aa0aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
|
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
|
||||||
.bar { height: 10px; background: #0f1115; border: 1px solid #262a33; border-radius: 999px; overflow: hidden; }
|
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
|
||||||
.bar > i { display: block; height: 100%; background: #3a5bfd; border-radius: 999px; transition: width .4s; }
|
.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); }
|
||||||
.subhead { font-size: 12px; color: #9aa0aa; margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
|
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
|
||||||
.subhead:first-child { margin-top: 0; }
|
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(237,228,211,.6); line-height: 1.55; }
|
||||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
@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); } }
|
||||||
li { font-size: 14px; line-height: 1.5; }
|
@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); } }
|
||||||
.muted { color: #6b7280; font-size: 13px; }
|
@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); } }
|
||||||
.inbox { margin-top: 12px; font-size: 13px; color: #9aa0aa; }
|
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
.err { color: #f06565; font-size: 13px; }
|
.brick { padding: 16px; border-radius: 12px; }
|
||||||
.stamp { text-align: center; color: #4b5563; font-size: 11px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<div class="card" id="card-light">
|
<div class="pagehead">
|
||||||
<div class="light-row"><span class="light-emoji" id="light-emoji">⏳</span><span class="light-word" id="light-word">載入中</span></div>
|
<div class="title serif">Mira<small> 駕駛艙</small></div>
|
||||||
<div class="beat" id="beat-line"></div>
|
<div class="date serif" id="date-str"></div>
|
||||||
<div class="bar-wrap">
|
</div>
|
||||||
<div class="bar-label"><span>今日完成度</span><span id="bar-num"></span></div>
|
<div class="orb-row">
|
||||||
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
|
<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>
|
</div>
|
||||||
<div class="card">
|
<div class="bricks">
|
||||||
<div class="subhead">今日路線</div>
|
<div class="brick amber">
|
||||||
<ul id="today-list"><li class="muted">載入中...</li></ul>
|
<div class="lbl">今日完成</div>
|
||||||
<div class="subhead" id="week-head" style="display:none">本週</div>
|
<div class="num"><span id="done-n">–</span><small> / <span id="total-n">–</span> 件</small></div>
|
||||||
<ul id="week-list"></ul>
|
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
|
||||||
<div class="subhead" style="margin-top:14px">等你的事</div>
|
</div>
|
||||||
<ul id="wait-list"></ul>
|
<div class="brick plain">
|
||||||
<div class="inbox" id="inbox-line"></div>
|
<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>
|
||||||
<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>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const LIGHT = { green: ['🟢', '系統運轉中'], yellow: ['🟡', '落後趕工中'], red: ['🔴', '卡住'] };
|
const LIGHT = {
|
||||||
const STATUS_EMOJI = { done: '✅', doing: '🔄', todo: '⬜', blocked: '⛔' };
|
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) {
|
function esc(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
}
|
}
|
||||||
function taskLine(t) {
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/console/dashboard-data');
|
const res = await fetch('/console/dashboard-data');
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
const [emoji, word] = LIGHT[d.light] || ['⚪', d.light];
|
const cfg = LIGHT[d.light] || LIGHT.green;
|
||||||
$('light-emoji').textContent = emoji;
|
const orb = $('orb');
|
||||||
$('light-word').textContent = word;
|
orb.style.background = cfg.grad;
|
||||||
$('beat-line').textContent = d.last_beat
|
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||||
? '最後心跳 ' + d.last_beat.actor + ' ' + d.last_beat.ago_minutes + ' 分鐘前'
|
$('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;
|
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) + '%';
|
$('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 today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
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-head').style.display = week.length ? '' : 'none';
|
||||||
$('week-list').innerHTML = week.map(taskLine).join('');
|
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||||
$('wait-list').innerHTML = (d.waiting && d.waiting.length)
|
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
|
||||||
? 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 });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$('light-emoji').textContent = '⚪';
|
$('orb-char').textContent = '?';
|
||||||
$('light-word').textContent = '讀不到狀態';
|
$('orb-title').textContent = '讀不到狀態';
|
||||||
$('beat-line').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
|
$('orb-sub').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
|||||||
+1150
-321
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,31 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
|||||||
return c.json({ success: true, name });
|
return c.json({ success: true, name });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||||
|
// 只回 metadata(name/service/sensitivity/created_at/last_used_at),**絕不回密文值**——
|
||||||
|
// 密文在 Workers Secrets,本 worker 自己也讀不回(D19「擁有目錄,不擁有內容物」)。
|
||||||
|
// 與舊 GET /credentials(KV 名稱清單)並存:這裡是新制 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 範圍)
|
// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍)
|
||||||
credentialsRouter.get('/credentials', async (c) => {
|
credentialsRouter.get('/credentials', async (c) => {
|
||||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||||
|
|||||||
@@ -189,6 +189,33 @@ kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
|
|||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── graph(kbdb-graph-plugin proxy,Mira Console 卡片詳頁「關聯視圖」)───────────────
|
||||||
|
//
|
||||||
|
// 純轉發(rule 07):圖能力真身在 kbdb-graph-plugin worker(GET /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)。
|
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
|
||||||
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
|
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
|
||||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export type Bindings = {
|
|||||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||||
CONSOLE_TENANT?: string;
|
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 用
|
// 重新 export Cloudflare Workers ExecutionContext 以便其他 module 用
|
||||||
|
|||||||
@@ -512,6 +512,26 @@ array-of-tables(`[[x]]`)不用 inline array。改 toml 後用 `wrangler --dr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 24. Worker 內嵌單檔 HTML 的三層跳脫=隱形破版源;驗證要「求值後」做(2026-07-04,Mira Console 完整版)
|
||||||
|
|
||||||
|
**情境**:console 頁是 TS template literal 包整份 HTML+inline JS。字面上要穿三層:
|
||||||
|
TS template(`\\d` 才輸出 `\d`、內層禁裸 backtick/`${`)→ inline JS(regex、字串拼接)→ 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 scratchpad(check-html.js / check-fns.js),下次改 console 直接複刻。
|
||||||
|
|
||||||
|
**連帶誠實缺口(憑證頁)**:`DELETE /credentials/:name` 仍是舊 KV 路徑(T9 範圍未動),
|
||||||
|
但目錄已改 D1(新 `GET /credentials/catalog`)——UI 若直接接 DELETE 會假綠(KV 刪了、D1 目錄還在、
|
||||||
|
Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,**不要接舊端點裝有效**。
|
||||||
|
同型判準:介面接動作前先問「這端點操作的儲存層,跟我列表讀的是同一層嗎」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速檢查清單(做新功能前)
|
## 快速檢查清單(做新功能前)
|
||||||
|
|
||||||
- [ ] 這是工作流還是零件?問「有必要嗎?」
|
- [ ] 這是工作流還是零件?問「有必要嗎?」
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ metadata:
|
|||||||
|
|
||||||
## 📍 當前位置
|
## 📍 當前位置
|
||||||
|
|
||||||
> **2026-07-02 本 session(雲端工人 T-loop1d,Arcrun#3 搜尋/控制台頁 v0,跨多 repo 第二次壓測)**:
|
> **2026-07-04 本 session(Mira Console 完整版 UI,Arcrun#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-plugin,token 只在 server 側;plugin base = `KBDB_GRAPH_URL` 或 `kbdb-graph-plugin.<subdomain>.workers.dev` 現算)④ types 加 `KBDB_GRAPH_URL?`。
|
||||||
|
> - **誠實分層**:語意搜尋未啟用→banner 讀 search 降級訊號(不假結果);搜尋 chips 用 entry_type(KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」分類);憑證「刪除」標即將開通(DELETE 仍舊 KV 路徑,接了會假綠,見 mistakes #24);設定頁 vectorize「開關」標即將開通(需部署端 kbdb_embed);關聯查無 triplet →「尚無關聯資料」。v0 零件/recipes 查詢區收進工作流頁折疊區(不砍功能)。
|
||||||
|
> - **驗證(本機,未部署)**:tsc exit 0;vitest 26/27(1 失敗 stash 複驗=pre-existing「不存在的零件回傳失敗」);`wrangler deploy --dry-run` 打包過;inline JS 求值後 `node --check` + 純函式行為測試全過(XSS escape/時間容錯,驗證法固化 mistakes #24)。
|
||||||
|
> - **部署注意(留總管)**:不跑 acr update(mistakes #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-loop1d,Arcrun#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。
|
> - **實作**:`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` 單一 worker(cypher-executor),避免碰 GitHub 又讓改動真的上線;部署後立刻 restore `wrangler.toml`,不留帳號 id 進 git。**此路只適合單 worker 小改動**;牽動多 worker 時的正解懸而未決,留 leo 裁(部署繞開 GitHub 鐵律 vs acr CLI 硬綁 codeload.github.com 的結構性衝突)。
|
> - **框架級發現(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` 單一 worker(cypher-executor),避免碰 GitHub 又讓改動真的上線;部署後立刻 restore `wrangler.toml`,不留帳號 id 進 git。**此路只適合單 worker 小改動**;牽動多 worker 時的正解懸而未決,留 leo 裁(部署繞開 GitHub 鐵律 vs acr CLI 硬綁 codeload.github.com 的結構性衝突)。
|
||||||
> - **端到端驗證(leo21c,curl 證據)**:`GET /console` 200;`GET /kbdb/search?mode=semantic` 200(誠實降級 keyword + capability_hint,該帳號 KBDB 目前 0 entries,真實狀態非 bug);`GET /workflows/search` 200(0 筆,該帳號無 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 邏輯替代。
|
> - **端到端驗證(leo21c,curl 證據)**:`GET /console` 200;`GET /kbdb/search?mode=semantic` 200(誠實降級 keyword + capability_hint,該帳號 KBDB 目前 0 entries,真實狀態非 bug);`GET /workflows/search` 200(0 筆,該帳號無 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 邏輯替代。
|
||||||
|
|||||||
Reference in New Issue
Block a user