/** * 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} */ 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} */ 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]; }