@co0ontty/wand 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/browser-extension/manifest.json +42 -0
- package/browser-extension/shared/password-tools.mjs +62 -0
- package/browser-extension/shared/url-tools.mjs +59 -0
- package/browser-extension/shared/webauthn-tools.mjs +378 -0
- package/browser-extension/src/api.js +79 -0
- package/browser-extension/src/background.js +428 -0
- package/browser-extension/src/content-script.js +354 -0
- package/browser-extension/src/options.css +118 -0
- package/browser-extension/src/options.html +39 -0
- package/browser-extension/src/options.js +101 -0
- package/browser-extension/src/popup.css +269 -0
- package/browser-extension/src/popup.html +99 -0
- package/browser-extension/src/popup.js +342 -0
- package/dist/build-info.json +3 -3
- package/dist/password-manager.d.ts +82 -0
- package/dist/password-manager.js +349 -0
- package/dist/server.js +194 -20
- package/dist/storage.d.ts +11 -0
- package/dist/storage.js +175 -0
- package/package.json +4 -2
package/dist/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { canUseDetachedUpdateHelper, startDetachedUpdateHelper } from "./update-
|
|
|
29
29
|
import { registerUploadRoutes } from "./upload-routes.js";
|
|
30
30
|
import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
|
|
31
31
|
import { resolveDatabasePath, WandStorage } from "./storage.js";
|
|
32
|
+
import { DEFAULT_BROWSER_EXTENSION_BASE_URL, buildPasswordSecurityReport, generatePassword, generateTotpCode, normalizePasswordItemType, } from "./password-manager.js";
|
|
32
33
|
import { deepRepairRuntimePath, formatPathRepairSummary, repairRuntimePath } from "./path-repair.js";
|
|
33
34
|
import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
|
|
34
35
|
import { EMBEDDED_WEB_ASSETS } from "./web-ui/embedded-assets.js";
|
|
@@ -384,15 +385,38 @@ async function enrichWithGitStatus(items, dirPath) {
|
|
|
384
385
|
}
|
|
385
386
|
}
|
|
386
387
|
// ── Auth helpers ──
|
|
387
|
-
function buildRequireAuth(useHttps) {
|
|
388
|
+
function buildRequireAuth(useHttps, storage, config) {
|
|
388
389
|
return function requireAuth(req, res, next) {
|
|
389
|
-
if (!validateSession(readSessionCookie(req, useHttps))) {
|
|
390
|
+
if (!validateSession(readSessionCookie(req, useHttps)) && !validateBearerAppToken(req, storage, config)) {
|
|
390
391
|
res.status(401).json({ error: "未授权,请先登录。" });
|
|
391
392
|
return;
|
|
392
393
|
}
|
|
393
394
|
next();
|
|
394
395
|
};
|
|
395
396
|
}
|
|
397
|
+
function getEffectivePassword(storage, config) {
|
|
398
|
+
return storage.getPassword() ?? config.password;
|
|
399
|
+
}
|
|
400
|
+
function validateBearerAppToken(req, storage, config) {
|
|
401
|
+
const header = firstHeaderValue(req.headers.authorization);
|
|
402
|
+
if (!header?.startsWith("Bearer "))
|
|
403
|
+
return false;
|
|
404
|
+
const token = header.slice("Bearer ".length).trim();
|
|
405
|
+
if (!token)
|
|
406
|
+
return false;
|
|
407
|
+
try {
|
|
408
|
+
return verifyAppToken(token, getEffectivePassword(storage, config), config.appSecret ?? "");
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function appTokenLoginPayload(storage, config) {
|
|
415
|
+
return {
|
|
416
|
+
appToken: generateAppToken(getEffectivePassword(storage, config), config.appSecret ?? ""),
|
|
417
|
+
serverUrl: DEFAULT_BROWSER_EXTENSION_BASE_URL,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
396
420
|
// ── App connection token helpers ──
|
|
397
421
|
function generateAppToken(password, secret) {
|
|
398
422
|
return crypto.createHmac("sha256", secret).update(password).digest("hex");
|
|
@@ -477,6 +501,13 @@ function normalizePublicOrigin(value) {
|
|
|
477
501
|
return undefined;
|
|
478
502
|
}
|
|
479
503
|
}
|
|
504
|
+
function isBrowserExtensionOrigin(value) {
|
|
505
|
+
if (!value)
|
|
506
|
+
return false;
|
|
507
|
+
return /^chrome-extension:\/\/[a-z]{32}$/i.test(value)
|
|
508
|
+
|| /^moz-extension:\/\/[0-9a-f-]+$/i.test(value)
|
|
509
|
+
|| /^safari-web-extension:\/\//i.test(value);
|
|
510
|
+
}
|
|
480
511
|
function isPrivateIpv4(address) {
|
|
481
512
|
if (address.startsWith("10.") || address.startsWith("192.168."))
|
|
482
513
|
return true;
|
|
@@ -1027,10 +1058,25 @@ export async function startServer(config, configPath) {
|
|
|
1027
1058
|
const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
|
|
1028
1059
|
const useHttps = config.https === true;
|
|
1029
1060
|
const protocol = useHttps ? "https" : "http";
|
|
1030
|
-
const requireAuth = buildRequireAuth(useHttps);
|
|
1061
|
+
const requireAuth = buildRequireAuth(useHttps, storage, config);
|
|
1031
1062
|
const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
|
|
1032
1063
|
app.use(express.json({ limit: "1mb" }));
|
|
1033
1064
|
app.use(compression({ threshold: 1024 }));
|
|
1065
|
+
app.use((req, res, next) => {
|
|
1066
|
+
const origin = firstHeaderValue(req.headers.origin);
|
|
1067
|
+
if (origin && isBrowserExtensionOrigin(origin)) {
|
|
1068
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1069
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
1070
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
1071
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
1072
|
+
res.setHeader("Vary", "Origin");
|
|
1073
|
+
}
|
|
1074
|
+
if (req.method === "OPTIONS" && origin && isBrowserExtensionOrigin(origin)) {
|
|
1075
|
+
res.status(204).end();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
next();
|
|
1079
|
+
});
|
|
1034
1080
|
const sendEmbeddedVendorAsset = (assetPath, _req, res) => {
|
|
1035
1081
|
const asset = EMBEDDED_WEB_ASSETS.vendor[assetPath];
|
|
1036
1082
|
res.setHeader("Cache-Control", "public, max-age=604800, immutable");
|
|
@@ -1095,9 +1141,8 @@ export async function startServer(config, configPath) {
|
|
|
1095
1141
|
res.status(429).json({ error: "登录尝试次数过多,请在 15 分钟后再试。" });
|
|
1096
1142
|
return;
|
|
1097
1143
|
}
|
|
1098
|
-
const { password, appToken } = req.body;
|
|
1099
|
-
const
|
|
1100
|
-
const effectivePassword = dbPassword ?? config.password;
|
|
1144
|
+
const { password, appToken, client } = req.body;
|
|
1145
|
+
const effectivePassword = getEffectivePassword(storage, config);
|
|
1101
1146
|
// App token login — derived from password, so password change invalidates it
|
|
1102
1147
|
let authenticated = false;
|
|
1103
1148
|
if (appToken) {
|
|
@@ -1138,7 +1183,10 @@ export async function startServer(config, configPath) {
|
|
|
1138
1183
|
res.cookie(SESSION_COOKIE_HTTP, token, { ...cookieOpts, secure: false });
|
|
1139
1184
|
res.cookie(SESSION_COOKIE_LEGACY, token, { ...cookieOpts, secure: false });
|
|
1140
1185
|
}
|
|
1141
|
-
res.json({
|
|
1186
|
+
res.json({
|
|
1187
|
+
ok: true,
|
|
1188
|
+
...(client === "browser-extension" ? appTokenLoginPayload(storage, config) : {}),
|
|
1189
|
+
});
|
|
1142
1190
|
});
|
|
1143
1191
|
app.post("/api/logout", (req, res) => {
|
|
1144
1192
|
revokeSession(readSessionCookie(req, useHttps));
|
|
@@ -1281,6 +1329,119 @@ export async function startServer(config, configPath) {
|
|
|
1281
1329
|
currentVersion: DISPLAY_VERSION,
|
|
1282
1330
|
});
|
|
1283
1331
|
});
|
|
1332
|
+
// ── Browser extension password vault endpoints ──
|
|
1333
|
+
app.get("/api/browser-extension/status", (_req, res) => {
|
|
1334
|
+
res.json({
|
|
1335
|
+
ok: true,
|
|
1336
|
+
serverUrl: DEFAULT_BROWSER_EXTENSION_BASE_URL,
|
|
1337
|
+
features: {
|
|
1338
|
+
loginAutofill: true,
|
|
1339
|
+
saveLogins: true,
|
|
1340
|
+
federatedLoginMemory: true,
|
|
1341
|
+
passwordGenerator: true,
|
|
1342
|
+
totp: true,
|
|
1343
|
+
cardsAndIdentities: true,
|
|
1344
|
+
vaults: true,
|
|
1345
|
+
securityReport: true,
|
|
1346
|
+
passkeys: "webauthn-proxy",
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
app.get("/api/browser-extension/vaults", (_req, res) => {
|
|
1351
|
+
res.json({ vaults: storage.listPasswordVaults() });
|
|
1352
|
+
});
|
|
1353
|
+
app.post("/api/browser-extension/vaults", (req, res) => {
|
|
1354
|
+
try {
|
|
1355
|
+
const vault = storage.createPasswordVault(req.body.name);
|
|
1356
|
+
res.status(201).json({ vault });
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
res.status(400).json({ error: getErrorMessage(error, "无法创建 vault。") });
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
app.get("/api/browser-extension/items", (req, res) => {
|
|
1363
|
+
const filter = {
|
|
1364
|
+
q: firstQueryStringValue(req.query.q),
|
|
1365
|
+
url: firstQueryStringValue(req.query.url),
|
|
1366
|
+
vaultId: firstQueryStringValue(req.query.vaultId),
|
|
1367
|
+
type: req.query.type ? normalizePasswordItemType(firstQueryStringValue(req.query.type)) : undefined,
|
|
1368
|
+
limit: req.query.limit ? Number(firstQueryStringValue(req.query.limit)) : undefined,
|
|
1369
|
+
};
|
|
1370
|
+
res.json({ items: storage.listPasswordItems(filter) });
|
|
1371
|
+
});
|
|
1372
|
+
app.post("/api/browser-extension/items", (req, res) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const item = storage.createPasswordItem(req.body);
|
|
1375
|
+
res.status(201).json({ item });
|
|
1376
|
+
}
|
|
1377
|
+
catch (error) {
|
|
1378
|
+
res.status(400).json({ error: getErrorMessage(error, "无法保存条目。") });
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
app.get("/api/browser-extension/items/:id", (req, res) => {
|
|
1382
|
+
const item = storage.getPasswordItem(req.params.id);
|
|
1383
|
+
if (!item) {
|
|
1384
|
+
res.status(404).json({ error: "条目不存在。" });
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
res.json({ item });
|
|
1388
|
+
});
|
|
1389
|
+
app.put("/api/browser-extension/items/:id", (req, res) => {
|
|
1390
|
+
try {
|
|
1391
|
+
const item = storage.updatePasswordItem(req.params.id, req.body);
|
|
1392
|
+
if (!item) {
|
|
1393
|
+
res.status(404).json({ error: "条目不存在。" });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
res.json({ item });
|
|
1397
|
+
}
|
|
1398
|
+
catch (error) {
|
|
1399
|
+
res.status(400).json({ error: getErrorMessage(error, "无法更新条目。") });
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
app.delete("/api/browser-extension/items/:id", (req, res) => {
|
|
1403
|
+
if (!storage.deletePasswordItem(req.params.id)) {
|
|
1404
|
+
res.status(404).json({ error: "条目不存在。" });
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
res.json({ ok: true });
|
|
1408
|
+
});
|
|
1409
|
+
app.post("/api/browser-extension/items/:id/use", (req, res) => {
|
|
1410
|
+
const item = storage.touchPasswordItem(req.params.id);
|
|
1411
|
+
if (!item) {
|
|
1412
|
+
res.status(404).json({ error: "条目不存在。" });
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
res.json({ item });
|
|
1416
|
+
});
|
|
1417
|
+
app.get("/api/browser-extension/generator/password", (req, res) => {
|
|
1418
|
+
res.json({
|
|
1419
|
+
password: generatePassword({
|
|
1420
|
+
length: Number(firstQueryStringValue(req.query.length)),
|
|
1421
|
+
digits: firstQueryStringValue(req.query.digits) !== "false",
|
|
1422
|
+
symbols: firstQueryStringValue(req.query.symbols) !== "false",
|
|
1423
|
+
}),
|
|
1424
|
+
});
|
|
1425
|
+
});
|
|
1426
|
+
app.post("/api/browser-extension/totp/preview", (req, res) => {
|
|
1427
|
+
try {
|
|
1428
|
+
const { secret, digits, period } = req.body;
|
|
1429
|
+
if (!secret) {
|
|
1430
|
+
res.status(400).json({ error: "缺少 TOTP secret。" });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
res.json({
|
|
1434
|
+
code: generateTotpCode(secret, Date.now(), digits ?? 6, period ?? 30),
|
|
1435
|
+
period: period ?? 30,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
res.status(400).json({ error: getErrorMessage(error, "无法生成 TOTP。") });
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
app.get("/api/browser-extension/security-report", (_req, res) => {
|
|
1443
|
+
res.json({ report: buildPasswordSecurityReport(storage.listPasswordItems({ includeArchived: false, limit: 200 })) });
|
|
1444
|
+
});
|
|
1284
1445
|
// ── Settings endpoints ──
|
|
1285
1446
|
app.get("/api/settings", async (_req, res) => {
|
|
1286
1447
|
const certPaths = {
|
|
@@ -1440,8 +1601,7 @@ export async function startServer(config, configPath) {
|
|
|
1440
1601
|
});
|
|
1441
1602
|
});
|
|
1442
1603
|
app.get("/api/app-connect-code", requireAuth, (req, res) => {
|
|
1443
|
-
const
|
|
1444
|
-
const effectivePassword = dbPassword ?? config.password;
|
|
1604
|
+
const effectivePassword = getEffectivePassword(storage, config);
|
|
1445
1605
|
const protocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
|
|
1446
1606
|
const host = getPublicRequestHost(req, config);
|
|
1447
1607
|
const browserOrigin = normalizePublicOrigin(firstQueryStringValue(req.query.origin));
|
|
@@ -2271,15 +2431,17 @@ export async function startServer(config, configPath) {
|
|
|
2271
2431
|
server.once("error", onListenError);
|
|
2272
2432
|
server.listen(config.port, config.host, () => {
|
|
2273
2433
|
server.off("error", onListenError);
|
|
2274
|
-
|
|
2434
|
+
const address = server.address();
|
|
2435
|
+
const actualPort = typeof address === "object" && address ? address.port : config.port;
|
|
2436
|
+
bindAddr = `${config.host}:${actualPort}`;
|
|
2275
2437
|
const scheme = useHttps ? "HTTPS" : "HTTP";
|
|
2276
2438
|
// 主 URL:本机回环;若绑定 0.0.0.0 再补一个对外提示。
|
|
2277
|
-
collectedUrls.push({ url: `${protocol}://127.0.0.1:${
|
|
2439
|
+
collectedUrls.push({ url: `${protocol}://127.0.0.1:${actualPort}`, scheme });
|
|
2278
2440
|
if (config.host === "0.0.0.0") {
|
|
2279
|
-
collectedUrls.push({ url: `${protocol}://0.0.0.0:${
|
|
2441
|
+
collectedUrls.push({ url: `${protocol}://0.0.0.0:${actualPort}`, scheme });
|
|
2280
2442
|
}
|
|
2281
2443
|
else if (config.host !== "127.0.0.1" && config.host !== "localhost") {
|
|
2282
|
-
collectedUrls.push({ url: `${protocol}://${config.host}:${
|
|
2444
|
+
collectedUrls.push({ url: `${protocol}://${config.host}:${actualPort}`, scheme });
|
|
2283
2445
|
}
|
|
2284
2446
|
resolve();
|
|
2285
2447
|
});
|
|
@@ -2287,10 +2449,16 @@ export async function startServer(config, configPath) {
|
|
|
2287
2449
|
if (!storage.hasCustomPassword() && config.password === "change-me") {
|
|
2288
2450
|
wandWarn("正在使用默认密码(change-me),任何能访问本机的人都可以登录。", "修改方法:在界面右上角「设置」中修改密码,或运行:node dist/cli.js config:set password <你的新密码>");
|
|
2289
2451
|
}
|
|
2452
|
+
const testMode = process.env.WAND_TEST_MODE === "1";
|
|
2453
|
+
const updateChecksEnabled = !testMode && process.env.WAND_DISABLE_UPDATE_CHECK !== "1";
|
|
2290
2454
|
// Start configured background sessions after the server is already reachable.
|
|
2291
|
-
|
|
2455
|
+
if (!testMode) {
|
|
2456
|
+
processes.runStartupCommands();
|
|
2457
|
+
}
|
|
2292
2458
|
// Pre-warm model cache (probes claude --version + codex debug models).
|
|
2293
|
-
|
|
2459
|
+
if (!testMode) {
|
|
2460
|
+
refreshModels().catch(() => { });
|
|
2461
|
+
}
|
|
2294
2462
|
// ── Auto-update endpoints ──
|
|
2295
2463
|
app.get("/api/auto-update", (_req, res) => {
|
|
2296
2464
|
const web = storage.getConfigValue("autoUpdateWeb") === "true";
|
|
@@ -2386,12 +2554,16 @@ export async function startServer(config, configPath) {
|
|
|
2386
2554
|
});
|
|
2387
2555
|
}
|
|
2388
2556
|
}
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
setInterval(() => {
|
|
2557
|
+
let updateCheckTimer = null;
|
|
2558
|
+
if (updateChecksEnabled) {
|
|
2559
|
+
// Background update check on startup
|
|
2393
2560
|
performAutoUpdate().catch(() => { });
|
|
2394
|
-
|
|
2561
|
+
// Periodic update check (every 30 minutes)
|
|
2562
|
+
updateCheckTimer = setInterval(() => {
|
|
2563
|
+
performAutoUpdate().catch(() => { });
|
|
2564
|
+
}, 30 * 60 * 1000);
|
|
2565
|
+
updateCheckTimer.unref();
|
|
2566
|
+
}
|
|
2395
2567
|
const close = () => new Promise((resolve) => {
|
|
2396
2568
|
let done = false;
|
|
2397
2569
|
const finish = () => {
|
|
@@ -2412,6 +2584,8 @@ export async function startServer(config, configPath) {
|
|
|
2412
2584
|
wss.close();
|
|
2413
2585
|
}
|
|
2414
2586
|
catch { /* ignore */ }
|
|
2587
|
+
if (updateCheckTimer)
|
|
2588
|
+
clearInterval(updateCheckTimer);
|
|
2415
2589
|
try {
|
|
2416
2590
|
server.close(() => finish());
|
|
2417
2591
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SessionSnapshot } from "./types.js";
|
|
2
|
+
import { type PasswordVault, type PasswordVaultItem, type PasswordVaultItemFilter, type PasswordVaultItemInput } from "./password-manager.js";
|
|
2
3
|
export declare const DEFAULT_DB_FILE = "wand.db";
|
|
3
4
|
export interface PersistedAuthSession {
|
|
4
5
|
token: string;
|
|
@@ -32,6 +33,16 @@ export declare class WandStorage {
|
|
|
32
33
|
getAppSecret(): string | null;
|
|
33
34
|
/** Persist appSecret in database (DB is the authoritative source after first migration) */
|
|
34
35
|
setAppSecret(value: string): void;
|
|
36
|
+
ensureDefaultPasswordVault(): void;
|
|
37
|
+
listPasswordVaults(): PasswordVault[];
|
|
38
|
+
createPasswordVault(nameInput: unknown): PasswordVault;
|
|
39
|
+
getPasswordVault(id: string): PasswordVault | null;
|
|
40
|
+
listPasswordItems(filter?: PasswordVaultItemFilter): PasswordVaultItem[];
|
|
41
|
+
getPasswordItem(id: string): PasswordVaultItem | null;
|
|
42
|
+
createPasswordItem(input: PasswordVaultItemInput): PasswordVaultItem;
|
|
43
|
+
updatePasswordItem(id: string, input: PasswordVaultItemInput): PasswordVaultItem | null;
|
|
44
|
+
touchPasswordItem(id: string): PasswordVaultItem | null;
|
|
45
|
+
deletePasswordItem(id: string): boolean;
|
|
35
46
|
saveAuthSession(token: string, expiresAt: number): void;
|
|
36
47
|
getAuthSession(token: string): PersistedAuthSession | null;
|
|
37
48
|
deleteAuthSession(token: string): void;
|
package/dist/storage.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, itemMatchesFilter, normalizePasswordItemInput, normalizeVaultName, nowIso, } from "./password-manager.js";
|
|
4
6
|
function safeJsonParse(raw) {
|
|
5
7
|
if (!raw)
|
|
6
8
|
return undefined;
|
|
@@ -232,6 +234,37 @@ const INIT_SQL = `
|
|
|
232
234
|
key TEXT PRIMARY KEY,
|
|
233
235
|
value TEXT NOT NULL
|
|
234
236
|
);
|
|
237
|
+
|
|
238
|
+
CREATE TABLE IF NOT EXISTS password_vaults (
|
|
239
|
+
id TEXT PRIMARY KEY,
|
|
240
|
+
name TEXT NOT NULL,
|
|
241
|
+
created_at TEXT NOT NULL,
|
|
242
|
+
updated_at TEXT NOT NULL
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
CREATE TABLE IF NOT EXISTS password_items (
|
|
246
|
+
id TEXT PRIMARY KEY,
|
|
247
|
+
vault_id TEXT NOT NULL,
|
|
248
|
+
type TEXT NOT NULL,
|
|
249
|
+
title TEXT NOT NULL,
|
|
250
|
+
username TEXT,
|
|
251
|
+
password TEXT,
|
|
252
|
+
urls TEXT NOT NULL DEFAULT '[]',
|
|
253
|
+
notes TEXT,
|
|
254
|
+
fields TEXT NOT NULL DEFAULT '{}',
|
|
255
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
256
|
+
favorite INTEGER NOT NULL DEFAULT 0,
|
|
257
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
258
|
+
created_at TEXT NOT NULL,
|
|
259
|
+
updated_at TEXT NOT NULL,
|
|
260
|
+
last_used_at TEXT,
|
|
261
|
+
password_updated_at TEXT,
|
|
262
|
+
FOREIGN KEY(vault_id) REFERENCES password_vaults(id)
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
CREATE INDEX IF NOT EXISTS idx_password_items_vault ON password_items(vault_id);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_password_items_type ON password_items(type);
|
|
267
|
+
CREATE INDEX IF NOT EXISTS idx_password_items_updated ON password_items(updated_at);
|
|
235
268
|
`;
|
|
236
269
|
export function ensureDatabaseFile(dbPath) {
|
|
237
270
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -249,6 +282,7 @@ export class WandStorage {
|
|
|
249
282
|
this.db = new DatabaseSync(dbPath);
|
|
250
283
|
this.db.exec(INIT_SQL);
|
|
251
284
|
ensureCommandSessionSchema(this.db);
|
|
285
|
+
this.ensureDefaultPasswordVault();
|
|
252
286
|
}
|
|
253
287
|
close() {
|
|
254
288
|
this.db.close();
|
|
@@ -321,6 +355,120 @@ export class WandStorage {
|
|
|
321
355
|
setAppSecret(value) {
|
|
322
356
|
this.setConfigValue("appSecret", value);
|
|
323
357
|
}
|
|
358
|
+
// ============ Browser Extension Password Vault Methods ============
|
|
359
|
+
ensureDefaultPasswordVault() {
|
|
360
|
+
const now = nowIso();
|
|
361
|
+
this.db
|
|
362
|
+
.prepare(`INSERT INTO password_vaults (id, name, created_at, updated_at)
|
|
363
|
+
VALUES (?, ?, ?, ?)
|
|
364
|
+
ON CONFLICT(id) DO NOTHING`)
|
|
365
|
+
.run(DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, now, now);
|
|
366
|
+
}
|
|
367
|
+
listPasswordVaults() {
|
|
368
|
+
this.ensureDefaultPasswordVault();
|
|
369
|
+
const rows = this.db
|
|
370
|
+
.prepare("SELECT id, name, created_at, updated_at FROM password_vaults ORDER BY name COLLATE NOCASE ASC")
|
|
371
|
+
.all();
|
|
372
|
+
return rows.map(mapPasswordVaultRow);
|
|
373
|
+
}
|
|
374
|
+
createPasswordVault(nameInput) {
|
|
375
|
+
const name = normalizeVaultName(nameInput);
|
|
376
|
+
const now = nowIso();
|
|
377
|
+
const id = crypto.randomUUID();
|
|
378
|
+
this.db
|
|
379
|
+
.prepare("INSERT INTO password_vaults (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)")
|
|
380
|
+
.run(id, name, now, now);
|
|
381
|
+
return { id, name, createdAt: now, updatedAt: now };
|
|
382
|
+
}
|
|
383
|
+
getPasswordVault(id) {
|
|
384
|
+
const row = this.db
|
|
385
|
+
.prepare("SELECT id, name, created_at, updated_at FROM password_vaults WHERE id = ?")
|
|
386
|
+
.get(id);
|
|
387
|
+
return row ? mapPasswordVaultRow(row) : null;
|
|
388
|
+
}
|
|
389
|
+
listPasswordItems(filter = {}) {
|
|
390
|
+
this.ensureDefaultPasswordVault();
|
|
391
|
+
const rows = this.db
|
|
392
|
+
.prepare(`SELECT id, vault_id, type, title, username, password, urls, notes, fields, tags, favorite, archived,
|
|
393
|
+
created_at, updated_at, last_used_at, password_updated_at
|
|
394
|
+
FROM password_items
|
|
395
|
+
WHERE (? = 1 OR archived = 0)
|
|
396
|
+
ORDER BY favorite DESC, last_used_at DESC NULLS LAST, updated_at DESC`)
|
|
397
|
+
.all(filter.includeArchived ? 1 : 0);
|
|
398
|
+
const limit = typeof filter.limit === "number" && Number.isFinite(filter.limit)
|
|
399
|
+
? Math.max(1, Math.min(200, Math.floor(filter.limit)))
|
|
400
|
+
: 100;
|
|
401
|
+
return rows.map(mapPasswordItemRow).filter((item) => itemMatchesFilter(item, filter)).slice(0, limit);
|
|
402
|
+
}
|
|
403
|
+
getPasswordItem(id) {
|
|
404
|
+
const row = this.db
|
|
405
|
+
.prepare(`SELECT id, vault_id, type, title, username, password, urls, notes, fields, tags, favorite, archived,
|
|
406
|
+
created_at, updated_at, last_used_at, password_updated_at
|
|
407
|
+
FROM password_items
|
|
408
|
+
WHERE id = ? AND archived = 0`)
|
|
409
|
+
.get(id);
|
|
410
|
+
return row ? mapPasswordItemRow(row) : null;
|
|
411
|
+
}
|
|
412
|
+
createPasswordItem(input) {
|
|
413
|
+
this.ensureDefaultPasswordVault();
|
|
414
|
+
const normalized = normalizePasswordItemInput(input);
|
|
415
|
+
const vaultId = typeof input.vaultId === "string" && this.getPasswordVault(input.vaultId)
|
|
416
|
+
? input.vaultId
|
|
417
|
+
: DEFAULT_PASSWORD_VAULT_ID;
|
|
418
|
+
const now = nowIso();
|
|
419
|
+
const id = crypto.randomUUID();
|
|
420
|
+
const passwordUpdatedAt = normalized.password ? now : undefined;
|
|
421
|
+
this.db
|
|
422
|
+
.prepare(`INSERT INTO password_items (
|
|
423
|
+
id, vault_id, type, title, username, password, urls, notes, fields, tags, favorite, archived,
|
|
424
|
+
created_at, updated_at, last_used_at, password_updated_at
|
|
425
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL, ?)`)
|
|
426
|
+
.run(id, vaultId, normalized.type, normalized.title, normalized.username ?? null, normalized.password ?? null, JSON.stringify(normalized.urls), normalized.notes ?? null, JSON.stringify(normalized.fields), JSON.stringify(normalized.tags), normalized.favorite ? 1 : 0, now, now, passwordUpdatedAt ?? null);
|
|
427
|
+
return this.getPasswordItem(id);
|
|
428
|
+
}
|
|
429
|
+
updatePasswordItem(id, input) {
|
|
430
|
+
const existing = this.getPasswordItem(id);
|
|
431
|
+
if (!existing)
|
|
432
|
+
return null;
|
|
433
|
+
const merged = {
|
|
434
|
+
vaultId: input.vaultId ?? existing.vaultId,
|
|
435
|
+
type: input.type ?? existing.type,
|
|
436
|
+
title: input.title ?? existing.title,
|
|
437
|
+
username: input.username ?? existing.username,
|
|
438
|
+
password: input.password ?? existing.password,
|
|
439
|
+
urls: input.urls ?? existing.urls,
|
|
440
|
+
notes: input.notes ?? existing.notes,
|
|
441
|
+
fields: input.fields ?? existing.fields,
|
|
442
|
+
tags: input.tags ?? existing.tags,
|
|
443
|
+
favorite: input.favorite ?? existing.favorite,
|
|
444
|
+
};
|
|
445
|
+
const normalized = normalizePasswordItemInput(merged);
|
|
446
|
+
const vaultId = typeof merged.vaultId === "string" && this.getPasswordVault(merged.vaultId)
|
|
447
|
+
? merged.vaultId
|
|
448
|
+
: existing.vaultId;
|
|
449
|
+
const now = nowIso();
|
|
450
|
+
const passwordChanged = Object.prototype.hasOwnProperty.call(input, "password") && normalized.password !== existing.password;
|
|
451
|
+
const passwordUpdatedAt = passwordChanged ? (normalized.password ? now : null) : (existing.passwordUpdatedAt ?? null);
|
|
452
|
+
this.db
|
|
453
|
+
.prepare(`UPDATE password_items
|
|
454
|
+
SET vault_id = ?, type = ?, title = ?, username = ?, password = ?, urls = ?, notes = ?,
|
|
455
|
+
fields = ?, tags = ?, favorite = ?, updated_at = ?, password_updated_at = ?
|
|
456
|
+
WHERE id = ? AND archived = 0`)
|
|
457
|
+
.run(vaultId, normalized.type, normalized.title, normalized.username ?? null, normalized.password ?? null, JSON.stringify(normalized.urls), normalized.notes ?? null, JSON.stringify(normalized.fields), JSON.stringify(normalized.tags), normalized.favorite ? 1 : 0, now, passwordUpdatedAt, id);
|
|
458
|
+
return this.getPasswordItem(id);
|
|
459
|
+
}
|
|
460
|
+
touchPasswordItem(id) {
|
|
461
|
+
const now = nowIso();
|
|
462
|
+
this.db.prepare("UPDATE password_items SET last_used_at = ?, updated_at = ? WHERE id = ? AND archived = 0").run(now, now, id);
|
|
463
|
+
return this.getPasswordItem(id);
|
|
464
|
+
}
|
|
465
|
+
deletePasswordItem(id) {
|
|
466
|
+
const now = nowIso();
|
|
467
|
+
const result = this.db
|
|
468
|
+
.prepare("UPDATE password_items SET archived = 1, updated_at = ? WHERE id = ? AND archived = 0")
|
|
469
|
+
.run(now, id);
|
|
470
|
+
return result.changes > 0;
|
|
471
|
+
}
|
|
324
472
|
// ============ Auth Session Methods ============
|
|
325
473
|
saveAuthSession(token, expiresAt) {
|
|
326
474
|
this.db
|
|
@@ -422,6 +570,33 @@ export class WandStorage {
|
|
|
422
570
|
this.db.prepare("DELETE FROM command_sessions WHERE id = ?").run(id);
|
|
423
571
|
}
|
|
424
572
|
}
|
|
573
|
+
function mapPasswordVaultRow(row) {
|
|
574
|
+
return {
|
|
575
|
+
id: row.id,
|
|
576
|
+
name: row.name,
|
|
577
|
+
createdAt: row.created_at,
|
|
578
|
+
updatedAt: row.updated_at,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function mapPasswordItemRow(row) {
|
|
582
|
+
return {
|
|
583
|
+
id: row.id,
|
|
584
|
+
vaultId: row.vault_id,
|
|
585
|
+
type: row.type,
|
|
586
|
+
title: row.title,
|
|
587
|
+
username: row.username ?? undefined,
|
|
588
|
+
password: row.password ?? undefined,
|
|
589
|
+
urls: safeJsonParse(row.urls)?.filter((item) => typeof item === "string") ?? [],
|
|
590
|
+
notes: row.notes ?? undefined,
|
|
591
|
+
fields: safeJsonParse(row.fields) ?? {},
|
|
592
|
+
tags: safeJsonParse(row.tags)?.filter((item) => typeof item === "string") ?? [],
|
|
593
|
+
favorite: Boolean(row.favorite),
|
|
594
|
+
createdAt: row.created_at,
|
|
595
|
+
updatedAt: row.updated_at,
|
|
596
|
+
lastUsedAt: row.last_used_at ?? undefined,
|
|
597
|
+
passwordUpdatedAt: row.password_updated_at ?? undefined,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
425
600
|
const SCHEMA_MIGRATIONS = [
|
|
426
601
|
["archived", "ALTER TABLE command_sessions ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"],
|
|
427
602
|
["archived_at", "ALTER TABLE command_sessions ADD COLUMN archived_at TEXT"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@co0ontty/wand",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A web terminal for local CLI tools like Claude.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"url": ""
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"dist"
|
|
15
|
+
"dist",
|
|
16
|
+
"browser-extension"
|
|
16
17
|
],
|
|
17
18
|
"preferGlobal": true,
|
|
18
19
|
"scripts": {
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"build:copy-content": "cp -r src/web-ui/content dist/web-ui/ && node scripts/minify-dist-content.js",
|
|
22
23
|
"dev": "node scripts/bundle-browser.js && npm run generate:web-assets && tsx src/cli.ts web",
|
|
23
24
|
"check": "node scripts/bundle-browser.js && npm run generate:web-assets && tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
|
|
25
|
+
"test": "node --test --import tsx tests/*.test.ts",
|
|
24
26
|
"prepublishOnly": "VER=${WAND_PUBLISH_VERSION:-$(git tag --sort=-v:refname --list 'v*' | head -1)} && VER=${VER#v} && npm version \"$VER\" --no-git-tag-version --allow-same-version && npm run build"
|
|
25
27
|
},
|
|
26
28
|
"keywords": [
|