Files
Agent Board 262e66b02e feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
2026-07-25 14:25:37 +08:00

417 lines
10 KiB
JavaScript

/**
* mnote-web vault API client (background only).
* Token / secrets never enter content scripts.
*/
/**
* @typedef {object} VaultConfig
* @property {string} baseUrl
* @property {string} rootUri
* @property {string} [token]
* @property {string} [userId]
* @property {string} [email]
*/
/**
* @param {string} baseUrl
* @returns {string}
*/
export function normalizeBaseUrl(baseUrl) {
const raw = String(baseUrl || "").trim().replace(/\/+$/, "");
if (!raw) throw new Error("baseUrl 不能为空");
let u;
try {
u = new URL(raw);
} catch {
throw new Error("baseUrl 不是合法 URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error("baseUrl 仅支持 http/https");
}
return u.origin + (u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""));
}
/**
* @param {string} baseUrl
* @param {string} path
* @param {object} [opts]
* @param {string} [opts.token]
* @param {string} [opts.method]
* @param {object} [opts.body]
* @param {boolean} [opts.credentials]
*/
export async function vaultFetch(baseUrl, path, opts = {}) {
const base = normalizeBaseUrl(baseUrl);
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
/** @type {Record<string, string>} */
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (opts.token) {
headers.Authorization = `Bearer ${opts.token}`;
}
const init = {
method: opts.method || (opts.body ? "POST" : "GET"),
headers,
credentials: opts.credentials ? "include" : "omit",
};
if (opts.body !== undefined) {
init.body = JSON.stringify(opts.body);
}
let res;
try {
res = await fetch(url, init);
} catch (e) {
const err = new Error(
`Failed to fetch ${url}: ${e?.message || e}(若是扩展请求,先确认已授权该 origin 的 host 权限)`
);
err.cause = e;
err.code = "network_error";
throw err;
}
let data = null;
const text = await res.text();
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!res.ok) {
const code = data?.code || data?.error || `http_${res.status}`;
const msg =
data?.message || data?.error || text || `${res.status} ${res.statusText}`;
const err = new Error(String(msg));
err.code = code;
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
/**
* Auth sign-in (sets session cookies when credentials:include).
* @param {string} baseUrl
* @param {{ email: string, password: string }} creds
*/
export async function signIn(baseUrl, creds) {
const email = String(creds.email || "").trim();
const password = String(creds.password || "");
const name = email.includes("@") ? email.split("@")[0] : email;
// 与 mnote-web /auth 页、scripts/mnote-vault-cli.js 对齐
return vaultFetch(baseUrl, "/api/auth", {
method: "POST",
credentials: true,
body: {
action: "auth:signIn",
args: {
provider: "password",
params: {
password,
flow: "signIn",
account: email,
email,
name,
},
},
},
});
}
/**
* Issue E2 extension token (requires session cookie from signIn).
* @param {string} baseUrl
* @param {object} [opts]
*/
export async function issueExtensionToken(baseUrl, opts = {}) {
return vaultFetch(baseUrl, "/api/vault/extension/token", {
method: "POST",
credentials: true,
body: {
clientId: "chrome-extension",
extensionId: opts.extensionId || null,
ttlHours: opts.ttlHours ?? 168,
email: opts.email || null,
},
});
}
/**
* 当前登录账号的默认 local_folder 工作区(每用户固定 my-space)。
* POST /api/local-folder/workspaces/default — 不存在则创建。
* @param {string} baseUrl
* @param {{ token?: string, credentials?: boolean }} [opts]
* @returns {Promise<{ rootUri: string, rootPath?: string }>}
*/
export async function ensureDefaultWorkspace(baseUrl, opts = {}) {
const data = await vaultFetch(
baseUrl,
"/api/local-folder/workspaces/default",
{
method: "POST",
credentials: opts.credentials !== false && !opts.token,
token: opts.token,
body: {},
}
);
const ws = data?.workspace || data?.result?.workspace || data?.result || data;
const rootUri = String(ws?.rootUri || "").trim();
if (!rootUri.startsWith("file://")) {
throw new Error(
"无法解析默认工作区 rootUri(请确认账号已登录且 mnote-web 可用)"
);
}
return {
rootUri,
rootPath: ws?.rootPath ? String(ws.rootPath) : undefined,
workspaceId: ws?.manifest?.workspaceId || ws?.workspaceId,
};
}
/**
* @param {VaultConfig} cfg
*/
export async function whoami(cfg) {
return vaultFetch(cfg.baseUrl, "/api/auth/whoami", {
method: "GET",
token: cfg.token,
credentials: !cfg.token,
});
}
/**
* @param {VaultConfig} cfg
*/
export async function listVault(cfg) {
const q = new URLSearchParams({
rootUri: cfg.rootUri,
sourceKind: "local_folder",
});
return vaultFetch(cfg.baseUrl, `/api/vault/list?${q}`, {
method: "GET",
token: cfg.token,
});
}
/**
* @param {VaultConfig} cfg
* @param {object} item
*/
export async function createItem(cfg, item) {
return vaultFetch(cfg.baseUrl, "/api/vault/items", {
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...item,
},
});
}
/**
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} patch
*/
export async function updateItem(cfg, id, patch) {
return vaultFetch(cfg.baseUrl, `/api/vault/items/${encodeURIComponent(id)}`, {
method: "PATCH",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...patch,
},
});
}
/**
* @param {VaultConfig} cfg
* @param {string} id
*/
export async function getItem(cfg, id) {
const q = new URLSearchParams({
rootUri: cfg.rootUri,
sourceKind: "local_folder",
});
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}?${q}`,
{
method: "GET",
token: cfg.token,
}
);
}
/**
* Reveal a secret field (password etc.) for dedupe / autofill decisions.
* @param {VaultConfig} cfg
* @param {string} id
* @param {{ field?: string, accountId?: string, secretId?: string }} [opts]
*/
export async function revealField(cfg, id, opts = {}) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/reveal`,
{
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
field: opts.field || "password",
accountId: opts.accountId,
secretId: opts.secretId,
},
}
);
}
/**
* PUT session file for credential/account.
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} session
*/
export async function putSession(cfg, id, session) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/session`,
{
method: "PUT",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
source: "chrome_extension",
...session,
},
}
);
}
/**
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} [opts]
*/
export async function shareToAi(cfg, id, opts = {}) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/share-to-ai`,
{
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...opts,
},
}
);
}
/**
* 从 list 条目收集唯一 folderPath(含父路径前缀)。
* @param {Array} items
* @returns {string[]}
*/
export function collectFolderPaths(items) {
/** @type {Record<string, true>} */
const set = {};
const list = Array.isArray(items) ? items : [];
for (const it of list) {
const fp = String(it?.folderPath || "").trim();
if (!fp) continue;
set[fp] = true;
const parts = fp.split("/").filter(Boolean);
for (let i = 1; i < parts.length; i++) {
set[parts.slice(0, i).join("/")] = true;
}
}
return Object.keys(set).sort((a, b) => a.localeCompare(b, "zh"));
}
/**
* Match list items by origin (client-side filter).
* @param {Array} items
* @param {string} pageUrl
*/
export function matchItemsByUrl(items, pageUrl) {
let origin = "";
let hostname = "";
try {
const u = new URL(pageUrl);
origin = u.origin;
hostname = u.hostname || "";
} catch {
return [];
}
const list = Array.isArray(items) ? items : [];
const hostOf = (raw) => {
try {
return new URL(String(raw)).hostname || "";
} catch {
return "";
}
};
const sameSite = (a, b) => {
if (!a || !b) return false;
if (a === b) return true;
return a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
};
return list
.filter((it) => {
if (it && it.status && String(it.status).toLowerCase() === "deleted") {
return false;
}
const urls = [];
if (it.url) urls.push(String(it.url));
if (Array.isArray(it.urls)) urls.push(...it.urls.map(String));
return urls.some((u) => {
try {
const ou = new URL(u);
if (ou.origin === origin) return true;
return sameSite(ou.hostname, hostname);
} catch {
const bare = origin.replace(/^https?:\/\//, "");
return String(u).includes(bare) || sameSite(hostOf(u), hostname);
}
});
})
.sort((a, b) =>
String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))
);
}
/**
* Find a list item that matches page origin + username (best effort).
* @param {Array} items
* @param {string} pageUrl
* @param {string} [username]
*/
export function findMatchingCredential(items, pageUrl, username) {
const matched = matchItemsByUrl(items, pageUrl);
if (!matched.length) return null;
const want = String(username || "")
.trim()
.toLowerCase();
if (!want) return matched[0];
const byUser = matched.find((it) => {
const candidates = [
it.username,
it.email,
...(Array.isArray(it.accounts)
? it.accounts.flatMap((a) => [a?.username, a?.email])
: []),
]
.filter(Boolean)
.map((s) => String(s).trim().toLowerCase());
return candidates.includes(want);
});
return byUser || matched[0];
}