@shawnstack/quickforge 1.7.7 → 1.7.8

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 (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,652 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { getSqliteStorage } from './database.mjs'
3
+ import { createRandomToken, safeHashEqual, sha256Base64Url } from '../utils/password-auth.mjs'
4
+
5
+ // F11 LAN access storage: `lan_access_state` (single config row with a fixed
6
+ // `singleton = 1` primary key, strict column mapping + `extra_json` for
7
+ // opaque/unknown fields) and `lan_access_tokens` (independent token table with
8
+ // PK token_id and a ≤100 cap enforced at write time, keeping the newest tokens
9
+ // by insertion order — the same `slice(-100)` semantics as lan-access-store).
10
+ // The snapshot digest algorithm is shared with the JSON cutover side so a JSON
11
+ // import and its SQLite copy always hash identically.
12
+ export const LAN_TOKEN_MAX_COUNT = 100
13
+ export const DEFAULT_SESSION_TTL_HOURS = 12
14
+
15
+ const CONFIG_COLUMNS = `
16
+ singleton, enabled, password_hash, password_salt, password_version, auth_version,
17
+ session_ttl_hours, updated_at, revision, record_digest, extra_json
18
+ `
19
+
20
+ const TOKEN_COLUMNS = `
21
+ token_id, seq, token_hash, issued_at, expires_at, auth_version, remote_address, user_agent
22
+ `
23
+
24
+ // Fields modeled as config columns; anything else on the JSON config is
25
+ // preserved verbatim in `extra_json` and restored on read/export (opaque
26
+ // roundtrip). Token-level records use fixed columns only (same as share_tokens).
27
+ const KNOWN_CONFIG_FIELDS = new Set([
28
+ 'enabled', 'passwordHash', 'passwordSalt', 'passwordVersion', 'authVersion',
29
+ 'sessionTtlHours', 'updatedAt', 'tokens',
30
+ ])
31
+
32
+ function isPlainObject(value) {
33
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
34
+ }
35
+
36
+ function canonicalize(value) {
37
+ if (Array.isArray(value)) return value.map(canonicalize)
38
+ if (!isPlainObject(value)) return value
39
+ return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
40
+ }
41
+
42
+ function invalid(message) {
43
+ const error = new Error(message)
44
+ error.statusCode = 400
45
+ error.errorCode = 'LAN_ACCESS_INVALID_INPUT'
46
+ return error
47
+ }
48
+
49
+ function conflict(expectedRevision, actualRevision) {
50
+ const error = new Error('LAN access state conflict')
51
+ error.statusCode = 409
52
+ error.errorCode = 'LAN_ACCESS_STATE_CONFLICT'
53
+ error.expectedRevision = expectedRevision
54
+ error.actualRevision = actualRevision
55
+ return error
56
+ }
57
+
58
+ function notFound() {
59
+ const error = new Error('LAN access session not found.')
60
+ error.statusCode = 404
61
+ error.errorCode = 'LAN_ACCESS_NOT_FOUND'
62
+ return error
63
+ }
64
+
65
+ function notEnabled() {
66
+ const error = new Error('LAN access is not enabled.')
67
+ error.statusCode = 403
68
+ error.errorCode = 'LAN_ACCESS_NOT_ENABLED'
69
+ return error
70
+ }
71
+
72
+ function optionalTimestamp(value, field) {
73
+ if (value === undefined || value === null || value === '') return undefined
74
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) throw invalid(`Invalid ${field}`)
75
+ return value
76
+ }
77
+
78
+ export function normalizeSessionTtlHours(value) {
79
+ const numeric = Number(value)
80
+ if (!Number.isFinite(numeric)) return DEFAULT_SESSION_TTL_HOURS
81
+ return Math.min(24 * 7, Math.max(1, Math.round(numeric)))
82
+ }
83
+
84
+ export function normalizeLanAccessText(value, maxLength) {
85
+ if (typeof value !== 'string') return undefined
86
+ const text = value.trim()
87
+ return text ? text.slice(0, maxLength) : undefined
88
+ }
89
+
90
+ export function normalizeLanAccessAddress(value) {
91
+ const address = normalizeLanAccessText(value, 128)
92
+ return address?.startsWith('::ffff:') ? address.slice(7) : address
93
+ }
94
+
95
+ // Deterministic public id for a token record; legacy tokens without an id get a
96
+ // stable derived id from the token hash (same rule as lan-access-store).
97
+ export function lanAccessTokenId(token) {
98
+ const id = normalizeLanAccessText(token?.id, 128)
99
+ return id || `legacy_${sha256Base64Url(token?.tokenHash || '').slice(0, 24)}`
100
+ }
101
+
102
+ // Normalize one JSON-shape LAN access config (exactly the format written by
103
+ // lan-access-store.mjs) into the canonical repository record. Shared with the
104
+ // JSON cutover snapshot builder so both sides validate and hash identically.
105
+ // Expired tokens are pruned like `pruneTokens` on the JSON side; the ≤100 cap
106
+ // keeps the newest entries by array order (`slice(-100)` semantics).
107
+ export function normalizeLanAccessConfig(input, { now = () => new Date().toISOString() } = {}) {
108
+ if (!isPlainObject(input)) throw invalid('LAN access config must be an object')
109
+ const enabled = Boolean(input.enabled)
110
+ const passwordHash = typeof input.passwordHash === 'string' && input.passwordHash.trim() ? input.passwordHash : undefined
111
+ const passwordSalt = typeof input.passwordSalt === 'string' && input.passwordSalt.trim() ? input.passwordSalt : undefined
112
+ if (passwordHash && !passwordSalt) throw invalid('LAN access password hash requires a password salt')
113
+ if (!passwordHash && passwordSalt) throw invalid('LAN access password salt requires a password hash')
114
+ const passwordVersion = input.passwordVersion === undefined ? undefined : input.passwordVersion
115
+ if (passwordVersion !== undefined && (!Number.isInteger(passwordVersion) || passwordVersion < 1)) throw invalid('LAN access passwordVersion must be a positive integer')
116
+ const authVersion = input.authVersion === undefined ? 1 : input.authVersion
117
+ if (!Number.isInteger(authVersion) || authVersion < 1) throw invalid('LAN access authVersion must be a positive integer')
118
+ if (enabled && !passwordHash) throw invalid('LAN access enabled requires a password hash')
119
+ const sessionTtlHours = normalizeSessionTtlHours(input.sessionTtlHours)
120
+ const updatedAt = typeof input.updatedAt === 'string' && input.updatedAt.trim() ? input.updatedAt : now()
121
+
122
+ const nowMs = Date.now()
123
+ let tokens = []
124
+ if (input.tokens !== undefined) {
125
+ if (!Array.isArray(input.tokens)) throw invalid('LAN access tokens must be an array')
126
+ tokens = input.tokens
127
+ .filter((entry) => !entry?.expiresAt || Date.parse(entry.expiresAt) > nowMs)
128
+ .map((entry) => normalizeLanAccessToken(entry, authVersion))
129
+ }
130
+ const normalizedTokens = tokens.slice(-LAN_TOKEN_MAX_COUNT)
131
+
132
+ const extra = {}
133
+ for (const [key, value] of Object.entries(input)) {
134
+ if (!KNOWN_CONFIG_FIELDS.has(key)) extra[key] = value
135
+ }
136
+
137
+ return {
138
+ enabled,
139
+ passwordHash,
140
+ passwordSalt,
141
+ passwordVersion,
142
+ authVersion,
143
+ sessionTtlHours,
144
+ updatedAt,
145
+ tokens: normalizedTokens,
146
+ ...extra,
147
+ }
148
+ }
149
+
150
+ function normalizeLanAccessToken(entry, configAuthVersion) {
151
+ if (!isPlainObject(entry)) throw invalid('LAN access token must be an object')
152
+ const tokenHash = entry.tokenHash
153
+ if (typeof tokenHash !== 'string' || !tokenHash.trim()) throw invalid('LAN access token hash is required')
154
+ const tokenAuthVersion = entry.authVersion === undefined ? configAuthVersion : entry.authVersion
155
+ if (!Number.isInteger(tokenAuthVersion) || tokenAuthVersion < 1) throw invalid('LAN access token authVersion must be a positive integer')
156
+ return {
157
+ id: lanAccessTokenId(entry),
158
+ tokenHash,
159
+ // Deterministic sentinel for records that omit issuedAt: the tokens table
160
+ // column is NOT NULL, so stored rows always carry a value (same rule as
161
+ // share_tokens so cutover/backup digests stay stable for legacy imports).
162
+ issuedAt: optionalTimestamp(entry.issuedAt, 'token issuedAt') ?? '1970-01-01T00:00:00.000Z',
163
+ expiresAt: optionalTimestamp(entry.expiresAt, 'token expiresAt'),
164
+ authVersion: tokenAuthVersion,
165
+ remoteAddress: normalizeLanAccessAddress(entry.remoteAddress),
166
+ userAgent: normalizeLanAccessText(entry.userAgent, 300),
167
+ }
168
+ }
169
+
170
+ function sortedTokens(tokens) {
171
+ return (Array.isArray(tokens) ? tokens : []).slice().sort((left, right) =>
172
+ String(left.issuedAt || '').localeCompare(String(right.issuedAt || ''))
173
+ || String(left.id || '').localeCompare(String(right.id || '')))
174
+ }
175
+
176
+ // Canonical SHA-256 digest of the full LAN access config (including tokens and
177
+ // any opaque config fields). Excludes the repository-internal `revision`.
178
+ export function lanAccessConfigDigest(config) {
179
+ if (!config) return ''
180
+ const content = {
181
+ enabled: config.enabled === true,
182
+ passwordHash: config.passwordHash,
183
+ passwordSalt: config.passwordSalt,
184
+ passwordVersion: config.passwordVersion,
185
+ authVersion: config.authVersion,
186
+ sessionTtlHours: config.sessionTtlHours,
187
+ updatedAt: config.updatedAt,
188
+ tokens: sortedTokens(config.tokens).map((token) => ({
189
+ id: token.id,
190
+ tokenHash: token.tokenHash,
191
+ issuedAt: token.issuedAt,
192
+ expiresAt: token.expiresAt,
193
+ authVersion: token.authVersion,
194
+ remoteAddress: token.remoteAddress,
195
+ userAgent: token.userAgent,
196
+ })),
197
+ }
198
+ for (const [key, value] of Object.entries(config)) {
199
+ if (!Object.prototype.hasOwnProperty.call(content, key) && key !== 'revision') content[key] = value
200
+ }
201
+ return createHash('sha256').update(JSON.stringify(canonicalize(content))).digest('hex')
202
+ }
203
+
204
+ function configJson(record) {
205
+ return JSON.stringify(canonicalize({ ...record, revision: undefined }))
206
+ }
207
+
208
+ function mapConfigRow(row) {
209
+ if (!row) return null
210
+ let extra
211
+ try {
212
+ extra = row.extra_json ? JSON.parse(row.extra_json) : {}
213
+ } catch {
214
+ extra = {}
215
+ }
216
+ return {
217
+ ...(isPlainObject(extra) ? extra : {}),
218
+ enabled: row.enabled === 1,
219
+ passwordHash: row.password_hash ?? undefined,
220
+ passwordSalt: row.password_salt ?? undefined,
221
+ passwordVersion: row.password_version === null ? undefined : Number(row.password_version),
222
+ authVersion: Number(row.auth_version),
223
+ sessionTtlHours: Number(row.session_ttl_hours),
224
+ updatedAt: row.updated_at,
225
+ revision: Number(row.revision),
226
+ }
227
+ }
228
+
229
+ function tokenRowsFor(database) {
230
+ return database.prepare(`SELECT ${TOKEN_COLUMNS} FROM lan_access_tokens ORDER BY seq, token_id`).all()
231
+ }
232
+
233
+ function attachTokens(config, tokenRows) {
234
+ if (!config) return config
235
+ return {
236
+ ...config,
237
+ tokens: (Array.isArray(tokenRows) ? tokenRows : []).map((row) => ({
238
+ id: row.token_id,
239
+ tokenHash: row.token_hash,
240
+ issuedAt: row.issued_at ?? undefined,
241
+ expiresAt: row.expires_at ?? undefined,
242
+ authVersion: Number(row.auth_version),
243
+ remoteAddress: row.remote_address ?? undefined,
244
+ userAgent: row.user_agent ?? undefined,
245
+ })),
246
+ }
247
+ }
248
+
249
+ function insertConfigRow(database, record, revision, updatedAt) {
250
+ const extra = {}
251
+ for (const [key, value] of Object.entries(record)) {
252
+ if (!KNOWN_CONFIG_FIELDS.has(key) && key !== 'revision') extra[key] = value
253
+ }
254
+ const digest = lanAccessConfigDigest(record)
255
+ database.prepare(`
256
+ INSERT INTO lan_access_state (${CONFIG_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
257
+ ON CONFLICT(singleton) DO UPDATE SET
258
+ enabled = excluded.enabled,
259
+ password_hash = excluded.password_hash,
260
+ password_salt = excluded.password_salt,
261
+ password_version = excluded.password_version,
262
+ auth_version = excluded.auth_version,
263
+ session_ttl_hours = excluded.session_ttl_hours,
264
+ updated_at = excluded.updated_at,
265
+ revision = excluded.revision,
266
+ record_digest = excluded.record_digest,
267
+ extra_json = excluded.extra_json
268
+ `).run(
269
+ 1, record.enabled === true ? 1 : 0,
270
+ record.passwordHash ?? null, record.passwordSalt ?? null, record.passwordVersion ?? null,
271
+ record.authVersion, record.sessionTtlHours, updatedAt,
272
+ revision, digest, JSON.stringify(extra),
273
+ )
274
+ return digest
275
+ }
276
+
277
+ function insertTokenRow(database, token, seq) {
278
+ database.prepare(`
279
+ INSERT INTO lan_access_tokens (${TOKEN_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
280
+ `).run(
281
+ token.id, seq, token.tokenHash, token.issuedAt, token.expiresAt ?? null,
282
+ token.authVersion, token.remoteAddress ?? null, token.userAgent ?? null,
283
+ )
284
+ }
285
+
286
+ function replaceTokens(database, tokens) {
287
+ database.prepare('DELETE FROM lan_access_tokens').run()
288
+ const insert = database.prepare(`INSERT INTO lan_access_tokens (${TOKEN_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
289
+ ;(Array.isArray(tokens) ? tokens : []).slice(-LAN_TOKEN_MAX_COUNT).forEach((token, index) => {
290
+ insert.run(token.id, index, token.tokenHash, token.issuedAt, token.expiresAt ?? null, token.authVersion, token.remoteAddress ?? null, token.userAgent ?? null)
291
+ })
292
+ }
293
+
294
+ function enqueueLanAccessMirror(database, record, operation, updatedAt) {
295
+ const stored = record ? { ...record, revision: undefined } : null
296
+ database.prepare(`
297
+ INSERT INTO lan_access_json_mirror_queue (singleton, operation, config_json, attempts, last_error, updated_at)
298
+ VALUES (1, ?, ?, 0, NULL, ?)
299
+ ON CONFLICT(singleton) DO UPDATE SET
300
+ operation = excluded.operation,
301
+ config_json = excluded.config_json,
302
+ attempts = 0,
303
+ last_error = NULL,
304
+ updated_at = excluded.updated_at
305
+ `).run(
306
+ operation,
307
+ operation === 'upsert' ? (stored ? configJson(stored) : null) : null,
308
+ updatedAt,
309
+ )
310
+ }
311
+
312
+ function currentConfigRow(database) {
313
+ return database.prepare(`SELECT ${CONFIG_COLUMNS} FROM lan_access_state WHERE singleton = 1`).get()
314
+ }
315
+
316
+ function actualRevision(database) {
317
+ const row = currentConfigRow(database)
318
+ return row ? { revision: Number(row.revision), exists: true, row } : { revision: 0, exists: false, row: null }
319
+ }
320
+
321
+ function assertCas(database, expectedRevision) {
322
+ if (expectedRevision === undefined || expectedRevision === null) return
323
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) throw invalid('expectedRevision must be a non-negative integer')
324
+ const current = actualRevision(database)
325
+ if (current.revision !== expectedRevision) throw conflict(expectedRevision, current.revision)
326
+ }
327
+
328
+ function updateStorageState(database, values, timestamp) {
329
+ if (!values) return
330
+ database.prepare(`UPDATE lan_access_storage_state SET phase = ?, lan_token_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ?
331
+ WHERE singleton = 1`).run(
332
+ values.phase,
333
+ values.lanTokenCount ?? null,
334
+ values.digest ?? null,
335
+ values.backupFile ?? null,
336
+ values.diagnostic ? JSON.stringify(values.diagnostic) : null,
337
+ timestamp,
338
+ )
339
+ }
340
+
341
+ function tokenNotExpiredWhere(cutoffIso) {
342
+ return `(expires_at IS NULL OR expires_at > '${String(cutoffIso).replace(/'/g, "''")}')`
343
+ }
344
+
345
+ export function createLanAccessRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
346
+ const storage = storageHandle ?? getSqliteStorage()
347
+ if (!storage || typeof storage.prepare !== 'function' || typeof storage.transaction !== 'function') {
348
+ throw new TypeError('LAN access repository requires a SQLite storage handle')
349
+ }
350
+
351
+ function getConfig() {
352
+ const row = currentConfigRow(storage)
353
+ if (!row) return null
354
+ const config = mapConfigRow(row)
355
+ return attachTokens(config, tokenRowsFor(storage))
356
+ }
357
+
358
+ function updateSettings(changes, { expectedRevision = null } = {}) {
359
+ if (!isPlainObject(changes)) throw invalid('LAN access settings must be an object')
360
+ const timestamp = now()
361
+ const passwordProvided = Object.prototype.hasOwnProperty.call(changes, 'passwordHash')
362
+ || Object.prototype.hasOwnProperty.call(changes, 'passwordSalt')
363
+ || Object.prototype.hasOwnProperty.call(changes, 'passwordVersion')
364
+ const enabledProvided = Object.prototype.hasOwnProperty.call(changes, 'enabled')
365
+ return storage.transaction((database) => {
366
+ const current = actualRevision(database)
367
+ assertCas(database, expectedRevision)
368
+ const existing = current.exists ? mapConfigRow(current.row) : null
369
+ const nextEnabled = enabledProvided ? Boolean(changes.enabled) : (existing ? existing.enabled : false)
370
+ if (nextEnabled && !passwordProvided && !(existing && existing.passwordHash)) {
371
+ const error = new Error('LAN access password is required before enabling full LAN access.')
372
+ error.statusCode = 400
373
+ throw error
374
+ }
375
+ const authChanged = passwordProvided || Boolean(existing && existing.enabled) !== nextEnabled
376
+ const passwordInfo = passwordProvided
377
+ ? {
378
+ passwordHash: typeof changes.passwordHash === 'string' && changes.passwordHash.trim() ? changes.passwordHash : undefined,
379
+ passwordSalt: typeof changes.passwordSalt === 'string' && changes.passwordSalt.trim() ? changes.passwordSalt : undefined,
380
+ passwordVersion: changes.passwordVersion,
381
+ }
382
+ : {}
383
+ const merged = {
384
+ ...(existing || {}),
385
+ ...passwordInfo,
386
+ enabled: nextEnabled,
387
+ authVersion: authChanged ? (existing?.authVersion || 1) + 1 : (existing?.authVersion || 1),
388
+ sessionTtlHours: changes.sessionTtlHours === undefined
389
+ ? (existing?.sessionTtlHours ?? DEFAULT_SESSION_TTL_HOURS)
390
+ : normalizeSessionTtlHours(changes.sessionTtlHours),
391
+ updatedAt: timestamp,
392
+ tokens: authChanged ? [] : (existing ? existing.tokens : []),
393
+ }
394
+ const normalized = normalizeLanAccessConfig(merged, { now })
395
+ const nextRevision = (current.revision || 0) + 1
396
+ insertConfigRow(database, normalized, nextRevision, timestamp)
397
+ replaceTokens(database, normalized.tokens)
398
+ enqueueLanAccessMirror(database, { ...normalized, revision: nextRevision }, 'upsert', timestamp)
399
+ return { config: { ...normalized, revision: nextRevision }, revision: nextRevision }
400
+ }, { mode: 'immediate' })
401
+ }
402
+
403
+ function issueToken({ remoteAddress, userAgent, expectedRevision = null } = {}) {
404
+ const timestamp = now()
405
+ const secret = createRandomToken(32)
406
+ const tokenHash = sha256Base64Url(secret)
407
+ return storage.transaction((database) => {
408
+ const row = currentConfigRow(database)
409
+ if (!row) throw notEnabled()
410
+ assertCas(database, expectedRevision)
411
+ const existing = mapConfigRow(row)
412
+ if (!existing.enabled || !existing.passwordHash) throw notEnabled()
413
+ const ttlMs = existing.sessionTtlHours * 60 * 60 * 1000
414
+ const expiresAt = new Date(Date.parse(timestamp) + ttlMs).toISOString()
415
+ const tokenRecord = {
416
+ id: createRandomToken(18),
417
+ tokenHash,
418
+ issuedAt: timestamp,
419
+ expiresAt,
420
+ authVersion: existing.authVersion,
421
+ remoteAddress: normalizeLanAccessAddress(remoteAddress),
422
+ userAgent: normalizeLanAccessText(userAgent, 300),
423
+ }
424
+ // Prune expired tokens and keep only the newest 100 by insertion order
425
+ // (same `slice(-100)` semantics as the JSON store) in this same
426
+ // transaction along with the config update and mirror enqueue.
427
+ database.prepare(`DELETE FROM lan_access_tokens WHERE expires_at IS NOT NULL AND expires_at <= ?`)
428
+ .run(new Date(Date.now()).toISOString())
429
+ const nextSeq = Number(database.prepare('SELECT COALESCE(MAX(seq), -1) AS seq FROM lan_access_tokens').get().seq) + 1
430
+ insertTokenRow(database, tokenRecord, nextSeq)
431
+ database.prepare(`DELETE FROM lan_access_tokens WHERE token_id NOT IN (
432
+ SELECT token_id FROM lan_access_tokens ORDER BY seq DESC, token_id DESC LIMIT ?)`).run(LAN_TOKEN_MAX_COUNT)
433
+ const merged = { ...existing, updatedAt: timestamp, tokens: attachTokens(existing, tokenRowsFor(database)).tokens }
434
+ const nextRevision = Number(row.revision) + 1
435
+ insertConfigRow(database, { ...merged, revision: undefined }, nextRevision, timestamp)
436
+ enqueueLanAccessMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
437
+ return {
438
+ token: `${existing.authVersion}.${secret}`,
439
+ expiresAt,
440
+ maxAge: Math.floor(ttlMs / 1000),
441
+ config: { ...merged, revision: nextRevision },
442
+ revision: nextRevision,
443
+ }
444
+ }, { mode: 'immediate' })
445
+ }
446
+
447
+ function revokeTokenById(tokenId) {
448
+ const normalizedId = normalizeLanAccessText(tokenId, 128)
449
+ if (!normalizedId) {
450
+ const error = new Error('LAN access session id is required.')
451
+ error.statusCode = 400
452
+ throw error
453
+ }
454
+ const timestamp = now()
455
+ return storage.transaction((database) => {
456
+ const row = currentConfigRow(database)
457
+ if (!row) throw notFound()
458
+ const existing = mapConfigRow(row)
459
+ const removed = Number(database.prepare('DELETE FROM lan_access_tokens WHERE token_id = ?').run(normalizedId).changes)
460
+ if (removed === 0) throw notFound()
461
+ const merged = { ...existing, updatedAt: timestamp, tokens: attachTokens(existing, tokenRowsFor(database)).tokens }
462
+ const nextRevision = Number(row.revision) + 1
463
+ insertConfigRow(database, { ...merged, revision: undefined }, nextRevision, timestamp)
464
+ enqueueLanAccessMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
465
+ return { config: { ...merged, revision: nextRevision }, revision: nextRevision }
466
+ }, { mode: 'immediate' })
467
+ }
468
+
469
+ function revokeToken(token) {
470
+ if (!token || typeof token !== 'string') return false
471
+ const [versionText, secret] = token.split('.')
472
+ if (!secret) return false
473
+ const actualHash = sha256Base64Url(secret)
474
+ const timestamp = now()
475
+ return storage.transaction((database) => {
476
+ const row = currentConfigRow(database)
477
+ if (!row) return false
478
+ const existing = mapConfigRow(row)
479
+ if (Number(versionText) !== (existing.authVersion || 1)) return false
480
+ const removed = Number(database.prepare('DELETE FROM lan_access_tokens WHERE token_hash = ?').run(actualHash).changes)
481
+ if (removed === 0) return false
482
+ const merged = { ...existing, updatedAt: timestamp, tokens: attachTokens(existing, tokenRowsFor(database)).tokens }
483
+ const nextRevision = Number(row.revision) + 1
484
+ insertConfigRow(database, { ...merged, revision: undefined }, nextRevision, timestamp)
485
+ enqueueLanAccessMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
486
+ return true
487
+ }, { mode: 'immediate' })
488
+ }
489
+
490
+ function revokeAll({ expectedRevision = null } = {}) {
491
+ const timestamp = now()
492
+ return storage.transaction((database) => {
493
+ const row = currentConfigRow(database)
494
+ if (!row) throw notFound()
495
+ assertCas(database, expectedRevision)
496
+ const existing = mapConfigRow(row)
497
+ const merged = {
498
+ ...existing,
499
+ authVersion: existing.authVersion + 1,
500
+ tokens: [],
501
+ updatedAt: timestamp,
502
+ }
503
+ database.prepare('DELETE FROM lan_access_tokens').run()
504
+ const nextRevision = Number(row.revision) + 1
505
+ insertConfigRow(database, { ...merged, revision: undefined }, nextRevision, timestamp)
506
+ enqueueLanAccessMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
507
+ return { config: { ...merged, revision: nextRevision }, revision: nextRevision }
508
+ }, { mode: 'immediate' })
509
+ }
510
+
511
+ function replaceAll(inputConfig, { expectedCount, expectedDigest, storageState, beforeCommit } = {}) {
512
+ const record = normalizeLanAccessConfig(inputConfig, { now })
513
+ const count = record.tokens.length
514
+ const digest = lanAccessConfigDigest(record)
515
+ if (expectedCount !== undefined && count !== expectedCount) throw new Error('LAN access replace token count verification failed')
516
+ if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('LAN access replace digest verification failed')
517
+ const timestamp = record.updatedAt || now()
518
+ return storage.transaction((database) => {
519
+ database.exec('DELETE FROM lan_access_state; DELETE FROM lan_access_tokens; DELETE FROM lan_access_json_mirror_queue;')
520
+ insertConfigRow(database, record, 1, timestamp)
521
+ replaceTokens(database, record.tokens)
522
+ enqueueLanAccessMirror(database, { ...record, revision: 1 }, 'upsert', timestamp)
523
+ updateStorageState(database, storageState ? { ...storageState, lanTokenCount: count, digest, updatedAt: timestamp } : null, timestamp)
524
+ beforeCommit?.(database)
525
+ return count
526
+ }, { mode: 'immediate' })
527
+ }
528
+
529
+ function exportSnapshot() {
530
+ return storage.transaction((database) => {
531
+ const row = currentConfigRow(database)
532
+ if (!row) return { config: null, tokenCount: 0, digest: '' }
533
+ const config = attachTokens(mapConfigRow(row), tokenRowsFor(database))
534
+ return { config, tokenCount: config.tokens.length, digest: lanAccessConfigDigest(config) }
535
+ }, { mode: 'deferred' })
536
+ }
537
+
538
+ function count() {
539
+ const cutoff = new Date(Date.now()).toISOString()
540
+ return Number(storage.prepare(`SELECT COUNT(*) AS count FROM lan_access_tokens WHERE ${tokenNotExpiredWhere(cutoff)}`).get().count)
541
+ }
542
+
543
+ function digest() {
544
+ return exportSnapshot().digest
545
+ }
546
+
547
+ function verifyIntegrity({ quickCheck = false } = {}) {
548
+ if (quickCheck) {
549
+ const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
550
+ if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
551
+ }
552
+ const row = currentConfigRow(storage)
553
+ let invalidRecords = 0
554
+ let invalidDigests = 0
555
+ let invalidPasswordPairs = 0
556
+ let missingConfig = 0
557
+ if (!row) {
558
+ missingConfig = 1
559
+ } else {
560
+ try {
561
+ const config = attachTokens(mapConfigRow(row), tokenRowsFor(storage))
562
+ normalizeLanAccessConfig({ ...config, revision: undefined }, { now })
563
+ if (row.record_digest !== lanAccessConfigDigest(config)) invalidDigests += 1
564
+ if (Boolean(config.passwordHash) !== Boolean(config.passwordSalt)) invalidPasswordPairs += 1
565
+ if (config.enabled === true && !config.passwordHash) invalidPasswordPairs += 1
566
+ } catch {
567
+ invalidRecords += 1
568
+ }
569
+ }
570
+ const totalTokens = Number(storage.prepare('SELECT COUNT(*) AS count FROM lan_access_tokens').get().count)
571
+ const overLimitTokens = totalTokens > LAN_TOKEN_MAX_COUNT ? 1 : 0
572
+ const orphanTokens = !row ? totalTokens : 0
573
+ const tokenAuthVersionMismatch = row
574
+ ? Number(storage.prepare('SELECT COUNT(*) AS count FROM lan_access_tokens WHERE auth_version != ?').get(Number(row.auth_version)).count)
575
+ : 0
576
+ return {
577
+ ok: invalidRecords === 0 && invalidDigests === 0 && invalidPasswordPairs === 0 && missingConfig === 0
578
+ && overLimitTokens === 0 && orphanTokens === 0 && tokenAuthVersionMismatch === 0,
579
+ count: count(),
580
+ digest: row ? digest() : '',
581
+ invalidRecords,
582
+ invalidDigests,
583
+ invalidPasswordPairs,
584
+ missingConfig,
585
+ overLimitTokens,
586
+ orphanTokens,
587
+ tokenAuthVersionMismatch,
588
+ }
589
+ }
590
+
591
+ function listMirrorQueue() {
592
+ const row = storage.prepare('SELECT * FROM lan_access_json_mirror_queue WHERE singleton = 1').get()
593
+ if (!row) return []
594
+ return [{
595
+ operation: row.operation,
596
+ config: row.config_json ? JSON.parse(row.config_json) : null,
597
+ attempts: Number(row.attempts),
598
+ lastError: row.last_error,
599
+ updatedAt: row.updated_at,
600
+ }]
601
+ }
602
+
603
+ function acknowledgeMirror(_entry) {
604
+ return Number(storage.prepare('DELETE FROM lan_access_json_mirror_queue WHERE singleton = 1').run().changes) === 1
605
+ }
606
+
607
+ function failMirror(_entry, error) {
608
+ storage.prepare(`UPDATE lan_access_json_mirror_queue SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE singleton = 1`)
609
+ .run(String(error?.message || error).slice(0, 1000), now())
610
+ }
611
+
612
+ return Object.freeze({
613
+ getConfig,
614
+ updateSettings,
615
+ issueToken,
616
+ verifyToken(token) {
617
+ return verifyLanAccessTokenRecord(getConfig(), token)
618
+ },
619
+ revokeTokenById,
620
+ revokeToken,
621
+ revokeAll,
622
+ replaceAll,
623
+ exportSnapshot,
624
+ verifyIntegrity,
625
+ count,
626
+ digest,
627
+ listMirrorQueue,
628
+ acknowledgeMirror,
629
+ failMirror,
630
+ })
631
+ }
632
+
633
+ /**
634
+ * Exact LAN access token verification: version number must match the current
635
+ * authVersion, the derived SHA-256 hash must match a stored token (constant
636
+ * time) with the same authVersion that is not expired. Fails closed on any
637
+ * mismatch, including a missing/disabled config.
638
+ */
639
+ export function verifyLanAccessTokenRecord(config, token) {
640
+ if (!config || !config.enabled || !config.passwordHash || !token || typeof token !== 'string') return false
641
+ const separator = token.indexOf('.')
642
+ if (separator <= 0) return false
643
+ const versionText = token.slice(0, separator)
644
+ const secret = token.slice(separator + 1)
645
+ if (Number(versionText) !== (config.authVersion || 1) || !secret) return false
646
+ const actualHash = sha256Base64Url(secret)
647
+ const nowMs = Date.now()
648
+ const tokens = (Array.isArray(config.tokens) ? config.tokens : [])
649
+ .filter((entry) => entry && typeof entry.tokenHash === 'string'
650
+ && (!entry.expiresAt || Date.parse(entry.expiresAt) > nowMs))
651
+ return tokens.some((entry) => (entry.authVersion || 1) === (config.authVersion || 1) && safeHashEqual(entry.tokenHash, actualHash))
652
+ }