@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.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
- package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
- package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
- package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
- package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
- package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
- package/dist/assets/index-BTPzyq7d.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
6
|
+
import { createLanAccessRepository, lanAccessConfigDigest, normalizeLanAccessConfig } from './sqlite/lan-access-repository.mjs'
|
|
7
|
+
import {
|
|
8
|
+
LAN_ACCESS_STORAGE_PHASES,
|
|
9
|
+
configureLanAccessService,
|
|
10
|
+
createDefaultLanAccessMirror,
|
|
11
|
+
drainLanAccessJsonMirror,
|
|
12
|
+
readLanAccessStorageState,
|
|
13
|
+
setLanAccessStoragePhase,
|
|
14
|
+
} from './lan-access-service.mjs'
|
|
15
|
+
import {
|
|
16
|
+
defaultLanAccessConfig,
|
|
17
|
+
ensureLanAccessJsonFile,
|
|
18
|
+
readLanAccessJsonFile,
|
|
19
|
+
writeLanAccessJsonFile,
|
|
20
|
+
} from './lan-access-json-file.mjs'
|
|
21
|
+
import { storageDir } from './storage.mjs'
|
|
22
|
+
|
|
23
|
+
const DEFAULT_LOCK_TTL_MS = 60_000
|
|
24
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 65_000
|
|
25
|
+
const maintenanceContext = new AsyncLocalStorage()
|
|
26
|
+
let maintenanceCount = 0
|
|
27
|
+
|
|
28
|
+
function isPlainObject(value) {
|
|
29
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate and normalize the whole LAN access JSON config (`security/
|
|
34
|
+
* lan-access.json`) into a canonical config. Fails closed on: malformed config
|
|
35
|
+
* object, password hash/salt inconsistency, enabled-without-password, malformed
|
|
36
|
+
* `tokens` array. Expired tokens are pruned exactly like `pruneTokens` on the
|
|
37
|
+
* JSON side and the ≤100 cap keeps the newest entries. The count/digest are
|
|
38
|
+
* computed with the exact repository digest algorithm so the SQLite replace
|
|
39
|
+
* verification can compare digests 1:1.
|
|
40
|
+
*/
|
|
41
|
+
export function buildLanAccessJsonSnapshot(input, { now = () => new Date().toISOString() } = {}) {
|
|
42
|
+
if (!isPlainObject(input)) throw new TypeError('LAN access JSON config must be an object')
|
|
43
|
+
const config = normalizeLanAccessConfig(
|
|
44
|
+
input.updatedAt ? input : { ...input, updatedAt: now() },
|
|
45
|
+
{ now },
|
|
46
|
+
)
|
|
47
|
+
return {
|
|
48
|
+
config,
|
|
49
|
+
tokenCount: config.tokens.length,
|
|
50
|
+
digest: lanAccessConfigDigest(config),
|
|
51
|
+
diagnostics: { source: 'security/lan-access.json' },
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Default cutover JSON source reader. Missing files (ENOENT) and unreadable or
|
|
57
|
+
* corrupt JSON both fall back to the default disabled config — the same
|
|
58
|
+
* semantics lan-access-store applies to a missing file — so the server can
|
|
59
|
+
* continue and LAN access simply starts disabled (fail-closed). The fallback is
|
|
60
|
+
* materialized so repeated reads stay stable for the double-snapshot check.
|
|
61
|
+
*/
|
|
62
|
+
export async function readLanAccessJsonSource() {
|
|
63
|
+
await ensureLanAccessJsonFile()
|
|
64
|
+
try {
|
|
65
|
+
return await readLanAccessJsonFile()
|
|
66
|
+
} catch {
|
|
67
|
+
const fallback = defaultLanAccessConfig()
|
|
68
|
+
await writeLanAccessJsonFile(fallback)
|
|
69
|
+
return fallback
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function writeLanAccessCutoverBackup(snapshot, directory = path.join(storageDir, 'backups')) {
|
|
74
|
+
await fs.mkdir(directory, { recursive: true })
|
|
75
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
76
|
+
const finalPath = path.join(directory, `quickforge-lan-access-cutover-${stamp}.json`)
|
|
77
|
+
const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
|
|
78
|
+
const backup = {
|
|
79
|
+
app: 'quickforge',
|
|
80
|
+
version: 1,
|
|
81
|
+
exportedAt: new Date().toISOString(),
|
|
82
|
+
scope: 'lan-access',
|
|
83
|
+
includeSecrets: false,
|
|
84
|
+
lanAccess: { tokenCount: snapshot.tokenCount, digest: snapshot.digest },
|
|
85
|
+
data: {
|
|
86
|
+
lanAccess: snapshot.config,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
|
|
91
|
+
const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
|
|
92
|
+
const verifiedSnapshot = buildLanAccessJsonSnapshot(verified.data?.lanAccess)
|
|
93
|
+
if (verified.lanAccess?.tokenCount !== snapshot.tokenCount || verified.lanAccess?.digest !== snapshot.digest
|
|
94
|
+
|| verifiedSnapshot.tokenCount !== snapshot.tokenCount || verifiedSnapshot.digest !== snapshot.digest) {
|
|
95
|
+
throw new Error('LAN access cutover backup verification failed')
|
|
96
|
+
}
|
|
97
|
+
await fs.rename(temporaryPath, finalPath)
|
|
98
|
+
return finalPath
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ownerPid(row) {
|
|
106
|
+
const value = Number(row?.owner_pid)
|
|
107
|
+
return Number.isInteger(value) && value > 0 ? value : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pidAlive(pid) {
|
|
111
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function acquireLanAccessMaintenanceLock(storage, options = {}) {
|
|
115
|
+
const nowMs = Number(options.now?.() ?? Date.now())
|
|
116
|
+
const acquiredAt = new Date(nowMs).toISOString()
|
|
117
|
+
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
118
|
+
const identity = isPlainObject(options.owner)
|
|
119
|
+
? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
|
|
120
|
+
: { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
|
|
121
|
+
const isPidAlive = options.pidAlive || pidAlive
|
|
122
|
+
return storage.transaction((database) => {
|
|
123
|
+
const current = database.prepare('SELECT * FROM lan_access_maintenance_lock WHERE singleton = 1').get()
|
|
124
|
+
if (current && current.owner === identity.id) {
|
|
125
|
+
database.prepare(`UPDATE lan_access_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
|
|
126
|
+
WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
127
|
+
.run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
|
|
128
|
+
return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
|
|
129
|
+
}
|
|
130
|
+
if (current) {
|
|
131
|
+
const expired = Date.parse(current.expires_at) <= nowMs
|
|
132
|
+
const pid = ownerPid(current)
|
|
133
|
+
if (!expired || pid === null || isPidAlive(pid)) return null
|
|
134
|
+
}
|
|
135
|
+
const fencing = Number(current?.fencing || 0) + 1
|
|
136
|
+
database.prepare(`INSERT INTO lan_access_maintenance_lock
|
|
137
|
+
(singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
|
|
138
|
+
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
139
|
+
ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
|
|
140
|
+
operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
|
|
141
|
+
.run(identity.id, identity.pid, fencing, options.operation || 'lan-access-maintenance', acquiredAt, acquiredAt, expiresAt)
|
|
142
|
+
return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
|
|
143
|
+
}, { mode: 'immediate' })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renewLanAccessMaintenanceLock(storage, lease, options = {}) {
|
|
147
|
+
const nowMs = Number(options.now?.() ?? Date.now())
|
|
148
|
+
const heartbeatAt = new Date(nowMs).toISOString()
|
|
149
|
+
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
150
|
+
const result = storage.prepare(`UPDATE lan_access_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
151
|
+
.run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
|
|
152
|
+
lease.expiresAt = expiresAt
|
|
153
|
+
return Number(result.changes) === 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function releaseLanAccessMaintenanceLock(storage, lease) {
|
|
157
|
+
return Number(storage.prepare('DELETE FROM lan_access_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function waitForLanAccessLock(storage, options) {
|
|
161
|
+
const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
const lease = acquireLanAccessMaintenanceLock(storage, options)
|
|
164
|
+
if (lease) return lease
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
|
|
166
|
+
}
|
|
167
|
+
throw new Error('Timed out waiting for LAN access maintenance lock')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function runLanAccessMaintenance(operation, options = {}) {
|
|
171
|
+
const existing = maintenanceContext.getStore()
|
|
172
|
+
if (existing) return operation(existing)
|
|
173
|
+
const storage = options.storage || getSqliteStorage()
|
|
174
|
+
const lease = await waitForLanAccessLock(storage, options)
|
|
175
|
+
const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
|
|
176
|
+
let rejectHeartbeat
|
|
177
|
+
let heartbeatError = null
|
|
178
|
+
const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
|
|
179
|
+
const timer = setInterval(() => {
|
|
180
|
+
try {
|
|
181
|
+
if (!renewLanAccessMaintenanceLock(storage, lease, options)) throw new Error('LAN access maintenance lock fencing was lost')
|
|
182
|
+
} catch (error) {
|
|
183
|
+
heartbeatError = error
|
|
184
|
+
rejectHeartbeat(error)
|
|
185
|
+
}
|
|
186
|
+
}, interval)
|
|
187
|
+
timer.unref?.()
|
|
188
|
+
maintenanceCount += 1
|
|
189
|
+
try {
|
|
190
|
+
const context = { storage, lease }
|
|
191
|
+
const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
|
|
192
|
+
if (heartbeatError) throw heartbeatError
|
|
193
|
+
return result
|
|
194
|
+
} finally {
|
|
195
|
+
clearInterval(timer)
|
|
196
|
+
releaseLanAccessMaintenanceLock(storage, lease)
|
|
197
|
+
maintenanceCount = Math.max(0, maintenanceCount - 1)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function isLanAccessMaintenanceActive(storage) {
|
|
202
|
+
if (!storage) {
|
|
203
|
+
try { storage = getSqliteStorage() } catch { return false }
|
|
204
|
+
}
|
|
205
|
+
return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM lan_access_maintenance_lock WHERE singleton = 1').get())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function currentLanAccessMaintenanceContext() {
|
|
209
|
+
return maintenanceContext.getStore() || null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* JSON → SQLite LAN access cutover. Phase machine:
|
|
214
|
+
* json_authoritative → cutover_running → sqlite_authoritative_json_pending →
|
|
215
|
+
* authoritative, backed by `lan_access_storage_state` and the independent
|
|
216
|
+
* `lan_access_maintenance_lock`. Runs a stable double snapshot (+ backup reread
|
|
217
|
+
* and a third stability read), then commits the SQLite replace and the pending
|
|
218
|
+
* phase in one transaction. Any failure before pending returns to
|
|
219
|
+
* json_authoritative; after pending the phase stays pending (fail-closed,
|
|
220
|
+
* recoverable on the next start by draining the mirror).
|
|
221
|
+
*/
|
|
222
|
+
export async function initializeLanAccessCutover(options = {}) {
|
|
223
|
+
const storage = options.storage || getSqliteStorage()
|
|
224
|
+
const repository = options.repository || createLanAccessRepository(storage)
|
|
225
|
+
configureLanAccessService({ repository, mirror: options.mirror || createDefaultLanAccessMirror() })
|
|
226
|
+
return runLanAccessMaintenance(async () => {
|
|
227
|
+
const current = readLanAccessStorageState()
|
|
228
|
+
if (current.phase === LAN_ACCESS_STORAGE_PHASES.JSON_PENDING) {
|
|
229
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
230
|
+
if (!integrity.ok) throw new Error('LAN access state pending integrity verification failed')
|
|
231
|
+
const drained = await drainLanAccessJsonMirror()
|
|
232
|
+
if (drained.pending === 0) setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE, { lanTokenCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
|
|
233
|
+
return readLanAccessStorageState()
|
|
234
|
+
}
|
|
235
|
+
if (current.phase === LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE) {
|
|
236
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
237
|
+
if (!integrity.ok) throw new Error('LAN access state authoritative integrity verification failed')
|
|
238
|
+
await drainLanAccessJsonMirror()
|
|
239
|
+
return readLanAccessStorageState()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (current.phase === LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
|
|
243
|
+
setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
244
|
+
backupFile: current.backupFile,
|
|
245
|
+
diagnostic: { operation: 'cutover_recovery', recoveredFrom: LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING },
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let backupFile = current.backupFile
|
|
250
|
+
try {
|
|
251
|
+
const readJson = options.readJson || readLanAccessJsonSource
|
|
252
|
+
const first = buildLanAccessJsonSnapshot(await readJson())
|
|
253
|
+
const second = buildLanAccessJsonSnapshot(await readJson())
|
|
254
|
+
if (first.tokenCount !== second.tokenCount || first.digest !== second.digest) {
|
|
255
|
+
throw new Error('LAN access JSON source changed during cutover double read')
|
|
256
|
+
}
|
|
257
|
+
if (!backupFile) backupFile = await writeLanAccessCutoverBackup(first, options.backupDirectory)
|
|
258
|
+
const third = buildLanAccessJsonSnapshot(await readJson())
|
|
259
|
+
if (first.tokenCount !== third.tokenCount || first.digest !== third.digest) {
|
|
260
|
+
throw new Error('LAN access JSON source changed before cutover commit')
|
|
261
|
+
}
|
|
262
|
+
setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.CUTOVER_RUNNING, {
|
|
263
|
+
lanTokenCount: first.tokenCount,
|
|
264
|
+
digest: first.digest,
|
|
265
|
+
backupFile,
|
|
266
|
+
diagnostic: first.diagnostics,
|
|
267
|
+
})
|
|
268
|
+
const pendingValues = {
|
|
269
|
+
phase: LAN_ACCESS_STORAGE_PHASES.JSON_PENDING,
|
|
270
|
+
backupFile,
|
|
271
|
+
diagnostic: first.diagnostics,
|
|
272
|
+
}
|
|
273
|
+
repository.replaceAll(first.config, {
|
|
274
|
+
expectedCount: first.tokenCount,
|
|
275
|
+
expectedDigest: first.digest,
|
|
276
|
+
storageState: pendingValues,
|
|
277
|
+
})
|
|
278
|
+
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
279
|
+
if (!integrity.ok || integrity.count !== first.tokenCount || integrity.digest !== first.digest) {
|
|
280
|
+
throw new Error('LAN access SQLite replace verification failed')
|
|
281
|
+
}
|
|
282
|
+
// Refresh the cached phase so the mirror drain runs in pending mode.
|
|
283
|
+
readLanAccessStorageState()
|
|
284
|
+
const drained = await drainLanAccessJsonMirror()
|
|
285
|
+
if (drained.pending === 0) {
|
|
286
|
+
setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE, {
|
|
287
|
+
lanTokenCount: integrity.count,
|
|
288
|
+
digest: integrity.digest,
|
|
289
|
+
backupFile,
|
|
290
|
+
diagnostic: first.diagnostics,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
return readLanAccessStorageState()
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const state = readLanAccessStorageState()
|
|
296
|
+
if (![LAN_ACCESS_STORAGE_PHASES.JSON_PENDING, LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
|
|
297
|
+
setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
298
|
+
backupFile,
|
|
299
|
+
diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
|
|
300
|
+
})
|
|
301
|
+
return readLanAccessStorageState()
|
|
302
|
+
}
|
|
303
|
+
throw error
|
|
304
|
+
}
|
|
305
|
+
}, { ...options, storage, operation: 'lan-access-cutover' })
|
|
306
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { ensureStorage, storageDir } from './storage.mjs'
|
|
4
|
+
import { normalizeLanAccessConfig } from './sqlite/lan-access-repository.mjs'
|
|
5
|
+
|
|
6
|
+
// F11 mirror file: `security/lan-access.json` stays as the best-effort JSON
|
|
7
|
+
// mirror (and cutover source) once SQLite is authoritative. Writes use an
|
|
8
|
+
// atomic tmp+rename and always normalize through the repository so the file
|
|
9
|
+
// shape exactly matches what lan-access-store.mjs writes.
|
|
10
|
+
export function lanAccessJsonPath() {
|
|
11
|
+
return path.join(storageDir, 'security', 'lan-access.json')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function defaultLanAccessConfig() {
|
|
15
|
+
return {
|
|
16
|
+
enabled: false,
|
|
17
|
+
passwordHash: undefined,
|
|
18
|
+
passwordSalt: undefined,
|
|
19
|
+
passwordVersion: undefined,
|
|
20
|
+
authVersion: 1,
|
|
21
|
+
sessionTtlHours: 12,
|
|
22
|
+
updatedAt: new Date().toISOString(),
|
|
23
|
+
tokens: [],
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function ensureLanAccessJsonFile() {
|
|
28
|
+
await ensureStorage()
|
|
29
|
+
await fs.mkdir(path.dirname(lanAccessJsonPath()), { recursive: true })
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(lanAccessJsonPath())
|
|
32
|
+
} catch {
|
|
33
|
+
await fs.writeFile(lanAccessJsonPath(), `${JSON.stringify(normalizeLanAccessConfig(defaultLanAccessConfig()), null, 2)}\n`, 'utf8')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readLanAccessJsonFile() {
|
|
38
|
+
await ensureLanAccessJsonFile()
|
|
39
|
+
try {
|
|
40
|
+
const raw = await fs.readFile(lanAccessJsonPath(), 'utf8')
|
|
41
|
+
const text = raw.trimStart()
|
|
42
|
+
const parsed = text ? JSON.parse(text) : {}
|
|
43
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error?.code === 'ENOENT') return defaultLanAccessConfig()
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function writeLanAccessJsonFile(config) {
|
|
51
|
+
await ensureLanAccessJsonFile()
|
|
52
|
+
const finalPath = lanAccessJsonPath()
|
|
53
|
+
const temporaryPath = `${finalPath}.${process.pid}.tmp`
|
|
54
|
+
try {
|
|
55
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(normalizeLanAccessConfig(config), null, 2)}\n`, 'utf8')
|
|
56
|
+
await fs.rename(temporaryPath, finalPath)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Materialize one mirror entry into the whole-file JSON store. `delete` resets
|
|
64
|
+
// the file to the default disabled config (the same fallback semantics the
|
|
65
|
+
// store applies to a missing file).
|
|
66
|
+
export async function materializeLanAccessJsonEntry(entry) {
|
|
67
|
+
if (!entry || typeof entry !== 'object') throw new Error('LAN access mirror entry is invalid')
|
|
68
|
+
if (entry.operation === 'delete') {
|
|
69
|
+
await writeLanAccessJsonFile(defaultLanAccessConfig())
|
|
70
|
+
} else if (entry.operation === 'upsert' && entry.config) {
|
|
71
|
+
await writeLanAccessJsonFile(entry.config)
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error('LAN access mirror entry is invalid')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createLanAccessRepository } from './sqlite/lan-access-repository.mjs'
|
|
2
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
3
|
+
import { materializeLanAccessJsonEntry } from './lan-access-json-file.mjs'
|
|
4
|
+
|
|
5
|
+
// F11 LAN access storage phases — the same state machine as F8 session state /
|
|
6
|
+
// F10 share storage but in its own storage domain (`lan_access_storage_state`,
|
|
7
|
+
// independent maintenance lock).
|
|
8
|
+
export const LAN_ACCESS_STORAGE_PHASES = Object.freeze({
|
|
9
|
+
JSON_AUTHORITATIVE: 'json_authoritative',
|
|
10
|
+
CUTOVER_RUNNING: 'cutover_running',
|
|
11
|
+
JSON_PENDING: 'sqlite_authoritative_json_pending',
|
|
12
|
+
AUTHORITATIVE: 'authoritative',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
let repositoryInstance = null
|
|
16
|
+
let cachedPhase = LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE
|
|
17
|
+
let mirrorAdapter = null
|
|
18
|
+
let drainPromise = null
|
|
19
|
+
let mirrorTimer = null
|
|
20
|
+
const MIRROR_DRAIN_INTERVAL_MS = 1000
|
|
21
|
+
|
|
22
|
+
function storage() {
|
|
23
|
+
return getSqliteStorage()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function repository() {
|
|
27
|
+
if (!repositoryInstance) repositoryInstance = createLanAccessRepository(storage())
|
|
28
|
+
return repositoryInstance
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sqliteReadable() {
|
|
32
|
+
return cachedPhase === LAN_ACCESS_STORAGE_PHASES.JSON_PENDING || cachedPhase === LAN_ACCESS_STORAGE_PHASES.AUTHORITATIVE
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createDefaultLanAccessMirror() {
|
|
36
|
+
return {
|
|
37
|
+
async upsert(config) {
|
|
38
|
+
await materializeLanAccessJsonEntry({ operation: 'upsert', config })
|
|
39
|
+
},
|
|
40
|
+
async delete() {
|
|
41
|
+
await materializeLanAccessJsonEntry({ operation: 'delete' })
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function configureLanAccessService({ repository: configuredRepository, mirror, phase } = {}) {
|
|
47
|
+
if (configuredRepository !== undefined) repositoryInstance = configuredRepository
|
|
48
|
+
if (mirror !== undefined) mirrorAdapter = mirror
|
|
49
|
+
if (phase !== undefined) {
|
|
50
|
+
if (!Object.values(LAN_ACCESS_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid LAN access storage phase: ${phase}`)
|
|
51
|
+
cachedPhase = phase
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The active LAN access repository (lazily created from the SQLite storage
|
|
57
|
+
* handle). Only callable while the LAN access storage is SQLite-readable
|
|
58
|
+
* (pending or authoritative); JSON-authoritative phases keep the legacy JSON
|
|
59
|
+
* store path.
|
|
60
|
+
*/
|
|
61
|
+
export function getLanAccessRepository() {
|
|
62
|
+
return repository()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function setLanAccessStoragePhase(phase, values = {}) {
|
|
66
|
+
if (!Object.values(LAN_ACCESS_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid LAN access storage phase: ${phase}`)
|
|
67
|
+
const updatedAt = new Date().toISOString()
|
|
68
|
+
storage().prepare(`UPDATE lan_access_storage_state SET phase = ?, lan_token_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ? WHERE singleton = 1`)
|
|
69
|
+
.run(phase, values.lanTokenCount ?? null, values.digest ?? null, values.backupFile ?? null, values.diagnostic ? JSON.stringify(values.diagnostic) : null, updatedAt)
|
|
70
|
+
cachedPhase = phase
|
|
71
|
+
return readLanAccessStorageState()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readLanAccessStorageState() {
|
|
75
|
+
const row = storage().prepare('SELECT * FROM lan_access_storage_state WHERE singleton = 1').get()
|
|
76
|
+
if (!row) throw new Error('LAN access storage state is missing')
|
|
77
|
+
cachedPhase = row.phase
|
|
78
|
+
return {
|
|
79
|
+
phase: row.phase,
|
|
80
|
+
lanTokenCount: row.lan_token_count === null ? null : Number(row.lan_token_count),
|
|
81
|
+
digest: row.digest,
|
|
82
|
+
backupFile: row.backup_file,
|
|
83
|
+
diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
|
|
84
|
+
updatedAt: row.updated_at,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function initializeLanAccessService() {
|
|
89
|
+
return readLanAccessStorageState()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getLanAccessStoragePhase() {
|
|
93
|
+
return cachedPhase
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isLanAccessStorageAuthoritative() {
|
|
97
|
+
return sqliteReadable()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scheduleLanAccessJsonMirrorDrain() {
|
|
101
|
+
if (!sqliteReadable() || !mirrorAdapter || mirrorTimer) return
|
|
102
|
+
mirrorTimer = setTimeout(() => {
|
|
103
|
+
mirrorTimer = null
|
|
104
|
+
void drainLanAccessJsonMirror().then(({ pending }) => {
|
|
105
|
+
if (pending > 0) scheduleLanAccessJsonMirrorDrain()
|
|
106
|
+
}).catch(() => {
|
|
107
|
+
scheduleLanAccessJsonMirrorDrain()
|
|
108
|
+
})
|
|
109
|
+
}, MIRROR_DRAIN_INTERVAL_MS)
|
|
110
|
+
mirrorTimer.unref?.()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function requestLanAccessJsonMirrorDrain() {
|
|
114
|
+
scheduleLanAccessJsonMirrorDrain()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function stopLanAccessService() {
|
|
118
|
+
if (mirrorTimer) clearTimeout(mirrorTimer)
|
|
119
|
+
mirrorTimer = null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function drainLanAccessJsonMirror() {
|
|
123
|
+
if (!sqliteReadable() || !mirrorAdapter) return { pending: 0, drained: 0, failed: 0 }
|
|
124
|
+
if (drainPromise) return drainPromise
|
|
125
|
+
drainPromise = (async () => {
|
|
126
|
+
let drained = 0
|
|
127
|
+
let failed = 0
|
|
128
|
+
for (;;) {
|
|
129
|
+
const entries = repository().listMirrorQueue()
|
|
130
|
+
if (entries.length === 0) break
|
|
131
|
+
let progressed = false
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
try {
|
|
134
|
+
if (entry.operation === 'upsert') await mirrorAdapter.upsert(entry.config)
|
|
135
|
+
else await mirrorAdapter.delete()
|
|
136
|
+
repository().acknowledgeMirror(entry)
|
|
137
|
+
drained += 1
|
|
138
|
+
progressed = true
|
|
139
|
+
} catch (error) {
|
|
140
|
+
repository().failMirror(entry, error)
|
|
141
|
+
failed += 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!progressed) break
|
|
145
|
+
}
|
|
146
|
+
return { pending: repository().listMirrorQueue().length, drained, failed }
|
|
147
|
+
})().finally(() => {
|
|
148
|
+
drainPromise = null
|
|
149
|
+
})
|
|
150
|
+
return drainPromise
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function listLanAccessMirrorQueue() {
|
|
154
|
+
return repository().listMirrorQueue()
|
|
155
|
+
}
|