@@ -746,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
function route() {
+ // 🔴 D62:`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
+ // 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**,
+ // 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
+ // 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
+ var rt = resetTokenFromUrl();
+ if (rt) { showReset(rt); return; }
var r = currentRoute();
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
@@ -817,6 +863,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} catch (e) { /* 登入殼還沒渲染就算了 */ }
}
function boot() {
+ // D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
+ // 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
+ var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
+ if (rt) { showReset(rt); return; }
if (!S.token) { showAuth(); return; }
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
@@ -2041,33 +2091,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'
角色' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '
' +
'
可查庫' + libHtml + '
';
}
- $('st-pw-save').addEventListener('click', function () {
+ // ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
+ //
+ // leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
+ // 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
+ // 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
+ // ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
+ // 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
+ // (arcrun-rag#40「同一個事實兩份」)。
+ // 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
+
+ var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
+
+ /** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
+ function mountPasswordForm(host, needCurrent) {
+ var form = $('pw-form');
+ if (form && form.parentNode !== host) host.appendChild(form);
+ $('st-pw-old').classList.toggle('hide', !needCurrent);
+ $('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
+ $('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
+ $('st-pw-status').textContent = '';
+ }
+
+ /** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
+ function submitPasswordChange() {
+ var needCurrent = !S_RESET_TOKEN;
var oldPw = $('st-pw-old').value;
var newPw = $('st-pw-new').value;
var newPw2 = $('st-pw-new2').value;
var st = $('st-pw-status');
st.className = 'err';
- if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
+ if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
+ if (!newPw) { st.textContent = '請填新密碼'; return; }
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
+ var body = { 'new': newPw };
+ if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
$('st-pw-save').disabled = true;
- st.textContent = '';
- fetch(API_BASE + '/portal/me/password', {
+ st.textContent = '處理中…';
+ fetch(API_BASE + '/portal/password/change', {
method: 'POST',
- headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
- body: JSON.stringify({ current: oldPw, 'new': newPw })
+ headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
+ body: JSON.stringify(body)
})
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('st-pw-save').disabled = false;
- if (guard401(x.status)) return;
- if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
+ // 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
+ if (needCurrent && guard401(x.status)) return;
+ if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
st.className = 'ok';
- st.textContent = '密碼已更新';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
- toast('密碼已更新');
+ if (S_RESET_TOKEN) {
+ st.textContent = '密碼已設定好了,正在帶你回登入頁…';
+ var who = (x.d && x.d.email) || '';
+ S_RESET_TOKEN = '';
+ setTimeout(function () {
+ history.replaceState(null, '', location.pathname);
+ location.hash = '';
+ $('v-reset').classList.remove('on');
+ mountPasswordForm($('st-pw-panel'), true);
+ showAuth();
+ if (who) $('login-email').value = who;
+ $('login-status').className = 'ok';
+ $('login-status').textContent = '新密碼已生效,請用它登入。';
+ }, 1200);
+ } else {
+ st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
+ toast('密碼已更新');
+ }
})
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
+ }
+ $('st-pw-save').addEventListener('click', submitPasswordChange);
+
+ // ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**,leo 2026-08-10)──
+ $('login-forgot').addEventListener('click', function (ev) {
+ ev.preventDefault();
+ $('forgot-box').classList.remove('hide');
+ $('forgot-email').value = $('login-email').value.trim();
+ $('forgot-email').focus();
+ });
+ $('forgot-submit').addEventListener('click', function () {
+ var email = $('forgot-email').value.trim();
+ var st = $('forgot-status');
+ st.className = 'err';
+ if (!email) { st.textContent = '請填 Email'; return; }
+ $('forgot-submit').disabled = true;
+ st.textContent = '寄送中…';
+ fetch(API_BASE + '/portal/password/forgot', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: email })
+ })
+ .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
+ .then(function (x) {
+ $('forgot-submit').disabled = false;
+ if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
+ st.className = 'ok';
+ st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
+ })
+ .catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
+ });
+
+ /** 點信裡的連結進來:`…/portal/#/reset?token=…`(cypher 的 /portal/password/reset-link 導過來的)。 */
+ function resetTokenFromUrl() {
+ var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
+ if (m) return decodeURIComponent(m[1]);
+ m = String(location.search || '').match(/[?&]token=([^&]+)/);
+ return m ? decodeURIComponent(m[1]) : '';
+ }
+ function showReset(token) {
+ $('v-login').classList.remove('on');
+ $('v-firstsetup').classList.remove('on');
+ $('shell').classList.remove('on');
+ $('tabbar').classList.remove('on');
+ $('v-reset').classList.add('on');
+ $('reset-error').textContent = '';
+ // 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
+ // 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
+ fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
+ .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
+ .then(function (x) {
+ if (!x.ok || !x.d || x.d.valid !== true) {
+ $('reset-who').textContent = '';
+ $('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
+ return;
+ }
+ S_RESET_TOKEN = token;
+ $('reset-who').innerHTML = '你正在為
' + esc(x.d.email) + ' 設定新密碼。
這條連結只能用一次,
不需要輸入現在的密碼。';
+ mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
+ })
+ .catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
+ }
+ $('reset-back').addEventListener('click', function (ev) {
+ ev.preventDefault();
+ S_RESET_TOKEN = '';
+ history.replaceState(null, '', location.pathname);
+ location.hash = '';
+ $('v-reset').classList.remove('on');
+ mountPasswordForm($('st-pw-panel'), true);
+ showAuth();
});
boot();
diff --git a/cypher-executor/src/lib/portal-auth.ts b/cypher-executor/src/lib/portal-auth.ts
index d0c6371..12b650f 100644
--- a/cypher-executor/src/lib/portal-auth.ts
+++ b/cypher-executor/src/lib/portal-auth.ts
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
.join('');
}
+/**
+ * SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。
+ *
+ * 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於
+ * 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。
+ * (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。)
+ */
+export async function sha256Hex(input: string): Promise
{
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
+ return Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
/**
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts
index b19332b..a14cc6c 100644
--- a/cypher-executor/src/routes/portal.ts
+++ b/cypher-executor/src/routes/portal.ts
@@ -23,7 +23,7 @@ import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
-import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
+import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
@@ -715,29 +715,300 @@ portalRouter.get('/portal/session', (c) =>
}),
);
-// POST /portal/me/password — body {current, new}。驗舊密改新密。
-portalRouter.post('/portal/me/password', (c) =>
- run(c, async () => {
- const auth = await requirePortalUser(c);
- if (!auth.ok) return auth.res;
- const body = await c.req.json().catch(() => null);
- const current = String(body?.current ?? '');
- const next = String(body?.new ?? '');
- if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
- if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
+// ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════
+//
+// leo 2026-08-10 拍板(頂層 decisions-summary D62):
+// 「這兩個機制其實是一個機制,可以簡化。」
+// - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
+// - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
+// ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。
+//
+// 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」,
+// 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。
+// 🔴 連結的安全性(承 D50 的理由,不可退讓):
+// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken)
+// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在
+// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV,
+// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
+// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
+// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。
+// 🔴 **入口在 portal,不是 console**(leo 2026-08-10:「是對 portal 不是對 console,
+// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是
+// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。
- const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
- if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
+/** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */
+const PWRESET_PREFIX = 'portal_pwreset:';
+/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */
+const PWRESET_TTL_SECONDS = 30 * 60;
+/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */
+const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
+const PWRESET_THROTTLE_SECONDS = 120;
+/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */
+const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
+const RELAY_TICKET_TTL_SECONDS = 120;
- const newHash = await hashPassword(next);
- await patchRecordValues(c.env, auth.user.recordId, {
- password_hash: newHash,
- updated_at: new Date().toISOString(),
+interface ResetTokenPayload {
+ record_id: string;
+ email: string;
+ created_at: string;
+}
+
+/**
+ * portal 前端(使用者會看到的那個網址)的 origin。
+ * 與 index.ts 的 CORS 白名單**同一套推導**:UI_ORIGINS 優先,否則用 workers.dev 兄弟位址。
+ * (2026-08-08 事故的教訓:能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。)
+ */
+function portalUiOrigin(env: Bindings): string | null {
+ const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
+ if (declared.length > 0) return declared[0];
+ const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
+ return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
+}
+
+/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */
+async function issueResetToken(env: Bindings, recordId: string, email: string): Promise {
+ const token = randomHex(32);
+ const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
+ await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
+ expirationTtl: PWRESET_TTL_SECONDS,
+ });
+ return token;
+}
+
+/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */
+async function peekResetToken(env: Bindings, token: string): Promise {
+ if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
+ const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as ResetTokenPayload;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * 用掉一張票(安全要求①「一次有效」):**先刪再回傳**。
+ * 順序是刻意的——先刪掉才回,兩個人同時點同一條連結時最多只有一個拿得到。
+ */
+async function consumeResetToken(env: Bindings, token: string): Promise {
+ const payload = await peekResetToken(env, token);
+ if (!payload) return null;
+ await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
+ return payload;
+}
+
+/**
+ * **唯一的密碼寫入路徑**(D62「同一條寫入路徑」的落地點)。
+ * 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。
+ */
+async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise {
+ const newHash = await hashPassword(newPassword);
+ await patchRecordValues(env, recordId, {
+ password_hash: newHash,
+ updated_at: new Date().toISOString(),
+ });
+}
+
+/**
+ * 請中央 landing 代寄「修改密碼」連結。
+ *
+ * 🔴 **為什麼要代寄**(leo 2026-08-10 給的職責切法):
+ * 「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover,
+ * 你幫我寄信給用戶讓他來修改密碼』,由 arcrun.dev 幫它寄出這封信。」
+ * ⇒ **實例**產生連結、管一次有效/過期/作廢;**arcrun.dev 只是郵差**,不碰任何認證邏輯。
+ * 必須這樣切的硬理由:用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding
+ * 只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize,**沒有 send_email**。
+ * ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**,他尚未正式表態(D62 明列為未裁前置)。
+ *
+ * 🔴 **絕不把整條 URL 交給郵差**(總管 2026-08-10 紅線):寄件網域 `arcrun.dev` 掛在 uncle6、
+ * 帶 DKIM。郵差若肯收「任意 URL + 任意 email」就寄,任何人裝一台實例就能用 `arcrun.dev`
+ * 的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼,
+ * 燒的是整個網域的信譽、波及所有用戶、**不可逆**。
+ *
+ * 做法:我們只交出**這台實例自己的 origin + 一張回呼票**。連結由郵差自己組,而且郵差會
+ * **回頭打這個 origin** 問「這張票是你發的嗎」(見 /portal/password/relay-verify)。
+ * 冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認,
+ * 不是相信呼叫方的宣稱**。
+ *
+ * `apiOrigin` 取自**這次請求真正抵達的位址**(`new URL(c.req.url).origin`),不新增任何
+ * 需要被注入的變數——2026-08-08 兩次事故的教訓:能推導的就不要再多一個會被漏掉的設定。
+ *
+ * **不假綠**(mindset §7):寄不出去就回 'failed' / 'not_configured'。
+ */
+async function relayResetLink(
+ env: Bindings,
+ apiOrigin: string,
+ email: string,
+ ticket: string,
+): Promise<'sent' | 'not_configured' | 'failed'> {
+ const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
+ if (!base) return 'not_configured';
+ const headers: Record = { 'Content-Type': 'application/json' };
+ if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
+ try {
+ const res = await fetch(`${base}/api/send-password-reset`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
});
- return c.json({ success: true });
+ return res.ok ? 'sent' : 'failed';
+ } catch {
+ return 'failed';
+ }
+}
+
+/**
+ * POST /portal/password/relay-verify — **郵差回頭確認用**(body `{ticket}`)。
+ *
+ * 這支存在的唯一理由是上面那條紅線:郵差不可以相信「呼叫方說這台是我的」。
+ * 它會回頭打**連結裡的那個主機**問這張票在不在——
+ * - 真的是這台發的 → 這裡答得出來 → 寄
+ * - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄
+ * 回應**不含 email 明碼、不含 reset token**,只回收件人 email 的 sha256 讓郵差核對它手上那個,
+ * 以及要放進信裡的完整連結(由本台自己組,主機必然是自己)。
+ * 票 TTL 兩分鐘、看過即焚——它不是憑據,只是一次握手。
+ */
+portalRouter.post('/portal/password/relay-verify', (c) =>
+ run(c, async () => {
+ const body = await c.req.json().catch(() => null);
+ const ticket = String(body?.ticket ?? '').trim();
+ if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
+ const key = `${RELAY_TICKET_PREFIX}${ticket}`;
+ const raw = await c.env.SESSIONS_KV.get(key);
+ if (!raw) return c.json({ ok: false }, 404);
+ await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚
+ let parsed: { email: string; link: string };
+ try {
+ parsed = JSON.parse(raw) as { email: string; link: string };
+ } catch {
+ return c.json({ ok: false }, 404);
+ }
+ return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
}),
);
+// GET /portal/password/reset-link?token=… — 信裡連結的落點。
+// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個,
+// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。
+// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。
+portalRouter.get('/portal/password/reset-link', (c) => {
+ const token = c.req.query('token') ?? '';
+ const ui = portalUiOrigin(c.env);
+ if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500);
+ return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
+});
+
+// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
+//
+// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
+// 唯一會回錯的是「這台實例根本沒設代寄服務」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
+// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
+portalRouter.post('/portal/password/forgot', (c) =>
+ run(c, async () => {
+ const body = await c.req.json().catch(() => null);
+ const email = String(body?.email ?? '').trim().toLowerCase();
+ if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
+
+ if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
+ return c.json(
+ {
+ error:
+ '這台實例還沒有設定寄信服務,「忘記密碼」的連結寄不出去。' +
+ '請重新執行安裝/更新讓它就緒,或請管理員直接幫你改密碼。',
+ code: 'mail_relay_not_configured',
+ },
+ 503,
+ );
+ }
+
+ const generic = {
+ success: true,
+ message: '如果這個 email 在這台實例上有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
+ };
+
+ // 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
+ const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
+ if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
+ await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });
+
+ const recordId = await findUserRecordId(c.env, email).catch(() => null);
+ if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄
+
+ const token = await issueResetToken(c.env, recordId, email);
+ // 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的)
+ const apiOrigin = new URL(c.req.url).origin;
+ const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
+ const ticket = randomHex(16);
+ await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
+ expirationTtl: RELAY_TICKET_TTL_SECONDS,
+ });
+ await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得)
+ return c.json(generic);
+ }),
+);
+
+// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。
+// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
+portalRouter.get('/portal/password/reset', (c) =>
+ run(c, async () => {
+ const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
+ if (!payload) {
+ return c.json(
+ { valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' },
+ 400,
+ );
+ }
+ return c.json({ valid: true, email: payload.email });
+ }),
+);
+
+/**
+ * POST /portal/password/change — **D62 的那一支**:修改密碼與忘記密碼共用。
+ *
+ * body:`{ new, current? , reset_token? }`
+ * - 帶 `reset_token`(從信裡的連結來)→ **忽略 current**,票就是憑據
+ * - 沒帶 → 必須是登入狀態 + 提供正確的 `current`
+ * 兩條路在這一行之後**完全相同**(writeNewPassword)——這就是「差別只有一格」的實體。
+ */
+async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise {
+ const body = await c.req.json().catch(() => null);
+ const next = String(body?.new ?? '');
+ const resetToken = String(body?.reset_token ?? '').trim();
+ if (!next) return c.json({ error: 'new(新密碼)必填' }, 400);
+ if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
+
+ // ── 忘記密碼那一格:憑連結,不問現有密碼 ──
+ if (resetToken) {
+ const payload = await consumeResetToken(c.env, resetToken);
+ if (!payload) {
+ return c.json(
+ { error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
+ 400,
+ );
+ }
+ await writeNewPassword(c.env, payload.record_id, next);
+ return c.json({ success: true, email: payload.email, via: 'reset_link' });
+ }
+
+ // ── 修改密碼那一格:要登入、要現有密碼 ──
+ const auth = await requirePortalUser(c);
+ if (!auth.ok) return auth.res;
+ const current = String(body?.current ?? '');
+ if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400);
+ const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
+ if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
+
+ await writeNewPassword(c.env, auth.user.recordId, next);
+ return c.json({ success: true, via: 'current_password' });
+}
+
+portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));
+
+// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。
+// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。
+portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));
+
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是
diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts
index a930ae0..fc6ea36 100644
--- a/cypher-executor/src/types.ts
+++ b/cypher-executor/src/types.ts
@@ -115,6 +115,19 @@ export type Bindings = {
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL?: string;
+ // Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
+ // D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
+ UI_ORIGINS?: string;
+ // ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
+ // 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器
+ // 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize,
+ // **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
+ // 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
+ // ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
+ PORTAL_MAIL_RELAY_BASE?: string;
+ // 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
+ // 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
+ PORTAL_MAIL_RELAY_KEY?: string;
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts
index f7f973b..448a525 100644
--- a/cypher-executor/tests/portal-auth.test.ts
+++ b/cypher-executor/tests/portal-auth.test.ts
@@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
});
});
+
+// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════
+//
+// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
+// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
+// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
+// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
+
+describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
+ const D62_EMAIL = 'd62-reset@example.com';
+
+ it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
+ // 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
+ // 存的是 token 的 sha256——KV 裡看不到可用的連結。
+ const { sha256Hex } = await import('../src/lib/portal-auth');
+ const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
+ const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
+ await env.SESSIONS_KV.put(
+ `portal_pwreset:${await sha256Hex(token)}`,
+ JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
+ );
+
+ // 票有效時,先「看一眼」不會消耗它
+ const peek = await json('GET', `/portal/password/reset?token=${token}`);
+ expect(peek.status).toBe(200);
+ expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
+
+ // 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
+ const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
+ expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
+ // 關鍵斷言:同一條連結**不能再用第二次**
+ const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
+ expect(again.status).toBe(400);
+ expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
+ // 而且票在 KV 裡真的沒了
+ expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
+ });
+
+ it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
+ for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
+ const res = await json('GET', `/portal/password/reset?token=${t}`);
+ expect(res.status).toBe(400);
+ expect((await res.json() as { valid: boolean }).valid).toBe(false);
+ }
+ });
+
+ it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
+ const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
+ expect(res.status).toBe(401);
+ });
+
+ it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
+ const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
+ expect(res.status).toBe(400);
+ });
+
+ it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
+ const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
+ expect(res.status).toBe(503);
+ expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
+ });
+});
+
+describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
+ const TOKEN_A = 'sess-token-66-propagating';
+ const TOKEN_B = 'sess-token-66-really-gone';
+ const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
+
+ it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
+ await seedPortalSession(TOKEN_A, MISSING);
+ // 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
+ await env.SESSIONS_KV.put(
+ 'auth_store_recent',
+ JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
+ );
+
+ const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
+ expect(res.status).toBe(503);
+ expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
+ // 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
+ expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
+ });
+
+ it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
+ await seedPortalSession(TOKEN_B, MISSING);
+ await env.SESSIONS_KV.delete('auth_store_recent');
+
+ const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
+ expect(res.status).toBe(401);
+ // 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
+ expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
+ });
+
+ it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
+ await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
+ const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
+ expect(res.status).toBe(401);
+ expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
+ });
+});