@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,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
|
+
}
|