@shawnstack/quickforge 1.2.3 → 1.2.5

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 (46) hide show
  1. package/README.md +163 -137
  2. package/dist/assets/{anthropic-7FxZObl9.js → anthropic-CI03rw7t.js} +1 -1
  3. package/dist/assets/{azure-openai-responses-DF-9sGwe.js → azure-openai-responses-mJnVT01L.js} +1 -1
  4. package/dist/assets/{google-Cz9fijiR.js → google-Dd9kRX4-.js} +1 -1
  5. package/dist/assets/{google-gemini-cli-DDvXYFFZ.js → google-gemini-cli-OPRz-Y1Q.js} +1 -1
  6. package/dist/assets/{google-vertex-8_3WLs6r.js → google-vertex-BhiKU4Ck.js} +1 -1
  7. package/dist/assets/{icons-BzvJv-Bv.js → icons-DjhMV6OE.js} +1 -1
  8. package/dist/assets/index-CX5KwBUy.css +3 -0
  9. package/dist/assets/{index-CnZAwmI7.js → index-EXRhW4K3.js} +624 -458
  10. package/dist/assets/{mistral-Cr6UaIcE.js → mistral-CboE22Tk.js} +1 -1
  11. package/dist/assets/{openai-codex-responses-BGNNQyHr.js → openai-codex-responses-DkEjFdsq.js} +1 -1
  12. package/dist/assets/{openai-completions-BxHDkwpI.js → openai-completions-CRPL8DsA.js} +1 -1
  13. package/dist/assets/{openai-responses-C1ZTWF9s.js → openai-responses-Db0Mxk_N.js} +1 -1
  14. package/dist/assets/{openai-responses-shared-R3hZNeJJ.js → openai-responses-shared-BgcP9Jll.js} +1 -1
  15. package/dist/assets/{react-vendor-CdZo8gqc.js → react-vendor-BK8yG_FK.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/node_modules/protobufjs/dist/light/protobuf.js +36 -12
  18. package/node_modules/protobufjs/dist/light/protobuf.js.map +1 -1
  19. package/node_modules/protobufjs/dist/light/protobuf.min.js +3 -3
  20. package/node_modules/protobufjs/dist/light/protobuf.min.js.map +1 -1
  21. package/node_modules/protobufjs/dist/minimal/protobuf.js +2 -2
  22. package/node_modules/protobufjs/dist/minimal/protobuf.min.js +2 -2
  23. package/node_modules/protobufjs/dist/protobuf.js +71 -42
  24. package/node_modules/protobufjs/dist/protobuf.js.map +1 -1
  25. package/node_modules/protobufjs/dist/protobuf.min.js +3 -3
  26. package/node_modules/protobufjs/dist/protobuf.min.js.map +1 -1
  27. package/node_modules/protobufjs/index.d.ts +18 -5
  28. package/node_modules/protobufjs/package.json +1 -1
  29. package/node_modules/protobufjs/src/namespace.js +8 -4
  30. package/node_modules/protobufjs/src/parse.js +35 -30
  31. package/node_modules/protobufjs/src/root.js +4 -2
  32. package/node_modules/protobufjs/src/service.js +4 -2
  33. package/node_modules/protobufjs/src/type.js +4 -2
  34. package/node_modules/protobufjs/src/util.js +14 -0
  35. package/node_modules/ws/lib/sender.js +6 -1
  36. package/node_modules/ws/package.json +1 -1
  37. package/package.json +1 -1
  38. package/server/agent-manager.mjs +2 -2
  39. package/server/index.mjs +50 -6
  40. package/server/lan-access-store.mjs +215 -0
  41. package/server/routes/backup.mjs +184 -39
  42. package/server/routes/lan-access.mjs +201 -0
  43. package/server/share-store.mjs +7 -29
  44. package/server/tools/index.mjs +39 -13
  45. package/server/utils/password-auth.mjs +44 -0
  46. package/dist/assets/index-B9f6WrD6.css +0 -3
@@ -0,0 +1,215 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { ensureStorage, storageDir } from './storage.mjs'
4
+ import { createRandomToken, hashPassword, safeHashEqual, sha256Base64Url, verifyPassword } from './utils/password-auth.mjs'
5
+
6
+ const LAN_ACCESS_DIR = path.join(storageDir, 'security')
7
+ const LAN_ACCESS_FILE = path.join(LAN_ACCESS_DIR, 'lan-access.json')
8
+ const LAN_TOKEN_MAX_COUNT = 100
9
+ const DEFAULT_SESSION_TTL_HOURS = 12
10
+ const MIN_PASSWORD_LENGTH = 8
11
+ const writeQueueName = 'lan-access'
12
+ const writeQueues = new Map()
13
+
14
+ function enqueueWrite(queueName, operation) {
15
+ const previous = writeQueues.get(queueName) || Promise.resolve()
16
+ const next = previous
17
+ .catch(() => undefined)
18
+ .then(operation)
19
+ writeQueues.set(queueName, next)
20
+ return next
21
+ }
22
+
23
+ function defaultLanAccessConfig() {
24
+ return {
25
+ enabled: false,
26
+ passwordHash: undefined,
27
+ passwordSalt: undefined,
28
+ passwordVersion: undefined,
29
+ authVersion: 1,
30
+ sessionTtlHours: DEFAULT_SESSION_TTL_HOURS,
31
+ updatedAt: new Date().toISOString(),
32
+ tokens: [],
33
+ }
34
+ }
35
+
36
+ function normalizeSessionTtlHours(value) {
37
+ const numeric = Number(value)
38
+ if (!Number.isFinite(numeric)) return DEFAULT_SESSION_TTL_HOURS
39
+ return Math.min(24 * 7, Math.max(1, Math.round(numeric)))
40
+ }
41
+
42
+ function normalizeConfig(value) {
43
+ const base = defaultLanAccessConfig()
44
+ const input = value && typeof value === 'object' ? value : {}
45
+ return {
46
+ ...base,
47
+ ...input,
48
+ enabled: Boolean(input.enabled),
49
+ authVersion: Number(input.authVersion || base.authVersion),
50
+ sessionTtlHours: normalizeSessionTtlHours(input.sessionTtlHours),
51
+ tokens: pruneTokens(input.tokens),
52
+ updatedAt: typeof input.updatedAt === 'string' ? input.updatedAt : base.updatedAt,
53
+ }
54
+ }
55
+
56
+ async function ensureLanAccessStore() {
57
+ await ensureStorage()
58
+ await fs.mkdir(LAN_ACCESS_DIR, { recursive: true })
59
+ try {
60
+ await fs.access(LAN_ACCESS_FILE)
61
+ } catch {
62
+ await fs.writeFile(LAN_ACCESS_FILE, `${JSON.stringify(defaultLanAccessConfig(), null, 2)}\n`, 'utf8')
63
+ }
64
+ }
65
+
66
+ async function readLanAccessFile() {
67
+ await ensureLanAccessStore()
68
+ try {
69
+ const raw = await fs.readFile(LAN_ACCESS_FILE, 'utf8')
70
+ const text = raw.trimStart()
71
+ return normalizeConfig(text ? JSON.parse(text) : {})
72
+ } catch (error) {
73
+ if (error?.code === 'ENOENT') return defaultLanAccessConfig()
74
+ throw error
75
+ }
76
+ }
77
+
78
+ async function writeLanAccessFile(config) {
79
+ await ensureLanAccessStore()
80
+ await fs.writeFile(LAN_ACCESS_FILE, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`, 'utf8')
81
+ }
82
+
83
+ function publicStatus(config) {
84
+ return {
85
+ enabled: Boolean(config.enabled),
86
+ hasPassword: Boolean(config.passwordHash),
87
+ sessionTtlHours: config.sessionTtlHours,
88
+ authVersion: config.authVersion || 1,
89
+ activeTokenCount: pruneTokens(config.tokens).length,
90
+ updatedAt: config.updatedAt,
91
+ }
92
+ }
93
+
94
+ function pruneTokens(tokens, now = Date.now()) {
95
+ return (Array.isArray(tokens) ? tokens : [])
96
+ .filter((tokenRecord) => {
97
+ if (!tokenRecord?.tokenHash) return false
98
+ if (!tokenRecord.expiresAt) return true
99
+ return Date.parse(tokenRecord.expiresAt) > now
100
+ })
101
+ .slice(-LAN_TOKEN_MAX_COUNT)
102
+ }
103
+
104
+ function assertPasswordAllowed(password) {
105
+ if (typeof password !== 'string' || password.trim().length < MIN_PASSWORD_LENGTH) {
106
+ const error = new Error(`LAN access password must be at least ${MIN_PASSWORD_LENGTH} characters.`)
107
+ error.statusCode = 400
108
+ throw error
109
+ }
110
+ }
111
+
112
+ export async function readLanAccessStatus() {
113
+ return publicStatus(await readLanAccessFile())
114
+ }
115
+
116
+ export async function readLanAccessConfig() {
117
+ return readLanAccessFile()
118
+ }
119
+
120
+ export async function updateLanAccessSettings({ enabled, password, sessionTtlHours }) {
121
+ return enqueueWrite(writeQueueName, async () => {
122
+ const current = await readLanAccessFile()
123
+ const passwordProvided = typeof password === 'string' && password.length > 0
124
+ const nextEnabled = Boolean(enabled)
125
+
126
+ if (passwordProvided) assertPasswordAllowed(password)
127
+ if (nextEnabled && !passwordProvided && !current.passwordHash) {
128
+ const error = new Error('LAN access password is required before enabling full LAN access.')
129
+ error.statusCode = 400
130
+ throw error
131
+ }
132
+
133
+ const passwordInfo = passwordProvided ? await hashPassword(password.trim()) : {}
134
+ const now = new Date().toISOString()
135
+ const authChanged = passwordProvided || current.enabled !== nextEnabled
136
+ const next = normalizeConfig({
137
+ ...current,
138
+ ...passwordInfo,
139
+ enabled: nextEnabled,
140
+ authVersion: authChanged ? (current.authVersion || 1) + 1 : (current.authVersion || 1),
141
+ sessionTtlHours: normalizeSessionTtlHours(sessionTtlHours ?? current.sessionTtlHours),
142
+ updatedAt: now,
143
+ tokens: authChanged ? [] : pruneTokens(current.tokens),
144
+ })
145
+
146
+ await writeLanAccessFile(next)
147
+ return publicStatus(next)
148
+ })
149
+ }
150
+
151
+ export async function revokeLanAccessTokens() {
152
+ return enqueueWrite(writeQueueName, async () => {
153
+ const current = await readLanAccessFile()
154
+ const next = normalizeConfig({
155
+ ...current,
156
+ authVersion: (current.authVersion || 1) + 1,
157
+ tokens: [],
158
+ updatedAt: new Date().toISOString(),
159
+ })
160
+ await writeLanAccessFile(next)
161
+ return publicStatus(next)
162
+ })
163
+ }
164
+
165
+ export function lanAccessCookieName() {
166
+ return 'qf_lan_access'
167
+ }
168
+
169
+ export async function issueLanAccessToken(password) {
170
+ return enqueueWrite(writeQueueName, async () => {
171
+ const current = await readLanAccessFile()
172
+ if (!current.enabled || !current.passwordHash) {
173
+ const error = new Error('LAN access is not enabled.')
174
+ error.statusCode = 403
175
+ throw error
176
+ }
177
+ if (!(await verifyPassword(current, typeof password === 'string' ? password.trim() : ''))) {
178
+ const error = new Error('Invalid LAN access password')
179
+ error.statusCode = 401
180
+ throw error
181
+ }
182
+
183
+ const secret = createRandomToken(32)
184
+ const tokenHash = sha256Base64Url(secret)
185
+ const issuedAt = new Date().toISOString()
186
+ const ttlMs = normalizeSessionTtlHours(current.sessionTtlHours) * 60 * 60 * 1000
187
+ const expiresAt = new Date(Date.now() + ttlMs).toISOString()
188
+ const next = normalizeConfig({
189
+ ...current,
190
+ tokens: [
191
+ ...pruneTokens(current.tokens),
192
+ { tokenHash, issuedAt, expiresAt, authVersion: current.authVersion || 1 },
193
+ ].slice(-LAN_TOKEN_MAX_COUNT),
194
+ updatedAt: issuedAt,
195
+ })
196
+ await writeLanAccessFile(next)
197
+ return {
198
+ token: `${current.authVersion || 1}.${secret}`,
199
+ expiresAt,
200
+ maxAge: Math.floor(ttlMs / 1000),
201
+ }
202
+ })
203
+ }
204
+
205
+ export async function verifyLanAccessToken(token) {
206
+ const current = await readLanAccessFile()
207
+ if (!current.enabled || !current.passwordHash || !token || typeof token !== 'string') return false
208
+ const [versionText, secret] = token.split('.')
209
+ if (Number(versionText) !== (current.authVersion || 1) || !secret) return false
210
+ const actualHash = sha256Base64Url(secret)
211
+ return pruneTokens(current.tokens).some((tokenRecord) => {
212
+ if ((tokenRecord.authVersion || 1) !== (current.authVersion || 1)) return false
213
+ return safeHashEqual(tokenRecord.tokenHash, actualHash)
214
+ })
215
+ }
@@ -16,12 +16,18 @@ import { getWorkspaceRoot } from '../utils/workspace.mjs'
16
16
  const BACKUP_VERSION = 1
17
17
  const BACKUP_APP = 'quickforge'
18
18
  const backupScopes = new Set(['all', 'config', 'sessions'])
19
+ const restoreSectionIds = new Set(['settings', 'providerKeys', 'customProviders', 'projects', 'scheduledTasks', 'conversations'])
19
20
 
20
21
  function normalizeScope(value) {
21
22
  const scope = String(value || 'all')
22
23
  return backupScopes.has(scope) ? scope : 'all'
23
24
  }
24
25
 
26
+ function parseBoolean(value) {
27
+ const text = String(value || '').toLowerCase()
28
+ return text === '1' || text === 'true' || text === 'yes'
29
+ }
30
+
25
31
  function backupTimestamp(date = new Date()) {
26
32
  return date.toISOString().replace(/[:.]/g, '-')
27
33
  }
@@ -53,6 +59,7 @@ function assertProjectConfig(value) {
53
59
  }
54
60
  return {
55
61
  activeProjectId: typeof projectConfig.activeProjectId === 'string' ? projectConfig.activeProjectId : null,
62
+ globalSkills: Array.isArray(projectConfig.globalSkills) ? projectConfig.globalSkills : [],
56
63
  projects: projectConfig.projects,
57
64
  }
58
65
  }
@@ -94,27 +101,28 @@ function normalizeSessionMetadata(sessions, metadata) {
94
101
  return nextMetadata
95
102
  }
96
103
 
97
- async function buildBackup(scope = 'all') {
104
+ async function buildBackup(scope = 'all', options = {}) {
98
105
  const normalizedScope = normalizeScope(scope)
99
106
  const includeConfig = normalizedScope === 'all' || normalizedScope === 'config'
100
107
  const includeSessions = normalizedScope === 'all' || normalizedScope === 'sessions'
108
+ const includeSecrets = Boolean(options.includeSecrets && includeConfig)
101
109
  const data = {}
102
110
 
103
111
  if (includeConfig) {
104
112
  const [settings, providerKeys, customProviders, projects, scheduledTasks] = await Promise.all([
105
113
  readStore('settings'),
106
- readStore('provider-keys'),
114
+ includeSecrets ? readStore('provider-keys') : Promise.resolve(undefined),
107
115
  readStore('custom-providers'),
108
116
  readProjectConfigData(),
109
117
  readStore('scheduled-tasks'),
110
118
  ])
111
119
  Object.assign(data, {
112
120
  settings,
113
- providerKeys,
114
121
  customProviders,
115
122
  projects,
116
123
  scheduledTasks,
117
124
  })
125
+ if (includeSecrets) data.providerKeys = providerKeys
118
126
  }
119
127
 
120
128
  if (includeSessions) {
@@ -133,6 +141,7 @@ async function buildBackup(scope = 'all') {
133
141
  version: BACKUP_VERSION,
134
142
  exportedAt: new Date().toISOString(),
135
143
  scope: normalizedScope,
144
+ includeSecrets,
136
145
  data,
137
146
  }
138
147
  }
@@ -149,7 +158,7 @@ function normalizeBackupPayload(payload) {
149
158
  ? backup.data
150
159
  : backup
151
160
 
152
- const normalized = {
161
+ const sections = {
153
162
  settings: section(data, 'settings'),
154
163
  providerKeys: section(data, 'providerKeys', 'provider-keys'),
155
164
  customProviders: section(data, 'customProviders', 'custom-providers'),
@@ -159,17 +168,149 @@ function normalizeBackupPayload(payload) {
159
168
  sessionsMetadata: section(data, 'sessionsMetadata', 'sessions-metadata'),
160
169
  }
161
170
 
162
- if (Object.values(normalized).every((value) => value === undefined)) {
171
+ if (Object.values(sections).every((value) => value === undefined)) {
163
172
  const error = new Error('Backup does not contain any restorable sections')
164
173
  error.statusCode = 400
165
174
  throw error
166
175
  }
167
176
 
168
- return normalized
177
+ return {
178
+ app: typeof backup.app === 'string' ? backup.app : null,
179
+ version: Number.isInteger(backup.version) ? backup.version : null,
180
+ exportedAt: typeof backup.exportedAt === 'string' ? backup.exportedAt : null,
181
+ scope: typeof backup.scope === 'string' ? backup.scope : null,
182
+ includeSecrets: backup.includeSecrets === true,
183
+ sections,
184
+ }
185
+ }
186
+
187
+ function validateBackupPayload(payload) {
188
+ const backup = normalizeBackupPayload(payload)
189
+ const { sections } = backup
190
+
191
+ const sessions = assertObjectSection(sections.sessions, 'sessions')
192
+ const sessionsMetadata = sessions !== undefined
193
+ ? normalizeSessionMetadata(sessions, sections.sessionsMetadata)
194
+ : assertObjectSection(sections.sessionsMetadata, 'sessionsMetadata')
195
+
196
+ return {
197
+ ...backup,
198
+ sections: {
199
+ settings: assertObjectSection(sections.settings, 'settings'),
200
+ providerKeys: assertObjectSection(sections.providerKeys, 'providerKeys'),
201
+ customProviders: assertObjectSection(sections.customProviders, 'customProviders'),
202
+ projects: assertProjectConfig(sections.projects),
203
+ scheduledTasks: assertObjectSection(sections.scheduledTasks, 'scheduledTasks'),
204
+ sessions,
205
+ sessionsMetadata,
206
+ },
207
+ }
208
+ }
209
+
210
+ function normalizeRestoreSections(value, sections) {
211
+ if (value === undefined || value === null) return null
212
+ if (!Array.isArray(value)) {
213
+ const error = new Error('Invalid restore sections')
214
+ error.statusCode = 400
215
+ throw error
216
+ }
217
+
218
+ const selected = new Set()
219
+ for (const item of value) {
220
+ const id = String(item)
221
+ if (!restoreSectionIds.has(id)) {
222
+ const error = new Error(`Invalid restore section: ${id}`)
223
+ error.statusCode = 400
224
+ throw error
225
+ }
226
+ selected.add(id)
227
+ }
228
+
229
+ if (selected.size === 0) {
230
+ const error = new Error('No restore sections selected')
231
+ error.statusCode = 400
232
+ throw error
233
+ }
234
+
235
+ const unavailable = [...selected].filter((id) => {
236
+ if (id === 'conversations') return sections.sessions === undefined && sections.sessionsMetadata === undefined
237
+ return sections[id] === undefined
238
+ })
239
+ if (unavailable.length > 0) {
240
+ const error = new Error(`Selected restore section is not available in backup: ${unavailable.join(', ')}`)
241
+ error.statusCode = 400
242
+ throw error
243
+ }
244
+
245
+ return selected
246
+ }
247
+
248
+ function filterRestoreSections(sections, selected) {
249
+ if (!selected) return sections
250
+ return {
251
+ settings: selected.has('settings') ? sections.settings : undefined,
252
+ providerKeys: selected.has('providerKeys') ? sections.providerKeys : undefined,
253
+ customProviders: selected.has('customProviders') ? sections.customProviders : undefined,
254
+ projects: selected.has('projects') ? sections.projects : undefined,
255
+ scheduledTasks: selected.has('scheduledTasks') ? sections.scheduledTasks : undefined,
256
+ sessions: selected.has('conversations') ? sections.sessions : undefined,
257
+ sessionsMetadata: selected.has('conversations') ? sections.sessionsMetadata : undefined,
258
+ }
259
+ }
260
+
261
+ function backupWithSelectedSections(backup, selected) {
262
+ return selected ? { ...backup, sections: filterRestoreSections(backup.sections, selected) } : backup
263
+ }
264
+
265
+ function parseImportPayload(body) {
266
+ const payload = body?.backup && typeof body.backup === 'object' ? body.backup : body
267
+ const backup = validateBackupPayload(payload)
268
+ const requestedSections = body?.backup && typeof body === 'object' ? body.sections : undefined
269
+ const selected = normalizeRestoreSections(requestedSections, backup.sections)
270
+ return backupWithSelectedSections(backup, selected)
271
+ }
272
+
273
+ function countKeys(value) {
274
+ return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value).length : 0
275
+ }
276
+
277
+ function buildSummary(sections) {
278
+ const summary = {}
279
+ if (sections.settings !== undefined) summary.settings = countKeys(sections.settings)
280
+ if (sections.providerKeys !== undefined) summary.providerKeys = countKeys(sections.providerKeys)
281
+ if (sections.customProviders !== undefined) summary.customProviders = countKeys(sections.customProviders)
282
+ if (sections.projects !== undefined) summary.projects = sections.projects.projects.length
283
+ if (sections.scheduledTasks !== undefined) summary.scheduledTasks = countKeys(sections.scheduledTasks)
284
+ if (sections.sessions !== undefined) summary.sessions = countKeys(filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata))
285
+ if (sections.sessionsMetadata !== undefined) summary.sessionsMetadata = countKeys(sections.sessionsMetadata)
286
+ return summary
287
+ }
288
+
289
+ function inspectBackup(payload) {
290
+ const backup = validateBackupPayload(payload)
291
+ const summary = buildSummary(backup.sections)
292
+ const warnings = []
293
+ const containsSecrets = countKeys(backup.sections.providerKeys) > 0
294
+
295
+ if (containsSecrets) warnings.push('Backup contains API keys.')
296
+ if (backup.sections.sessions !== undefined || backup.sections.sessionsMetadata !== undefined) {
297
+ warnings.push('Importing conversations will replace local conversation data.')
298
+ }
299
+
300
+ return {
301
+ ok: true,
302
+ app: backup.app,
303
+ version: backup.version,
304
+ exportedAt: backup.exportedAt,
305
+ scope: backup.scope,
306
+ includeSecrets: containsSecrets || backup.includeSecrets,
307
+ sections: summary,
308
+ warnings,
309
+ }
169
310
  }
170
311
 
171
312
  async function writeSafetyBackup() {
172
- const backup = await buildBackup('all')
313
+ const backup = await buildBackup('all', { includeSecrets: true })
173
314
  const dir = path.join(storageDir, 'backups')
174
315
  await fs.mkdir(dir, { recursive: true })
175
316
  const file = path.join(dir, `quickforge-before-restore-${backupTimestamp()}.json`)
@@ -177,52 +318,46 @@ async function writeSafetyBackup() {
177
318
  return file
178
319
  }
179
320
 
180
- async function restoreBackup(payload) {
181
- const backup = normalizeBackupPayload(payload)
321
+ async function restoreValidatedBackup(backup) {
322
+ const { sections } = backup
182
323
  const summary = {}
183
324
 
184
- const settings = assertObjectSection(backup.settings, 'settings')
185
- if (settings !== undefined) {
186
- await writeStore('settings', settings)
187
- summary.settings = Object.keys(settings).length
325
+ if (sections.settings !== undefined) {
326
+ await writeStore('settings', sections.settings)
327
+ summary.settings = countKeys(sections.settings)
188
328
  }
189
329
 
190
- const providerKeys = assertObjectSection(backup.providerKeys, 'providerKeys')
191
- if (providerKeys !== undefined) {
192
- await writeStore('provider-keys', providerKeys)
193
- summary.providerKeys = Object.keys(providerKeys).length
330
+ if (sections.providerKeys !== undefined) {
331
+ await writeStore('provider-keys', sections.providerKeys)
332
+ summary.providerKeys = countKeys(sections.providerKeys)
194
333
  }
195
334
 
196
- const customProviders = assertObjectSection(backup.customProviders, 'customProviders')
197
- if (customProviders !== undefined) {
198
- await writeStore('custom-providers', customProviders)
199
- summary.customProviders = Object.keys(customProviders).length
335
+ if (sections.customProviders !== undefined) {
336
+ await writeStore('custom-providers', sections.customProviders)
337
+ summary.customProviders = countKeys(sections.customProviders)
200
338
  }
201
339
 
202
- const projects = assertProjectConfig(backup.projects)
203
- if (projects !== undefined) {
204
- await writeProjectConfigData(projects)
340
+ if (sections.projects !== undefined) {
341
+ await writeProjectConfigData(sections.projects)
205
342
  await initializeActiveProject()
206
343
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
207
- summary.projects = projects.projects.length
344
+ summary.projects = sections.projects.projects.length
208
345
  }
209
346
 
210
- const scheduledTasks = assertObjectSection(backup.scheduledTasks, 'scheduledTasks')
211
- if (scheduledTasks !== undefined) {
212
- await writeStore('scheduled-tasks', scheduledTasks)
213
- summary.scheduledTasks = Object.keys(scheduledTasks).length
347
+ if (sections.scheduledTasks !== undefined) {
348
+ await writeStore('scheduled-tasks', sections.scheduledTasks)
349
+ summary.scheduledTasks = countKeys(sections.scheduledTasks)
214
350
  }
215
351
 
216
- const sessions = assertObjectSection(backup.sessions, 'sessions')
217
- const sessionsMetadata = normalizeSessionMetadata(sessions, backup.sessionsMetadata)
218
- if (sessions !== undefined) {
219
- await writeStore('sessions', filterSessionsByMetadata(sessions, sessionsMetadata))
220
- summary.sessions = Object.keys(sessions).length
352
+ if (sections.sessions !== undefined) {
353
+ const sessions = filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata)
354
+ await writeStore('sessions', sessions)
355
+ summary.sessions = countKeys(sessions)
221
356
  }
222
357
 
223
- if (sessionsMetadata !== undefined) {
224
- await writeStore('sessions-metadata', sessionsMetadata)
225
- summary.sessionsMetadata = Object.keys(sessionsMetadata).length
358
+ if (sections.sessionsMetadata !== undefined) {
359
+ await writeStore('sessions-metadata', sections.sessionsMetadata)
360
+ summary.sessionsMetadata = countKeys(sections.sessionsMetadata)
226
361
  }
227
362
 
228
363
  return summary
@@ -231,15 +366,25 @@ async function restoreBackup(payload) {
231
366
  export async function handleBackupApi(req, res, url) {
232
367
  if (req.method === 'GET' && url.pathname === '/api/backup/export') {
233
368
  await ensureStorage()
234
- sendJson(res, 200, await buildBackup(url.searchParams.get('scope')))
369
+ sendJson(res, 200, await buildBackup(url.searchParams.get('scope'), {
370
+ includeSecrets: parseBoolean(url.searchParams.get('includeSecrets')),
371
+ }))
372
+ return
373
+ }
374
+
375
+ if (req.method === 'POST' && url.pathname === '/api/backup/inspect') {
376
+ await ensureStorage()
377
+ const body = await readJsonBody(req)
378
+ sendJson(res, 200, inspectBackup(body))
235
379
  return
236
380
  }
237
381
 
238
382
  if (req.method === 'POST' && url.pathname === '/api/backup/import') {
239
383
  await ensureStorage()
240
384
  const body = await readJsonBody(req)
385
+ const backup = parseImportPayload(body)
241
386
  const safetyBackupPath = await writeSafetyBackup()
242
- const summary = await restoreBackup(body)
387
+ const summary = await restoreValidatedBackup(backup)
243
388
  sendJson(res, 200, { ok: true, safetyBackupPath, summary })
244
389
  return
245
390
  }