@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 CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  Wand is a web console for remotely accessing and managing local CLI tools from a browser. It is designed for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Codex](https://github.com/openai/codex), with terminal and structured conversation views, persistent resumable sessions, permission controls, file browsing, and native clients for multiple platforms.
15
15
 
16
+ The browser password manager extension source lives in `browser-extension/`; setup and supported baseline features are documented in [docs/browser-extension.md](docs/browser-extension.md).
17
+
16
18
  <p align="center">
17
19
  <img src="docs/screenshots/clients-overview.png" width="100%" alt="Web centered with iOS and Android clients on both sides" />
18
20
  </p>
@@ -0,0 +1,42 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Wand Passwords",
4
+ "description": "Wand browser password manager with autofill, save prompts, cards, identities, TOTP, and security checks.",
5
+ "version": "0.1.0",
6
+ "permissions": [
7
+ "activeTab",
8
+ "clipboardWrite",
9
+ "contextMenus",
10
+ "webAuthenticationProxy",
11
+ "storage",
12
+ "scripting"
13
+ ],
14
+ "host_permissions": [
15
+ "https://home.huniu.fun:8183/*",
16
+ "http://127.0.0.1:*/*",
17
+ "http://localhost:*/*",
18
+ "https://127.0.0.1:*/*",
19
+ "https://localhost:*/*",
20
+ "<all_urls>"
21
+ ],
22
+ "background": {
23
+ "service_worker": "src/background.js",
24
+ "type": "module"
25
+ },
26
+ "action": {
27
+ "default_title": "Wand Passwords",
28
+ "default_popup": "src/popup.html"
29
+ },
30
+ "options_page": "src/options.html",
31
+ "content_scripts": [
32
+ {
33
+ "matches": [
34
+ "<all_urls>"
35
+ ],
36
+ "js": [
37
+ "src/content-script.js"
38
+ ],
39
+ "run_at": "document_idle"
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,62 @@
1
+ const COMMON_WEAK_PASSWORDS = new Set([
2
+ "123456",
3
+ "123456789",
4
+ "qwerty",
5
+ "password",
6
+ "111111",
7
+ "abc123",
8
+ "password1",
9
+ "iloveyou"
10
+ ]);
11
+
12
+ export function generatePassword(options = {}) {
13
+ const length = clampInteger(options.length ?? 20, 8, 80);
14
+ const lower = "abcdefghijkmnopqrstuvwxyz";
15
+ const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
16
+ const digits = "23456789";
17
+ const symbols = "!@#$%^&*-_=+?";
18
+ const pools = [lower, upper];
19
+ if (options.digits !== false) pools.push(digits);
20
+ if (options.symbols !== false) pools.push(symbols);
21
+ const alphabet = pools.join("");
22
+ const chars = pools.map((pool) => randomChar(pool));
23
+ while (chars.length < length) chars.push(randomChar(alphabet));
24
+ return shuffle(chars).join("");
25
+ }
26
+
27
+ export function scorePasswordStrength(password) {
28
+ if (!password) return 0;
29
+ const value = String(password);
30
+ const lower = value.toLowerCase();
31
+ if (COMMON_WEAK_PASSWORDS.has(lower)) return 0;
32
+ let score = Math.min(40, value.length * 3);
33
+ if (/[a-z]/.test(value)) score += 10;
34
+ if (/[A-Z]/.test(value)) score += 10;
35
+ if (/\d/.test(value)) score += 10;
36
+ if (/[^A-Za-z0-9]/.test(value)) score += 15;
37
+ if (value.length >= 20) score += 15;
38
+ if (/(.)\1{2,}/.test(value)) score -= 20;
39
+ if (/^(?:[a-z]+|\d+)$/.test(value)) score -= 20;
40
+ return Math.max(0, Math.min(100, score));
41
+ }
42
+
43
+ function clampInteger(value, min, max) {
44
+ if (!Number.isFinite(value)) return min;
45
+ return Math.max(min, Math.min(max, Math.floor(value)));
46
+ }
47
+
48
+ function randomChar(alphabet) {
49
+ const values = new Uint32Array(1);
50
+ globalThis.crypto.getRandomValues(values);
51
+ return alphabet[values[0] % alphabet.length];
52
+ }
53
+
54
+ function shuffle(values) {
55
+ for (let i = values.length - 1; i > 0; i -= 1) {
56
+ const rand = new Uint32Array(1);
57
+ globalThis.crypto.getRandomValues(rand);
58
+ const j = rand[0] % (i + 1);
59
+ [values[i], values[j]] = [values[j], values[i]];
60
+ }
61
+ return values;
62
+ }
@@ -0,0 +1,59 @@
1
+ export const DEFAULT_BASE_URL = "https://home.huniu.fun:8183";
2
+
3
+ export function normalizeBaseUrl(value) {
4
+ const raw = String(value || "").trim();
5
+ if (!raw) return DEFAULT_BASE_URL;
6
+ try {
7
+ const parsed = new URL(raw);
8
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return DEFAULT_BASE_URL;
9
+ parsed.hash = "";
10
+ parsed.search = "";
11
+ const pathname = parsed.pathname.replace(/\/+$/g, "");
12
+ return parsed.origin + (pathname === "/" ? "" : pathname);
13
+ } catch {
14
+ return DEFAULT_BASE_URL;
15
+ }
16
+ }
17
+
18
+ export function normalizeStoredUrl(value) {
19
+ const raw = String(value || "").trim();
20
+ if (!raw) return null;
21
+ try {
22
+ const parsed = new URL(raw.includes("://") ? raw : `https://${raw}`);
23
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
24
+ parsed.username = "";
25
+ parsed.password = "";
26
+ parsed.hash = "";
27
+ return parsed.origin + normalizePathname(parsed.pathname);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function urlsMatch(storedUrl, pageUrl) {
34
+ try {
35
+ const stored = new URL(normalizeStoredUrl(storedUrl) || storedUrl);
36
+ const page = new URL(pageUrl);
37
+ const storedHost = stored.hostname.toLowerCase();
38
+ const pageHost = page.hostname.toLowerCase();
39
+ if (pageHost !== storedHost && !pageHost.endsWith(`.${storedHost}`)) return false;
40
+ const storedPath = normalizePathname(stored.pathname);
41
+ return storedPath === "/" || normalizePathname(page.pathname).startsWith(storedPath);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export function pageTitleFallback(url) {
48
+ try {
49
+ const parsed = new URL(url);
50
+ return parsed.hostname.replace(/^www\./, "");
51
+ } catch {
52
+ return "Login";
53
+ }
54
+ }
55
+
56
+ function normalizePathname(pathname) {
57
+ if (!pathname || pathname === "/") return "/";
58
+ return pathname.endsWith("/") ? pathname : `${pathname}/`;
59
+ }
@@ -0,0 +1,378 @@
1
+ export const WEBAUTHN_ALGORITHM_ES256 = -7;
2
+
3
+ export async function createPasskeyCredential(options) {
4
+ const rpId = resolveRpId(options);
5
+ const origin = resolveOrigin(options, rpId);
6
+ const challenge = requiredBase64Url(options.challenge, "challenge");
7
+ const userId = requiredBase64Url(options.user?.id, "user.id");
8
+ const userName = String(options.user?.name || "");
9
+ const userDisplayName = String(options.user?.displayName || userName || "");
10
+ ensureEs256Allowed(options.pubKeyCredParams || []);
11
+
12
+ const credentialIdBytes = randomBytes(32);
13
+ const credentialId = base64UrlEncode(credentialIdBytes);
14
+ const keyPair = await subtle().generateKey(
15
+ { name: "ECDSA", namedCurve: "P-256" },
16
+ true,
17
+ ["sign", "verify"]
18
+ );
19
+ const publicJwk = await subtle().exportKey("jwk", keyPair.publicKey);
20
+ const privateJwk = await subtle().exportKey("jwk", keyPair.privateKey);
21
+ const publicRaw = new Uint8Array(await subtle().exportKey("raw", keyPair.publicKey));
22
+ const cosePublicKey = encodeCosePublicKey(publicRaw);
23
+ const authData = await buildAuthenticatorData({
24
+ rpId,
25
+ flags: 0x45,
26
+ signCount: 0,
27
+ credentialIdBytes,
28
+ cosePublicKey
29
+ });
30
+ const attestationObject = cborEncode(new Map([
31
+ ["fmt", "none"],
32
+ ["attStmt", new Map()],
33
+ ["authData", authData]
34
+ ]));
35
+ const clientDataJSON = encodeClientData("webauthn.create", challenge, origin);
36
+ const response = {
37
+ id: credentialId,
38
+ rawId: credentialId,
39
+ type: "public-key",
40
+ authenticatorAttachment: "platform",
41
+ response: {
42
+ clientDataJSON: base64UrlEncode(clientDataJSON),
43
+ attestationObject: base64UrlEncode(attestationObject),
44
+ transports: ["internal", "hybrid"],
45
+ publicKeyAlgorithm: WEBAUTHN_ALGORITHM_ES256,
46
+ publicKey: base64UrlEncode(publicRaw),
47
+ authenticatorData: base64UrlEncode(authData)
48
+ },
49
+ clientExtensionResults: {}
50
+ };
51
+ const item = {
52
+ type: "passkey",
53
+ title: buildPasskeyTitle(options, rpId),
54
+ username: userName || userDisplayName || rpId,
55
+ urls: [origin],
56
+ fields: {
57
+ rpId,
58
+ origin,
59
+ credentialId,
60
+ userHandle: userId,
61
+ userName,
62
+ userDisplayName,
63
+ algorithm: String(WEBAUTHN_ALGORITHM_ES256),
64
+ signCount: "0",
65
+ publicKeyJwk: JSON.stringify(publicJwk),
66
+ privateKeyJwk: JSON.stringify(privateJwk),
67
+ publicKeyRaw: base64UrlEncode(publicRaw),
68
+ publicKeyCose: base64UrlEncode(cosePublicKey)
69
+ },
70
+ tags: ["passkey", "browser-extension"]
71
+ };
72
+ return { response, item, credentialId };
73
+ }
74
+
75
+ export async function getPasskeyAssertion(options, item) {
76
+ const fields = item?.fields || {};
77
+ const rpId = resolveRpId(options, fields.rpId);
78
+ const origin = resolveOrigin(options, rpId, fields.origin);
79
+ const challenge = requiredBase64Url(options.challenge, "challenge");
80
+ const credentialId = fields.credentialId;
81
+ const privateKeyJwk = parseJson(fields.privateKeyJwk, "privateKeyJwk");
82
+ if (!credentialId || !privateKeyJwk) {
83
+ throw new Error("Stored passkey is missing credential material.");
84
+ }
85
+ const credentialIdBytes = base64UrlDecode(credentialId);
86
+ const nextSignCount = Math.max(0, Number(fields.signCount || "0") || 0) + 1;
87
+ const authenticatorData = await buildAuthenticatorData({
88
+ rpId,
89
+ flags: 0x05,
90
+ signCount: nextSignCount
91
+ });
92
+ const clientDataJSON = encodeClientData("webauthn.get", challenge, origin);
93
+ const clientDataHash = await sha256(clientDataJSON);
94
+ const signedBytes = concatBytes(authenticatorData, clientDataHash);
95
+ const key = await subtle().importKey(
96
+ "jwk",
97
+ privateKeyJwk,
98
+ { name: "ECDSA", namedCurve: "P-256" },
99
+ false,
100
+ ["sign"]
101
+ );
102
+ const rawSignature = new Uint8Array(await subtle().sign({ name: "ECDSA", hash: "SHA-256" }, key, signedBytes));
103
+ const signature = maybeDerEncodeEcdsa(rawSignature);
104
+ const response = {
105
+ id: credentialId,
106
+ rawId: credentialId,
107
+ type: "public-key",
108
+ authenticatorAttachment: "platform",
109
+ response: {
110
+ authenticatorData: base64UrlEncode(authenticatorData),
111
+ clientDataJSON: base64UrlEncode(clientDataJSON),
112
+ signature: base64UrlEncode(signature),
113
+ userHandle: fields.userHandle || null
114
+ },
115
+ clientExtensionResults: {}
116
+ };
117
+ return { response, signCount: nextSignCount };
118
+ }
119
+
120
+ export function passkeyMatchesRequest(item, options) {
121
+ const fields = item?.fields || {};
122
+ const rpId = resolveRpId(options, fields.rpId);
123
+ if (fields.rpId !== rpId) return false;
124
+ const allowCredentials = Array.isArray(options.allowCredentials) ? options.allowCredentials : [];
125
+ if (!allowCredentials.length) return true;
126
+ return allowCredentials.some((credential) => credential?.id === fields.credentialId);
127
+ }
128
+
129
+ export function parseWebAuthnRequestJson(raw) {
130
+ if (typeof raw !== "string" || !raw.trim()) {
131
+ throw new Error("Missing WebAuthn request JSON.");
132
+ }
133
+ return JSON.parse(raw);
134
+ }
135
+
136
+ export function webAuthnDomException(name, message) {
137
+ return { name, message };
138
+ }
139
+
140
+ export function base64UrlEncode(value) {
141
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
142
+ let binary = "";
143
+ for (let i = 0; i < bytes.length; i += 0x8000) {
144
+ binary += String.fromCharCode(...bytes.slice(i, i + 0x8000));
145
+ }
146
+ return btoaCompat(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
147
+ }
148
+
149
+ export function base64UrlDecode(value) {
150
+ const normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
151
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
152
+ const binary = atobCompat(padded);
153
+ const out = new Uint8Array(binary.length);
154
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
155
+ return out;
156
+ }
157
+
158
+ export function cborEncode(value) {
159
+ const chunks = [];
160
+ encodeCborValue(value, chunks);
161
+ return concatBytes(...chunks);
162
+ }
163
+
164
+ export function maybeDerEncodeEcdsa(signature) {
165
+ if (signature[0] === 0x30) return signature;
166
+ if (signature.length !== 64) return signature;
167
+ const r = derInteger(signature.slice(0, 32));
168
+ const s = derInteger(signature.slice(32));
169
+ return concatBytes(
170
+ new Uint8Array([0x30]),
171
+ encodeDerLength(r.length + s.length),
172
+ r,
173
+ s
174
+ );
175
+ }
176
+
177
+ function buildPasskeyTitle(options, rpId) {
178
+ const rpName = options.rp?.name || rpId;
179
+ const user = options.user?.name || options.user?.displayName;
180
+ return user ? `${rpName} (${user})` : String(rpName);
181
+ }
182
+
183
+ function resolveRpId(options, fallback) {
184
+ const raw = options.rpId || options.rp?.id || fallback;
185
+ const rpId = String(raw || "").trim().toLowerCase();
186
+ if (!rpId) throw new Error("WebAuthn request has no rpId.");
187
+ return rpId;
188
+ }
189
+
190
+ function resolveOrigin(options, rpId, fallback) {
191
+ const raw = options.origin || options.topOrigin || fallback || `https://${rpId}`;
192
+ try {
193
+ const parsed = new URL(raw);
194
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid origin");
195
+ return parsed.origin;
196
+ } catch {
197
+ return `https://${rpId}`;
198
+ }
199
+ }
200
+
201
+ function ensureEs256Allowed(params) {
202
+ if (!Array.isArray(params) || !params.length) return;
203
+ const allowed = params.some((param) => Number(param?.alg) === WEBAUTHN_ALGORITHM_ES256);
204
+ if (!allowed) throw new Error("Only ES256 passkeys are supported.");
205
+ }
206
+
207
+ function requiredBase64Url(value, name) {
208
+ if (typeof value !== "string" || !value) throw new Error(`Missing ${name}.`);
209
+ base64UrlDecode(value);
210
+ return value;
211
+ }
212
+
213
+ async function buildAuthenticatorData(options) {
214
+ const rpIdHash = await sha256(new TextEncoder().encode(options.rpId));
215
+ const flags = new Uint8Array([options.flags]);
216
+ const signCount = new Uint8Array(4);
217
+ new DataView(signCount.buffer).setUint32(0, options.signCount, false);
218
+ if (!options.credentialIdBytes || !options.cosePublicKey) {
219
+ return concatBytes(rpIdHash, flags, signCount);
220
+ }
221
+ const aaguid = new Uint8Array(16);
222
+ const credLen = new Uint8Array(2);
223
+ new DataView(credLen.buffer).setUint16(0, options.credentialIdBytes.length, false);
224
+ return concatBytes(
225
+ rpIdHash,
226
+ flags,
227
+ signCount,
228
+ aaguid,
229
+ credLen,
230
+ options.credentialIdBytes,
231
+ options.cosePublicKey
232
+ );
233
+ }
234
+
235
+ function encodeCosePublicKey(publicRaw) {
236
+ if (publicRaw.length !== 65 || publicRaw[0] !== 4) {
237
+ throw new Error("Unexpected P-256 public key format.");
238
+ }
239
+ const x = publicRaw.slice(1, 33);
240
+ const y = publicRaw.slice(33, 65);
241
+ return cborEncode(new Map([
242
+ [1, 2],
243
+ [3, WEBAUTHN_ALGORITHM_ES256],
244
+ [-1, 1],
245
+ [-2, x],
246
+ [-3, y]
247
+ ]));
248
+ }
249
+
250
+ function encodeClientData(type, challenge, origin) {
251
+ return new TextEncoder().encode(JSON.stringify({
252
+ type,
253
+ challenge,
254
+ origin,
255
+ crossOrigin: false
256
+ }));
257
+ }
258
+
259
+ async function sha256(bytes) {
260
+ return new Uint8Array(await subtle().digest("SHA-256", bytes));
261
+ }
262
+
263
+ function subtle() {
264
+ if (!globalThis.crypto?.subtle) throw new Error("WebCrypto is unavailable.");
265
+ return globalThis.crypto.subtle;
266
+ }
267
+
268
+ function randomBytes(length) {
269
+ const bytes = new Uint8Array(length);
270
+ globalThis.crypto.getRandomValues(bytes);
271
+ return bytes;
272
+ }
273
+
274
+ function concatBytes(...parts) {
275
+ const length = parts.reduce((sum, part) => sum + part.length, 0);
276
+ const out = new Uint8Array(length);
277
+ let offset = 0;
278
+ for (const part of parts) {
279
+ out.set(part, offset);
280
+ offset += part.length;
281
+ }
282
+ return out;
283
+ }
284
+
285
+ function encodeCborValue(value, chunks) {
286
+ if (value instanceof Uint8Array) {
287
+ encodeTypeAndLength(2, value.length, chunks);
288
+ chunks.push(value);
289
+ return;
290
+ }
291
+ if (typeof value === "string") {
292
+ const bytes = new TextEncoder().encode(value);
293
+ encodeTypeAndLength(3, bytes.length, chunks);
294
+ chunks.push(bytes);
295
+ return;
296
+ }
297
+ if (typeof value === "number") {
298
+ if (Number.isInteger(value) && value >= 0) {
299
+ encodeTypeAndLength(0, value, chunks);
300
+ return;
301
+ }
302
+ if (Number.isInteger(value) && value < 0) {
303
+ encodeTypeAndLength(1, -1 - value, chunks);
304
+ return;
305
+ }
306
+ }
307
+ if (Array.isArray(value)) {
308
+ encodeTypeAndLength(4, value.length, chunks);
309
+ value.forEach((item) => encodeCborValue(item, chunks));
310
+ return;
311
+ }
312
+ if (value instanceof Map) {
313
+ encodeTypeAndLength(5, value.size, chunks);
314
+ for (const [key, mapValue] of value.entries()) {
315
+ encodeCborValue(key, chunks);
316
+ encodeCborValue(mapValue, chunks);
317
+ }
318
+ return;
319
+ }
320
+ if (value && typeof value === "object") {
321
+ const entries = Object.entries(value);
322
+ encodeTypeAndLength(5, entries.length, chunks);
323
+ for (const [key, objectValue] of entries) {
324
+ encodeCborValue(key, chunks);
325
+ encodeCborValue(objectValue, chunks);
326
+ }
327
+ return;
328
+ }
329
+ throw new Error("Unsupported CBOR value.");
330
+ }
331
+
332
+ function encodeTypeAndLength(type, length, chunks) {
333
+ const major = type << 5;
334
+ if (length < 24) {
335
+ chunks.push(new Uint8Array([major | length]));
336
+ } else if (length < 0x100) {
337
+ chunks.push(new Uint8Array([major | 24, length]));
338
+ } else if (length < 0x10000) {
339
+ chunks.push(new Uint8Array([major | 25, length >> 8, length & 0xff]));
340
+ } else {
341
+ const bytes = new Uint8Array(5);
342
+ bytes[0] = major | 26;
343
+ new DataView(bytes.buffer).setUint32(1, length, false);
344
+ chunks.push(bytes);
345
+ }
346
+ }
347
+
348
+ function derInteger(bytes) {
349
+ let start = 0;
350
+ while (start < bytes.length - 1 && bytes[start] === 0) start += 1;
351
+ let value = bytes.slice(start);
352
+ if (value[0] & 0x80) value = concatBytes(new Uint8Array([0]), value);
353
+ return concatBytes(new Uint8Array([0x02]), encodeDerLength(value.length), value);
354
+ }
355
+
356
+ function encodeDerLength(length) {
357
+ if (length < 0x80) return new Uint8Array([length]);
358
+ if (length < 0x100) return new Uint8Array([0x81, length]);
359
+ return new Uint8Array([0x82, length >> 8, length & 0xff]);
360
+ }
361
+
362
+ function parseJson(raw, name) {
363
+ try {
364
+ return JSON.parse(raw);
365
+ } catch {
366
+ throw new Error(`Stored passkey has invalid ${name}.`);
367
+ }
368
+ }
369
+
370
+ function btoaCompat(binary) {
371
+ if (typeof btoa === "function") return btoa(binary);
372
+ return Buffer.from(binary, "binary").toString("base64");
373
+ }
374
+
375
+ function atobCompat(value) {
376
+ if (typeof atob === "function") return atob(value);
377
+ return Buffer.from(value, "base64").toString("binary");
378
+ }
@@ -0,0 +1,79 @@
1
+ import { DEFAULT_BASE_URL, normalizeBaseUrl } from "../shared/url-tools.mjs";
2
+
3
+ export const STORAGE_KEYS = {
4
+ baseUrl: "wand.baseUrl",
5
+ appToken: "wand.appToken",
6
+ passkeyProxyEnabled: "wand.passkeyProxyEnabled"
7
+ };
8
+
9
+ export function extensionStorage() {
10
+ if (globalThis.chrome?.storage?.local) return globalThis.chrome.storage.local;
11
+ throw new Error("Browser extension storage is unavailable.");
12
+ }
13
+
14
+ export async function getSettings() {
15
+ const data = await extensionStorage().get({
16
+ [STORAGE_KEYS.baseUrl]: DEFAULT_BASE_URL,
17
+ [STORAGE_KEYS.appToken]: "",
18
+ [STORAGE_KEYS.passkeyProxyEnabled]: true
19
+ });
20
+ return {
21
+ baseUrl: normalizeBaseUrl(data[STORAGE_KEYS.baseUrl]),
22
+ appToken: data[STORAGE_KEYS.appToken] || "",
23
+ passkeyProxyEnabled: data[STORAGE_KEYS.passkeyProxyEnabled] !== false
24
+ };
25
+ }
26
+
27
+ export async function saveSettings(settings) {
28
+ const payload = {};
29
+ if (settings.baseUrl !== undefined) payload[STORAGE_KEYS.baseUrl] = normalizeBaseUrl(settings.baseUrl);
30
+ if (settings.appToken !== undefined) payload[STORAGE_KEYS.appToken] = settings.appToken || "";
31
+ if (settings.passkeyProxyEnabled !== undefined) payload[STORAGE_KEYS.passkeyProxyEnabled] = settings.passkeyProxyEnabled !== false;
32
+ await extensionStorage().set(payload);
33
+ }
34
+
35
+ export async function loginToWand(baseUrl, password) {
36
+ const normalizedBase = normalizeBaseUrl(baseUrl);
37
+ const response = await fetch(`${normalizedBase}/api/login`, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify({ password, client: "browser-extension" })
41
+ });
42
+ const data = await readJson(response);
43
+ if (!response.ok || !data.appToken) {
44
+ throw new Error(data.error || "Login failed.");
45
+ }
46
+ await saveSettings({ baseUrl: normalizedBase, appToken: data.appToken });
47
+ return { baseUrl: normalizedBase, appToken: data.appToken, serverUrl: data.serverUrl };
48
+ }
49
+
50
+ export async function apiFetch(path, options = {}) {
51
+ const settings = await getSettings();
52
+ if (!settings.appToken) {
53
+ throw new Error("Wand extension is locked. Sign in from Options.");
54
+ }
55
+ const headers = {
56
+ "Content-Type": "application/json",
57
+ ...(options.headers || {}),
58
+ Authorization: `Bearer ${settings.appToken}`
59
+ };
60
+ const response = await fetch(`${settings.baseUrl}${path}`, {
61
+ ...options,
62
+ headers
63
+ });
64
+ const data = await readJson(response);
65
+ if (!response.ok) {
66
+ throw new Error(data.error || `Request failed with ${response.status}.`);
67
+ }
68
+ return data;
69
+ }
70
+
71
+ export async function readJson(response) {
72
+ const text = await response.text();
73
+ if (!text) return {};
74
+ try {
75
+ return JSON.parse(text);
76
+ } catch {
77
+ return { error: text };
78
+ }
79
+ }