mouaif 0.3.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,515 @@
1
+ 'use strict';
2
+
3
+ // Local app access authentication: one account, password sessions, one-time
4
+ // setup codes, one-time disable codes, and WebAuthn credentials. Provider
5
+ // OAuth remains in auth.js.
6
+
7
+ const crypto = require('node:crypto');
8
+ const settings = require('./settings.js');
9
+
10
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
11
+ const SETUP_TTL_MS = 15 * 60 * 1000;
12
+ const DISABLE_TTL_MS = 15 * 60 * 1000;
13
+ const CHALLENGE_TTL_MS = 5 * 60 * 1000;
14
+ const CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
15
+ const challenges = new Map();
16
+
17
+ function b64url(value) {
18
+ return Buffer.from(value).toString('base64url');
19
+ }
20
+
21
+ function fromB64url(value) {
22
+ return Buffer.from(String(value || ''), 'base64url');
23
+ }
24
+
25
+ function digest(value) {
26
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
27
+ }
28
+
29
+ function safeEqual(a, b) {
30
+ const left = Buffer.from(a || '');
31
+ const right = Buffer.from(b || '');
32
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
33
+ }
34
+
35
+ function ensureTables() {
36
+ settings.getDb().exec(`
37
+ CREATE TABLE IF NOT EXISTS access_users (
38
+ id INTEGER PRIMARY KEY CHECK (id = 1),
39
+ username TEXT NOT NULL,
40
+ password_salt TEXT NOT NULL,
41
+ password_hash TEXT NOT NULL,
42
+ created_at TEXT NOT NULL,
43
+ updated_at TEXT NOT NULL
44
+ );
45
+ CREATE TABLE IF NOT EXISTS access_sessions (
46
+ token_hash TEXT PRIMARY KEY,
47
+ user_id INTEGER NOT NULL,
48
+ expires_at INTEGER NOT NULL,
49
+ created_at TEXT NOT NULL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS access_setup_codes (
52
+ code_hash TEXT PRIMARY KEY,
53
+ expires_at INTEGER NOT NULL,
54
+ created_at TEXT NOT NULL
55
+ );
56
+ CREATE TABLE IF NOT EXISTS access_passkeys (
57
+ credential_id TEXT PRIMARY KEY,
58
+ user_id INTEGER NOT NULL,
59
+ name TEXT NOT NULL,
60
+ public_key TEXT NOT NULL,
61
+ algorithm INTEGER NOT NULL,
62
+ counter INTEGER NOT NULL DEFAULT 0,
63
+ created_at TEXT NOT NULL,
64
+ last_used_at TEXT
65
+ );
66
+ CREATE TABLE IF NOT EXISTS access_disable_codes (
67
+ code_hash TEXT PRIMARY KEY,
68
+ expires_at INTEGER NOT NULL,
69
+ created_at TEXT NOT NULL
70
+ );
71
+ CREATE TABLE IF NOT EXISTS access_state (
72
+ id INTEGER PRIMARY KEY CHECK (id = 1),
73
+ enabled INTEGER NOT NULL,
74
+ updated_at TEXT NOT NULL
75
+ );
76
+ `);
77
+ }
78
+
79
+ function user() {
80
+ ensureTables();
81
+ return settings.getDb().prepare('SELECT id, username, created_at, updated_at FROM access_users WHERE id = 1').get() || null;
82
+ }
83
+
84
+ function configured() {
85
+ return !!user();
86
+ }
87
+
88
+ // ---- Enable state -------------------------------------------------------
89
+ // `mouaif serve --auth` (and friends) turns the gate on for a process, but
90
+ // access can also be disabled from inside the app — turning a protection off
91
+ // usually means "right now, on this device". The decision is persisted so a
92
+ // later restart that still passes an auth flag keeps it off instead of
93
+ // silently locking the user out of a store whose password may be forgotten.
94
+ function disabled() {
95
+ ensureTables();
96
+ const row = settings.getDb().prepare('SELECT enabled FROM access_state WHERE id = 1').get();
97
+ return !!row && row.enabled === 0;
98
+ }
99
+
100
+ function setEnabled(enabled) {
101
+ ensureTables();
102
+ const on = enabled !== false;
103
+ settings.getDb().prepare(`
104
+ INSERT INTO access_state (id, enabled, updated_at) VALUES (1, ?, ?)
105
+ ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at
106
+ `).run(on ? 1 : 0, new Date().toISOString());
107
+ return on;
108
+ }
109
+
110
+ // effectiveEnabled(processEnabled) — the gate the server actually enforces:
111
+ // a process started with an auth flag, unless the store says the user
112
+ // disabled access from inside the app.
113
+ function effectiveEnabled(processEnabled) {
114
+ return !!processEnabled && !disabled();
115
+ }
116
+
117
+ function validateCredentials(username, password) {
118
+ const cleanUser = String(username || '').trim();
119
+ const cleanPassword = String(password || '');
120
+ if (cleanUser.length < 1 || cleanUser.length > 128) throw Object.assign(new Error('User must be 1–128 characters'), { code: 'EBADUSER' });
121
+ if (cleanPassword.length < 8 || cleanPassword.length > 1024) throw Object.assign(new Error('Password must be at least 8 characters'), { code: 'EBADPASSWORD' });
122
+ return { username: cleanUser, password: cleanPassword };
123
+ }
124
+
125
+ function setPassword(username, password) {
126
+ ensureTables();
127
+ const clean = validateCredentials(username, password);
128
+ // Repeating the same --user/--password on a normal or watch-mode restart
129
+ // must not log out every browser or discard passkeys.
130
+ if (verifyPassword(clean.username, clean.password)) return user();
131
+ const salt = crypto.randomBytes(16);
132
+ const hash = crypto.scryptSync(clean.password, salt, 32);
133
+ const now = new Date().toISOString();
134
+ settings.getDb().prepare(`
135
+ INSERT INTO access_users (id, username, password_salt, password_hash, created_at, updated_at)
136
+ VALUES (1, ?, ?, ?, ?, ?)
137
+ ON CONFLICT(id) DO UPDATE SET
138
+ username = excluded.username,
139
+ password_salt = excluded.password_salt,
140
+ password_hash = excluded.password_hash,
141
+ updated_at = excluded.updated_at
142
+ `).run(clean.username, salt.toString('base64'), hash.toString('base64'), now, now);
143
+ // A password reset is a security boundary: invalidate every old browser.
144
+ settings.getDb().prepare('DELETE FROM access_sessions').run();
145
+ settings.getDb().prepare('DELETE FROM access_passkeys').run();
146
+ return user();
147
+ }
148
+
149
+ function verifyPassword(username, password) {
150
+ ensureTables();
151
+ const row = settings.getDb().prepare('SELECT * FROM access_users WHERE id = 1').get();
152
+ if (!row) return false;
153
+ const actual = crypto.scryptSync(String(password || ''), Buffer.from(row.password_salt, 'base64'), 32);
154
+ const expected = Buffer.from(row.password_hash, 'base64');
155
+ return safeEqual(String(username || '').trim(), row.username) && safeEqual(actual, expected);
156
+ }
157
+
158
+ // Password change from an authenticated session. Verifies the current
159
+ // password, validates and stores the new one, then revokes every other
160
+ // session and discards passkeys — the caller keeps the session token
161
+ // passed here so the current browser stays signed in. Rejects if the new
162
+ // password matches the current one (a no-op that would still invalidate
163
+ // other sessions and passkeys otherwise).
164
+ function changePassword(username, currentPassword, newPassword, keepToken) {
165
+ ensureTables();
166
+ const clean = validateCredentials(username, newPassword);
167
+ if (!verifyPassword(clean.username, currentPassword)) {
168
+ throw Object.assign(new Error('Current password is incorrect'), { code: 'EBADCREDENTIALS' });
169
+ }
170
+ if (verifyPassword(clean.username, newPassword)) {
171
+ throw Object.assign(new Error('New password must be different from the current one'), { code: 'EBADPASSWORD' });
172
+ }
173
+ const salt = crypto.randomBytes(16);
174
+ const hash = crypto.scryptSync(clean.password, salt, 32);
175
+ const now = new Date().toISOString();
176
+ settings.getDb().prepare(`
177
+ UPDATE access_users SET
178
+ username = ?, password_salt = ?, password_hash = ?, updated_at = ?
179
+ WHERE id = 1
180
+ `).run(clean.username, salt.toString('base64'), hash.toString('base64'), now);
181
+ // A password change is a security boundary like a reset: invalidate
182
+ // every old browser and discard passkeys. The current session is
183
+ // re-issued below so this browser stays signed in.
184
+ settings.getDb().prepare('DELETE FROM access_sessions').run();
185
+ settings.getDb().prepare('DELETE FROM access_passkeys').run();
186
+ if (keepToken) {
187
+ const expiresAt = Date.now() + SESSION_TTL_MS;
188
+ settings.getDb().prepare('INSERT INTO access_sessions (token_hash, user_id, expires_at, created_at) VALUES (?, 1, ?, ?)')
189
+ .run(digest(keepToken), expiresAt, new Date().toISOString());
190
+ }
191
+ return { user: user(), expiresAt: keepToken ? Date.now() + SESSION_TTL_MS : null };
192
+ }
193
+
194
+ function issueSession() {
195
+ ensureTables();
196
+ const token = crypto.randomBytes(32).toString('base64url');
197
+ const expiresAt = Date.now() + SESSION_TTL_MS;
198
+ settings.getDb().prepare('DELETE FROM access_sessions WHERE expires_at <= ?').run(Date.now());
199
+ settings.getDb().prepare('INSERT INTO access_sessions (token_hash, user_id, expires_at, created_at) VALUES (?, 1, ?, ?)')
200
+ .run(digest(token), expiresAt, new Date().toISOString());
201
+ return { token, expiresAt };
202
+ }
203
+
204
+ function session(token) {
205
+ ensureTables();
206
+ if (!token) return null;
207
+ return settings.getDb().prepare(`
208
+ SELECT s.expires_at, u.id, u.username
209
+ FROM access_sessions s JOIN access_users u ON u.id = s.user_id
210
+ WHERE s.token_hash = ? AND s.expires_at > ?
211
+ `).get(digest(token), Date.now()) || null;
212
+ }
213
+
214
+ function revokeSession(token) {
215
+ ensureTables();
216
+ if (!token) return false;
217
+ return settings.getDb().prepare('DELETE FROM access_sessions WHERE token_hash = ?').run(digest(token)).changes > 0;
218
+ }
219
+
220
+ function randomCode() {
221
+ let value = '';
222
+ for (const byte of crypto.randomBytes(8)) value += CODE_ALPHABET[byte % CODE_ALPHABET.length];
223
+ return value.slice(0, 4) + '-' + value.slice(4, 8);
224
+ }
225
+
226
+ function normalizeCode(code) {
227
+ const raw = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
228
+ return raw.length === 8 ? raw.slice(0, 4) + '-' + raw.slice(4) : '';
229
+ }
230
+
231
+ function createSetupCode(ttlMs = SETUP_TTL_MS) {
232
+ ensureTables();
233
+ const code = randomCode();
234
+ const expiresAt = Date.now() + Math.max(60_000, Number(ttlMs) || SETUP_TTL_MS);
235
+ settings.getDb().prepare('DELETE FROM access_setup_codes WHERE expires_at <= ?').run(Date.now());
236
+ settings.getDb().prepare('INSERT INTO access_setup_codes (code_hash, expires_at, created_at) VALUES (?, ?, ?)')
237
+ .run(digest(code), expiresAt, new Date().toISOString());
238
+ return { code, expiresAt };
239
+ }
240
+
241
+ function setupCodeValid(code) {
242
+ ensureTables();
243
+ const normalized = normalizeCode(code);
244
+ if (!normalized) return false;
245
+ return !!settings.getDb().prepare('SELECT 1 FROM access_setup_codes WHERE code_hash = ? AND expires_at > ?')
246
+ .get(digest(normalized), Date.now());
247
+ }
248
+
249
+ function consumeSetupCode(code) {
250
+ ensureTables();
251
+ const normalized = normalizeCode(code);
252
+ if (!normalized) return false;
253
+ const hash = digest(normalized);
254
+ const transaction = settings.getDb().transaction(() => {
255
+ const valid = settings.getDb().prepare('SELECT 1 FROM access_setup_codes WHERE code_hash = ? AND expires_at > ?').get(hash, Date.now());
256
+ if (!valid) return false;
257
+ settings.getDb().prepare('DELETE FROM access_setup_codes WHERE code_hash = ?').run(hash);
258
+ return true;
259
+ });
260
+ return transaction();
261
+ }
262
+
263
+ // createDisableCode() — mint a short-lived, single-use code that turns app
264
+ // access off. Modelled on createSetupCode() with its own table so the two
265
+ // invitations can never be confused (a setup code adds a password, a disable
266
+ // code removes the login wall). Restricting access is signed-in-only, so no
267
+ // code is needed for that direction; only disabling is code-gated.
268
+ function createDisableCode(ttlMs = DISABLE_TTL_MS) {
269
+ ensureTables();
270
+ const code = randomCode();
271
+ const expiresAt = Date.now() + Math.max(60_000, Number(ttlMs) || DISABLE_TTL_MS);
272
+ settings.getDb().prepare('DELETE FROM access_disable_codes WHERE expires_at <= ?').run(Date.now());
273
+ settings.getDb().prepare('INSERT INTO access_disable_codes (code_hash, expires_at, created_at) VALUES (?, ?, ?)')
274
+ .run(digest(code), expiresAt, new Date().toISOString());
275
+ return { code, expiresAt };
276
+ }
277
+
278
+ function disableCodeValid(code) {
279
+ ensureTables();
280
+ const normalized = normalizeCode(code);
281
+ if (!normalized) return false;
282
+ return !!settings.getDb().prepare('SELECT 1 FROM access_disable_codes WHERE code_hash = ? AND expires_at > ?')
283
+ .get(digest(normalized), Date.now());
284
+ }
285
+
286
+ function consumeDisableCode(code) {
287
+ ensureTables();
288
+ const normalized = normalizeCode(code);
289
+ if (!normalized) return false;
290
+ const hash = digest(normalized);
291
+ const transaction = settings.getDb().transaction(() => {
292
+ const valid = settings.getDb().prepare('SELECT 1 FROM access_disable_codes WHERE code_hash = ? AND expires_at > ?').get(hash, Date.now());
293
+ if (!valid) return false;
294
+ settings.getDb().prepare('DELETE FROM access_disable_codes WHERE code_hash = ?').run(hash);
295
+ return true;
296
+ });
297
+ return transaction();
298
+ }
299
+
300
+ function passkeys() {
301
+ ensureTables();
302
+ return settings.getDb().prepare('SELECT credential_id AS id, name, created_at AS createdAt, last_used_at AS lastUsedAt FROM access_passkeys ORDER BY created_at').all();
303
+ }
304
+
305
+ function challengeKey(id) {
306
+ return digest(id);
307
+ }
308
+
309
+ function putChallenge(type, data) {
310
+ const id = crypto.randomBytes(18).toString('base64url');
311
+ const challenge = crypto.randomBytes(32).toString('base64url');
312
+ challenges.set(challengeKey(id), { type, challenge, expiresAt: Date.now() + CHALLENGE_TTL_MS, ...data });
313
+ return { id, challenge };
314
+ }
315
+
316
+ function takeChallenge(id, type) {
317
+ const key = challengeKey(id);
318
+ const value = challenges.get(key);
319
+ challenges.delete(key);
320
+ if (!value || value.type !== type || value.expiresAt <= Date.now()) throw Object.assign(new Error('Challenge is missing or expired'), { code: 'ECHALLENGE' });
321
+ return value;
322
+ }
323
+
324
+ function rpDetails(origin) {
325
+ const parsed = new URL(origin);
326
+ return { origin: parsed.origin, rpId: parsed.hostname, rpName: 'mouaif' };
327
+ }
328
+
329
+ function beginRegistration({ origin, username, authorizedBy }) {
330
+ const rp = rpDetails(origin);
331
+ const pending = putChallenge('register', { ...rp, username, authorizedBy });
332
+ return {
333
+ challengeId: pending.id,
334
+ publicKey: {
335
+ challenge: pending.challenge,
336
+ rp: { id: rp.rpId, name: rp.rpName },
337
+ user: { id: b64url(Buffer.from('mouaif-user-1')), name: username, displayName: username },
338
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
339
+ timeout: CHALLENGE_TTL_MS,
340
+ attestation: 'none',
341
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
342
+ excludeCredentials: passkeys().map((entry) => ({ type: 'public-key', id: entry.id }))
343
+ }
344
+ };
345
+ }
346
+
347
+ function readCbor(buffer, offset = 0) {
348
+ const first = buffer[offset++];
349
+ if (first === undefined) throw new Error('Invalid CBOR');
350
+ const major = first >> 5;
351
+ const info = first & 31;
352
+ if (major === 7 && info === 20) return { value: false, offset };
353
+ if (major === 7 && info === 21) return { value: true, offset };
354
+ if (major === 7 && (info === 22 || info === 23)) return { value: null, offset };
355
+ let length;
356
+ if (info < 24) length = info;
357
+ else if (info === 24) length = buffer[offset++];
358
+ else if (info === 25) { length = buffer.readUInt16BE(offset); offset += 2; }
359
+ else if (info === 26) { length = buffer.readUInt32BE(offset); offset += 4; }
360
+ else throw new Error('Unsupported CBOR length');
361
+ if (major === 0) return { value: length, offset };
362
+ if (major === 1) return { value: -1 - length, offset };
363
+ if (major === 2) return { value: buffer.subarray(offset, offset + length), offset: offset + length };
364
+ if (major === 3) return { value: buffer.toString('utf8', offset, offset + length), offset: offset + length };
365
+ if (major === 4) {
366
+ const value = [];
367
+ for (let i = 0; i < length; i++) { const item = readCbor(buffer, offset); value.push(item.value); offset = item.offset; }
368
+ return { value, offset };
369
+ }
370
+ if (major === 5) {
371
+ const value = new Map();
372
+ for (let i = 0; i < length; i++) {
373
+ const key = readCbor(buffer, offset); offset = key.offset;
374
+ const item = readCbor(buffer, offset); offset = item.offset;
375
+ value.set(key.value, item.value);
376
+ }
377
+ return { value, offset };
378
+ }
379
+ throw new Error('Unsupported CBOR type');
380
+ }
381
+
382
+ function parseClientData(encoded, expectedType, pending) {
383
+ let client;
384
+ try { client = JSON.parse(fromB64url(encoded).toString('utf8')); } catch { throw Object.assign(new Error('Invalid WebAuthn client data'), { code: 'EWEBAUTHN' }); }
385
+ if (client.type !== expectedType || client.challenge !== pending.challenge || client.origin !== pending.origin) {
386
+ throw Object.assign(new Error('WebAuthn challenge or origin did not match'), { code: 'EWEBAUTHN' });
387
+ }
388
+ return fromB64url(encoded);
389
+ }
390
+
391
+ function parseAuthData(buffer, rpId) {
392
+ if (buffer.length < 37) throw new Error('Invalid authenticator data');
393
+ const expectedRp = crypto.createHash('sha256').update(rpId).digest();
394
+ if (!safeEqual(buffer.subarray(0, 32), expectedRp)) throw new Error('WebAuthn RP ID did not match');
395
+ const flags = buffer[32];
396
+ if (!(flags & 0x01)) throw new Error('WebAuthn user presence is required');
397
+ return { flags, counter: buffer.readUInt32BE(33), rest: buffer.subarray(37) };
398
+ }
399
+
400
+ function coseToPem(cose) {
401
+ const alg = cose.get(3);
402
+ const type = cose.get(1);
403
+ if (type === 2 && alg === -7) {
404
+ const x = cose.get(-2); const y = cose.get(-3);
405
+ if (!Buffer.isBuffer(x) || !Buffer.isBuffer(y)) throw new Error('Invalid EC passkey');
406
+ return { algorithm: alg, pem: crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: b64url(x), y: b64url(y) }, format: 'jwk' }).export({ type: 'spki', format: 'pem' }) };
407
+ }
408
+ if (type === 3 && alg === -257) {
409
+ const n = cose.get(-1); const e = cose.get(-2);
410
+ if (!Buffer.isBuffer(n) || !Buffer.isBuffer(e)) throw new Error('Invalid RSA passkey');
411
+ return { algorithm: alg, pem: crypto.createPublicKey({ key: { kty: 'RSA', n: b64url(n), e: b64url(e), alg: 'RS256' }, format: 'jwk' }).export({ type: 'spki', format: 'pem' }) };
412
+ }
413
+ throw new Error('Unsupported passkey algorithm');
414
+ }
415
+
416
+ function finishRegistration({ challengeId, credential, name }) {
417
+ ensureTables();
418
+ const pending = takeChallenge(challengeId, 'register');
419
+ const response = credential && credential.response;
420
+ if (!credential || credential.type !== 'public-key' || !credential.id || !response) throw new Error('Invalid passkey credential');
421
+ parseClientData(response.clientDataJSON, 'webauthn.create', pending);
422
+ const attestation = readCbor(fromB64url(response.attestationObject)).value;
423
+ const authData = attestation instanceof Map ? attestation.get('authData') : null;
424
+ if (!Buffer.isBuffer(authData)) throw new Error('Passkey attestation has no authenticator data');
425
+ const parsed = parseAuthData(authData, pending.rpId);
426
+ if (!(parsed.flags & 0x40) || parsed.rest.length < 18) throw new Error('Passkey attestation has no credential data');
427
+ const idLength = parsed.rest.readUInt16BE(16);
428
+ const id = parsed.rest.subarray(18, 18 + idLength);
429
+ const cose = readCbor(parsed.rest, 18 + idLength).value;
430
+ if (!(cose instanceof Map)) throw new Error('Invalid passkey public key');
431
+ const submittedId = fromB64url(credential.rawId || credential.id);
432
+ if (!safeEqual(id, submittedId)) throw new Error('Passkey credential ID did not match');
433
+ const key = coseToPem(cose);
434
+ if (pending.authorizedBy !== 'session' && !consumeSetupCode(pending.authorizedBy)) {
435
+ throw Object.assign(new Error('Setup code was already used or expired'), { code: 'ESETUP_CODE' });
436
+ }
437
+ settings.getDb().prepare(`
438
+ INSERT INTO access_passkeys (credential_id, user_id, name, public_key, algorithm, counter, created_at)
439
+ VALUES (?, 1, ?, ?, ?, ?, ?)
440
+ ON CONFLICT(credential_id) DO UPDATE SET name = excluded.name, public_key = excluded.public_key, algorithm = excluded.algorithm
441
+ `).run(b64url(id), String(name || 'Passkey').slice(0, 80), key.pem, key.algorithm, parsed.counter, new Date().toISOString());
442
+ return { passkeys: passkeys() };
443
+ }
444
+
445
+ function beginAuthentication({ origin }) {
446
+ const rp = rpDetails(origin);
447
+ const keys = passkeys();
448
+ if (!keys.length) throw Object.assign(new Error('No passkeys are registered'), { code: 'ENOPASSKEY' });
449
+ const pending = putChallenge('authenticate', rp);
450
+ return {
451
+ challengeId: pending.id,
452
+ publicKey: {
453
+ challenge: pending.challenge,
454
+ rpId: rp.rpId,
455
+ timeout: CHALLENGE_TTL_MS,
456
+ userVerification: 'preferred',
457
+ allowCredentials: keys.map((entry) => ({ type: 'public-key', id: entry.id }))
458
+ }
459
+ };
460
+ }
461
+
462
+ function finishAuthentication({ challengeId, credential }) {
463
+ ensureTables();
464
+ const pending = takeChallenge(challengeId, 'authenticate');
465
+ const response = credential && credential.response;
466
+ if (!credential || credential.type !== 'public-key' || !credential.id || !response) throw new Error('Invalid passkey credential');
467
+ const row = settings.getDb().prepare('SELECT * FROM access_passkeys WHERE credential_id = ?').get(String(credential.id));
468
+ if (!row) throw new Error('Passkey is not registered');
469
+ const clientData = parseClientData(response.clientDataJSON, 'webauthn.get', pending);
470
+ const authData = fromB64url(response.authenticatorData);
471
+ const parsed = parseAuthData(authData, pending.rpId);
472
+ const signed = Buffer.concat([authData, crypto.createHash('sha256').update(clientData).digest()]);
473
+ const algorithm = row.algorithm === -7 ? 'sha256' : 'RSA-SHA256';
474
+ if (!crypto.verify(algorithm, signed, row.public_key, fromB64url(response.signature))) throw new Error('Passkey signature was invalid');
475
+ if (row.counter > 0 && parsed.counter > 0 && parsed.counter <= row.counter) throw new Error('Passkey counter did not advance');
476
+ settings.getDb().prepare('UPDATE access_passkeys SET counter = ?, last_used_at = ? WHERE credential_id = ?')
477
+ .run(parsed.counter, new Date().toISOString(), row.credential_id);
478
+ return user();
479
+ }
480
+
481
+ function deletePasskey(id) {
482
+ ensureTables();
483
+ return settings.getDb().prepare('DELETE FROM access_passkeys WHERE credential_id = ?').run(String(id || '')).changes > 0;
484
+ }
485
+
486
+ module.exports = {
487
+ SESSION_TTL_MS,
488
+ SETUP_TTL_MS,
489
+ DISABLE_TTL_MS,
490
+ ensureTables,
491
+ configured,
492
+ user,
493
+ disabled,
494
+ setEnabled,
495
+ effectiveEnabled,
496
+ setPassword,
497
+ verifyPassword,
498
+ changePassword,
499
+ issueSession,
500
+ session,
501
+ revokeSession,
502
+ createSetupCode,
503
+ normalizeCode,
504
+ setupCodeValid,
505
+ consumeSetupCode,
506
+ createDisableCode,
507
+ disableCodeValid,
508
+ consumeDisableCode,
509
+ passkeys,
510
+ beginRegistration,
511
+ finishRegistration,
512
+ beginAuthentication,
513
+ finishAuthentication,
514
+ deletePasskey
515
+ };