@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,854 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { getSqliteStorage } from './database.mjs'
3
+
4
+ // F10 share storage: `share_sessions` (strictly mapped columns + `extra_json`
5
+ // for opaque/unknown fields) and `share_tokens` (independent token table with
6
+ // UNIQUE(share_id, token_hash) and a ≤50 cap enforced at write time). The
7
+ // snapshot digest algorithm is shared with the JSON cutover side so a JSON
8
+ // import and its SQLite copy always hash identically.
9
+ export const MAX_SHARE_TOKENS = 50
10
+ export const SHARE_ID_PREFIX = 'qfs_'
11
+ export const SHARE_ID_PATTERN = /^qfs_[A-Za-z0-9_-]{16,80}$/
12
+
13
+ const SHARE_COLUMNS = `
14
+ share_id, session_id, permission, title_snapshot, scope, project_id,
15
+ password_hash, password_salt, password_version, auth_version, allow_cloud_usage,
16
+ created_at, updated_at, expires_at, revoked_at, superseded_at,
17
+ access_count, last_accessed_at, created_from_host, last_updated_from_host,
18
+ revision, record_digest, deleted_at, extra_json
19
+ `
20
+
21
+ // Fields modeled as columns; anything else on the JSON record is preserved
22
+ // verbatim in `extra_json` and restored on read/export (opaque roundtrip).
23
+ const KNOWN_FIELDS = new Set([
24
+ 'id', 'sessionId', 'permission', 'titleSnapshot', 'scope', 'projectId',
25
+ 'passwordHash', 'passwordSalt', 'passwordVersion', 'authVersion',
26
+ 'allowCloudUsage', 'createdAt', 'updatedAt', 'expiresAt', 'revokedAt',
27
+ 'supersededAt', 'accessCount', 'lastAccessedAt', 'createdFromHost',
28
+ 'lastUpdatedFromHost', 'tokens', 'tokenHash', 'tokenIssuedAt', 'tokenExpiresAt',
29
+ ])
30
+
31
+ function isPlainObject(value) {
32
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
33
+ }
34
+
35
+ function canonicalize(value) {
36
+ if (Array.isArray(value)) return value.map(canonicalize)
37
+ if (!isPlainObject(value)) return value
38
+ return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
39
+ }
40
+
41
+ function invalid(message) {
42
+ const error = new Error(message)
43
+ error.statusCode = 400
44
+ error.errorCode = 'SHARE_INVALID_INPUT'
45
+ return error
46
+ }
47
+
48
+ function notFound(shareId) {
49
+ const error = new Error(`Share not found: ${shareId}`)
50
+ error.statusCode = 404
51
+ error.errorCode = 'SHARE_NOT_FOUND'
52
+ return error
53
+ }
54
+
55
+ function conflict(shareId, expectedRevision, actualRevision) {
56
+ const error = new Error(`Share state conflict for ${shareId}`)
57
+ error.statusCode = 409
58
+ error.errorCode = 'SHARE_STATE_CONFLICT'
59
+ error.expectedRevision = expectedRevision
60
+ error.actualRevision = actualRevision
61
+ return error
62
+ }
63
+
64
+ function notActive(shareId, reason) {
65
+ const error = new Error(`Share ${shareId} is ${reason}`)
66
+ error.statusCode = 410
67
+ error.errorCode = 'SHARE_NOT_ACTIVE'
68
+ error.reason = reason
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
+ function nonEmptyString(value, field) {
79
+ if (typeof value !== 'string' || !value.trim()) throw invalid(`${field} is required`)
80
+ return value
81
+ }
82
+
83
+ export function assertSafeShareId(shareId) {
84
+ return nonEmptyString(shareId, 'shareId')
85
+ }
86
+
87
+ // Normalize one JSON-shape share record (camelCase, exactly the format written
88
+ // by share-store.mjs) into the canonical repository record. Shared with the
89
+ // JSON cutover snapshot builder so both sides validate identically.
90
+ export function normalizeShareRecord(input) {
91
+ if (!isPlainObject(input)) throw invalid('Share record must be an object')
92
+ const id = input.id
93
+ if (typeof id !== 'string' || !SHARE_ID_PATTERN.test(id)) throw invalid('Invalid share id')
94
+ if (input.sessionId !== undefined && typeof input.sessionId !== 'string') throw invalid('sessionId must be a string')
95
+
96
+ const sessionId = nonEmptyString(input.sessionId, 'sessionId')
97
+ const permission = input.permission
98
+ if (permission !== 'read' && permission !== 'operate') throw invalid('Invalid share permission')
99
+
100
+ const scope = input.scope === 'project' ? 'project' : 'global'
101
+ let projectId
102
+ if (scope === 'project') {
103
+ projectId = nonEmptyString(input.projectId, 'projectId')
104
+ } else if (input.projectId !== undefined && input.projectId !== null && input.projectId !== '') {
105
+ throw invalid('Global shares cannot have a projectId')
106
+ }
107
+
108
+ const passwordHash = typeof input.passwordHash === 'string' && input.passwordHash.trim() ? input.passwordHash : undefined
109
+ const passwordSalt = typeof input.passwordSalt === 'string' && input.passwordSalt.trim() ? input.passwordSalt : undefined
110
+ const passwordVersion = input.passwordVersion === undefined ? undefined : input.passwordVersion
111
+ if (passwordHash && !passwordSalt) throw invalid('Share password hash requires a password salt')
112
+ if (!passwordHash && passwordSalt) throw invalid('Share password salt requires a password hash')
113
+ if (passwordVersion !== undefined && (!Number.isInteger(passwordVersion) || passwordVersion < 1)) throw invalid('Share passwordVersion must be a positive integer')
114
+
115
+ const authVersion = input.authVersion === undefined ? 1 : input.authVersion
116
+ if (!Number.isInteger(authVersion) || authVersion < 0) throw invalid('Share authVersion must be a non-negative integer')
117
+
118
+ const accessCount = input.accessCount === undefined ? 0 : input.accessCount
119
+ if (!Number.isInteger(accessCount) || accessCount < 0) throw invalid('Share accessCount must be a non-negative integer')
120
+
121
+ const createdAt = nonEmptyString(input.createdAt, 'createdAt')
122
+ const updatedAt = nonEmptyString(input.updatedAt, 'updatedAt')
123
+
124
+ // `tokens` is authoritative when present; otherwise legacy single-token
125
+ // fields (tokenHash/tokenIssuedAt/tokenExpiresAt) are folded into one entry.
126
+ let tokens
127
+ if (input.tokens !== undefined) {
128
+ if (!Array.isArray(input.tokens)) throw invalid('Share tokens must be an array')
129
+ tokens = input.tokens
130
+ } else if (typeof input.tokenHash === 'string' && input.tokenHash.trim()) {
131
+ tokens = [{
132
+ tokenHash: input.tokenHash,
133
+ issuedAt: input.tokenIssuedAt,
134
+ expiresAt: input.tokenExpiresAt,
135
+ authVersion,
136
+ }]
137
+ } else {
138
+ tokens = []
139
+ }
140
+ const normalizedTokens = tokens.slice(-MAX_SHARE_TOKENS).map((entry) => {
141
+ if (!isPlainObject(entry)) throw invalid('Share token must be an object')
142
+ const tokenHash = entry.tokenHash
143
+ if (typeof tokenHash !== 'string' || !tokenHash.trim()) throw invalid('Share token hash is required')
144
+ const tokenAuthVersion = entry.authVersion === undefined ? authVersion : entry.authVersion
145
+ if (!Number.isInteger(tokenAuthVersion) || tokenAuthVersion < 1) throw invalid('Share token authVersion must be a positive integer')
146
+ return {
147
+ tokenHash,
148
+ // Deterministic sentinel for records that omit issuedAt: the tokens table
149
+ // column is NOT NULL, so stored rows always carry a value. Normalizing the
150
+ // JSON-side representation to the same sentinel keeps cutover and
151
+ // backup/restore digests stable for legacy v1 imports.
152
+ issuedAt: optionalTimestamp(entry.issuedAt, 'token issuedAt') ?? '1970-01-01T00:00:00.000Z',
153
+ expiresAt: optionalTimestamp(entry.expiresAt, 'token expiresAt'),
154
+ authVersion: tokenAuthVersion,
155
+ }
156
+ })
157
+
158
+ const extra = {}
159
+ for (const [key, value] of Object.entries(input)) {
160
+ if (!KNOWN_FIELDS.has(key)) extra[key] = value
161
+ }
162
+
163
+ return {
164
+ id,
165
+ sessionId,
166
+ permission,
167
+ titleSnapshot: typeof input.titleSnapshot === 'string' ? input.titleSnapshot : undefined,
168
+ scope,
169
+ projectId,
170
+ passwordHash,
171
+ passwordSalt,
172
+ passwordVersion,
173
+ authVersion,
174
+ allowCloudUsage: input.allowCloudUsage === true,
175
+ createdAt,
176
+ updatedAt,
177
+ expiresAt: optionalTimestamp(input.expiresAt, 'expiresAt'),
178
+ revokedAt: optionalTimestamp(input.revokedAt, 'revokedAt'),
179
+ supersededAt: optionalTimestamp(input.supersededAt, 'supersededAt'),
180
+ accessCount,
181
+ lastAccessedAt: optionalTimestamp(input.lastAccessedAt, 'lastAccessedAt'),
182
+ createdFromHost: typeof input.createdFromHost === 'string' ? input.createdFromHost : undefined,
183
+ lastUpdatedFromHost: typeof input.lastUpdatedFromHost === 'string' ? input.lastUpdatedFromHost : undefined,
184
+ tokens: normalizedTokens,
185
+ ...extra,
186
+ }
187
+ }
188
+
189
+ function sortedTokens(tokens) {
190
+ return (Array.isArray(tokens) ? tokens : []).slice().sort((left, right) =>
191
+ String(left.issuedAt || '').localeCompare(String(right.issuedAt || ''))
192
+ || String(left.tokenHash).localeCompare(String(right.tokenHash)))
193
+ }
194
+
195
+ // Canonical SHA-256 digest of a single share record (including tokens and any
196
+ // opaque fields). Excludes repository-internal `revision`/`deletedAt`.
197
+ export function shareRecordDigest(record) {
198
+ if (!record) return ''
199
+ const content = {
200
+ id: record.id,
201
+ sessionId: record.sessionId,
202
+ permission: record.permission,
203
+ titleSnapshot: record.titleSnapshot,
204
+ scope: record.scope,
205
+ projectId: record.projectId,
206
+ passwordHash: record.passwordHash,
207
+ passwordSalt: record.passwordSalt,
208
+ passwordVersion: record.passwordVersion,
209
+ authVersion: record.authVersion,
210
+ allowCloudUsage: record.allowCloudUsage === true,
211
+ createdAt: record.createdAt,
212
+ updatedAt: record.updatedAt,
213
+ expiresAt: record.expiresAt,
214
+ revokedAt: record.revokedAt,
215
+ supersededAt: record.supersededAt,
216
+ accessCount: record.accessCount || 0,
217
+ lastAccessedAt: record.lastAccessedAt,
218
+ createdFromHost: record.createdFromHost,
219
+ lastUpdatedFromHost: record.lastUpdatedFromHost,
220
+ tokens: sortedTokens(record.tokens).map((token) => ({
221
+ tokenHash: token.tokenHash,
222
+ issuedAt: token.issuedAt,
223
+ expiresAt: token.expiresAt,
224
+ authVersion: token.authVersion,
225
+ })),
226
+ }
227
+ for (const [key, value] of Object.entries(record)) {
228
+ if (!Object.prototype.hasOwnProperty.call(content, key) && !['revision', 'deletedAt'].includes(key)) content[key] = value
229
+ }
230
+ return createHash('sha256').update(JSON.stringify(canonicalize(content))).digest('hex')
231
+ }
232
+
233
+ // Snapshot digest: sha256 over sorted `shareId\0recordDigest` lines.
234
+ export function shareSnapshotDigest(records) {
235
+ const values = (Array.isArray(records) ? records : [])
236
+ .map((record) => `${record.id}\0${shareRecordDigest(record)}`)
237
+ .sort()
238
+ return createHash('sha256').update(values.join('\n')).digest('hex')
239
+ }
240
+
241
+ function recordJson(record) {
242
+ return JSON.stringify(canonicalize({ ...record, revision: undefined, deletedAt: undefined }))
243
+ }
244
+
245
+ function mapShareRow(row) {
246
+ if (!row) return null
247
+ let extra
248
+ try {
249
+ extra = row.extra_json ? JSON.parse(row.extra_json) : {}
250
+ } catch {
251
+ extra = {}
252
+ }
253
+ const record = {
254
+ ...(isPlainObject(extra) ? extra : {}),
255
+ id: row.share_id,
256
+ sessionId: row.session_id,
257
+ permission: row.permission,
258
+ titleSnapshot: row.title_snapshot ?? undefined,
259
+ scope: row.scope,
260
+ projectId: row.scope === 'project' ? row.project_id : undefined,
261
+ passwordHash: row.password_hash ?? undefined,
262
+ passwordSalt: row.password_salt ?? undefined,
263
+ passwordVersion: row.password_version === null ? undefined : Number(row.password_version),
264
+ authVersion: Number(row.auth_version),
265
+ allowCloudUsage: row.allow_cloud_usage === 1,
266
+ createdAt: row.created_at,
267
+ updatedAt: row.updated_at,
268
+ expiresAt: row.expires_at ?? undefined,
269
+ revokedAt: row.revoked_at ?? undefined,
270
+ supersededAt: row.superseded_at ?? undefined,
271
+ accessCount: Number(row.access_count),
272
+ lastAccessedAt: row.last_accessed_at ?? undefined,
273
+ createdFromHost: row.created_from_host ?? undefined,
274
+ lastUpdatedFromHost: row.last_updated_from_host ?? undefined,
275
+ revision: Number(row.revision),
276
+ }
277
+ return record
278
+ }
279
+
280
+ function tokenRowsFor(database, shareIds) {
281
+ if (!shareIds || shareIds.length === 0) return []
282
+ const placeholders = shareIds.map(() => '?').join(',')
283
+ return database.prepare(`SELECT share_id, token_hash, issued_at, expires_at, auth_version
284
+ FROM share_tokens WHERE share_id IN (${placeholders}) ORDER BY issued_at, token_hash`).all(...shareIds)
285
+ }
286
+
287
+ function attachTokens(records, tokenRows) {
288
+ const groups = new Map()
289
+ for (const row of tokenRows) {
290
+ if (!groups.has(row.share_id)) groups.set(row.share_id, [])
291
+ groups.get(row.share_id).push(row)
292
+ }
293
+ return records.map((record) => ({
294
+ ...record,
295
+ tokens: (groups.get(record.id) || []).map((row) => ({
296
+ tokenHash: row.token_hash,
297
+ issuedAt: row.issued_at ?? undefined,
298
+ expiresAt: row.expires_at ?? undefined,
299
+ authVersion: Number(row.auth_version),
300
+ })),
301
+ }))
302
+ }
303
+
304
+ function insertShareRow(database, record, revision, createdAt, updatedAt) {
305
+ const extra = {}
306
+ for (const [key, value] of Object.entries(record)) {
307
+ if (!KNOWN_FIELDS.has(key) && !['revision', 'deletedAt'].includes(key)) extra[key] = value
308
+ }
309
+ const values = [
310
+ record.id, record.sessionId, record.permission,
311
+ record.titleSnapshot ?? null, record.scope, record.projectId ?? null,
312
+ record.passwordHash ?? null, record.passwordSalt ?? null, record.passwordVersion ?? null,
313
+ record.authVersion, record.allowCloudUsage === true ? 1 : 0,
314
+ createdAt, updatedAt,
315
+ record.expiresAt ?? null, record.revokedAt ?? null, record.supersededAt ?? null,
316
+ record.accessCount || 0, record.lastAccessedAt ?? null,
317
+ record.createdFromHost ?? null, record.lastUpdatedFromHost ?? null,
318
+ revision, shareRecordDigest(record), record.deletedAt ?? null, JSON.stringify(extra),
319
+ ]
320
+ database.prepare(`
321
+ INSERT INTO share_sessions (${SHARE_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
322
+ ON CONFLICT(share_id) DO UPDATE SET
323
+ session_id = excluded.session_id,
324
+ permission = excluded.permission,
325
+ title_snapshot = excluded.title_snapshot,
326
+ scope = excluded.scope,
327
+ project_id = excluded.project_id,
328
+ password_hash = excluded.password_hash,
329
+ password_salt = excluded.password_salt,
330
+ password_version = excluded.password_version,
331
+ auth_version = excluded.auth_version,
332
+ allow_cloud_usage = excluded.allow_cloud_usage,
333
+ created_at = excluded.created_at,
334
+ updated_at = excluded.updated_at,
335
+ expires_at = excluded.expires_at,
336
+ revoked_at = excluded.revoked_at,
337
+ superseded_at = excluded.superseded_at,
338
+ access_count = excluded.access_count,
339
+ last_accessed_at = excluded.last_accessed_at,
340
+ created_from_host = excluded.created_from_host,
341
+ last_updated_from_host = excluded.last_updated_from_host,
342
+ revision = excluded.revision,
343
+ record_digest = excluded.record_digest,
344
+ deleted_at = excluded.deleted_at,
345
+ extra_json = excluded.extra_json
346
+ `).run(...values)
347
+ }
348
+
349
+ function replaceTokens(database, shareId, tokens) {
350
+ database.prepare('DELETE FROM share_tokens WHERE share_id = ?').run(shareId)
351
+ const insert = database.prepare('INSERT INTO share_tokens (share_id, token_hash, issued_at, expires_at, auth_version) VALUES (?, ?, ?, ?, ?)')
352
+ for (const token of (tokens || []).slice(-MAX_SHARE_TOKENS)) {
353
+ insert.run(shareId, token.tokenHash, token.issuedAt || new Date(0).toISOString(), token.expiresAt ?? null, token.authVersion)
354
+ }
355
+ }
356
+
357
+ function enqueueShareMirror(database, record, operation, updatedAt) {
358
+ const stored = record ? { ...record, revision: undefined, deletedAt: undefined } : null
359
+ database.prepare(`
360
+ INSERT INTO share_json_mirror_queue (share_id, operation, share_json, attempts, last_error, updated_at)
361
+ VALUES (?, ?, ?, 0, NULL, ?)
362
+ ON CONFLICT(share_id) DO UPDATE SET
363
+ operation = excluded.operation,
364
+ share_json = excluded.share_json,
365
+ attempts = 0,
366
+ last_error = NULL,
367
+ updated_at = excluded.updated_at
368
+ `).run(
369
+ record?.id || record,
370
+ operation,
371
+ operation === 'upsert' ? (stored ? recordJson(stored) : null) : null,
372
+ updatedAt,
373
+ )
374
+ }
375
+
376
+ function currentRow(database, shareId) {
377
+ return database.prepare(`SELECT ${SHARE_COLUMNS} FROM share_sessions WHERE share_id = ?`).get(shareId)
378
+ }
379
+
380
+ function actualRevision(database, shareId) {
381
+ const row = currentRow(database, shareId)
382
+ if (row) return { revision: Number(row.revision), active: row.deleted_at === null, row }
383
+ return { revision: 0, active: false, row: null }
384
+ }
385
+
386
+ function assertCas(database, shareId, expectedRevision) {
387
+ if (expectedRevision === undefined || expectedRevision === null) return
388
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) throw invalid('expectedRevision must be a non-negative integer')
389
+ const current = actualRevision(database, shareId)
390
+ if (current.revision !== expectedRevision) throw conflict(shareId, expectedRevision, current.revision)
391
+ }
392
+
393
+ function updateStorageState(database, values, timestamp) {
394
+ if (!values) return
395
+ database.prepare(`UPDATE share_storage_state SET phase = ?, share_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ?
396
+ WHERE singleton = 1`).run(
397
+ values.phase,
398
+ values.shareCount ?? null,
399
+ values.digest ?? null,
400
+ values.backupFile ?? null,
401
+ values.diagnostic ? JSON.stringify(values.diagnostic) : null,
402
+ timestamp,
403
+ )
404
+ }
405
+
406
+ export function createShareRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
407
+ const storage = storageHandle ?? getSqliteStorage()
408
+ if (!storage || typeof storage.prepare !== 'function' || typeof storage.transaction !== 'function') {
409
+ throw new TypeError('Share repository requires a SQLite storage handle')
410
+ }
411
+
412
+ function get(shareId) {
413
+ assertSafeShareId(shareId)
414
+ const row = currentRow(storage, shareId)
415
+ if (!row || row.deleted_at !== null) return null
416
+ const record = mapShareRow(row)
417
+ return attachTokens([record], tokenRowsFor(storage, [shareId]))[0]
418
+ }
419
+
420
+ function list({ sessionId, includeRevoked = false } = {}) {
421
+ const conditions = ['deleted_at IS NULL', 'superseded_at IS NULL']
422
+ const params = []
423
+ if (!includeRevoked) conditions.push('revoked_at IS NULL')
424
+ if (sessionId) {
425
+ nonEmptyString(sessionId, 'sessionId')
426
+ conditions.push('session_id = ?')
427
+ params.push(sessionId)
428
+ }
429
+ const rows = storage.prepare(`SELECT ${SHARE_COLUMNS} FROM share_sessions
430
+ WHERE ${conditions.join(' AND ')} ORDER BY updated_at DESC, share_id DESC`).all(...params)
431
+ return attachTokens(rows.map(mapShareRow), tokenRowsFor(storage, rows.map((row) => row.share_id)))
432
+ }
433
+
434
+ function create(input, { expectedRevision = null, beforeCommit } = {}) {
435
+ const record = normalizeShareRecord(input)
436
+ const timestamp = now()
437
+ const passwordProvided = Object.prototype.hasOwnProperty.call(input, 'passwordHash')
438
+ || Object.prototype.hasOwnProperty.call(input, 'passwordSalt')
439
+ || Object.prototype.hasOwnProperty.call(input, 'passwordVersion')
440
+ const tokensProvided = Object.prototype.hasOwnProperty.call(input, 'tokens')
441
+
442
+ return storage.transaction((database) => {
443
+ const sessionRows = database.prepare(`SELECT ${SHARE_COLUMNS} FROM share_sessions
444
+ WHERE session_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, share_id DESC`).all(record.sessionId)
445
+ const current = sessionRows.find((row) => row.superseded_at === null) || sessionRows[0]
446
+ const targetId = current ? current.share_id : (input.id ?? null)
447
+
448
+ // Supersede every other share record for the same session in this same
449
+ // transaction (their tokens are cleared; later records are revoked too).
450
+ for (const row of sessionRows) {
451
+ if (row.share_id === targetId) continue
452
+ const stale = mapShareRow(row)
453
+ const superseded = {
454
+ ...stale,
455
+ supersededAt: stale.supersededAt || timestamp,
456
+ revokedAt: stale.revokedAt || timestamp,
457
+ updatedAt: timestamp,
458
+ tokens: [],
459
+ authVersion: stale.authVersion + 1,
460
+ }
461
+ assertCas(database, row.share_id, null)
462
+ insertShareRow(database, superseded, Number(row.revision) + 1, stale.createdAt, timestamp)
463
+ enqueueShareMirror(database, { ...superseded, revision: undefined }, 'upsert', timestamp)
464
+ }
465
+
466
+ if (current) {
467
+ assertCas(database, current.share_id, expectedRevision)
468
+ const existing = mapShareRow(current)
469
+ const willHavePassword = passwordProvided ? Boolean(record.passwordHash) : Boolean(existing.passwordHash)
470
+ if (record.permission === 'operate' && !willHavePassword) throw invalid('Editable shares require a non-empty password')
471
+ const merged = {
472
+ ...existing,
473
+ ...record,
474
+ id: current.share_id,
475
+ createdAt: existing.createdAt,
476
+ updatedAt: timestamp,
477
+ createdFromHost: existing.createdFromHost || record.createdFromHost,
478
+ lastUpdatedFromHost: record.lastUpdatedFromHost || timestamp,
479
+ authVersion: passwordProvided ? (existing.authVersion || 1) + 1 : (existing.authVersion || 1),
480
+ tokens: tokensProvided ? record.tokens : (passwordProvided ? [] : existing.tokens),
481
+ allowCloudUsage: record.permission === 'operate' && record.allowCloudUsage === true,
482
+ supersededAt: undefined,
483
+ revokedAt: undefined,
484
+ }
485
+ const nextRevision = Number(current.revision) + 1
486
+ insertShareRow(database, merged, nextRevision, existing.createdAt, timestamp)
487
+ replaceTokens(database, merged.id, merged.tokens)
488
+ enqueueShareMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
489
+ return { ...merged, revision: nextRevision }
490
+ }
491
+
492
+ let id = targetId
493
+ while (!id || database.prepare('SELECT 1 FROM share_sessions WHERE share_id = ?').get(id)) {
494
+ id = `${SHARE_ID_PREFIX}${randomBytes(18).toString('base64url')}`
495
+ }
496
+ assertCas(database, id, expectedRevision)
497
+ if (record.permission === 'operate' && !record.passwordHash) throw invalid('Editable shares require a non-empty password')
498
+ const created = { ...record, id, updatedAt: timestamp, tokens: record.tokens }
499
+ // The row's created_at must match the createdAt used for record_digest
500
+ // (the input value) so verifyIntegrity's digest check stays consistent.
501
+ insertShareRow(database, created, 1, created.createdAt || timestamp, timestamp)
502
+ replaceTokens(database, id, created.tokens)
503
+ enqueueShareMirror(database, { ...created, revision: 1 }, 'upsert', timestamp)
504
+ beforeCommit?.(database)
505
+ return { ...created, revision: 1 }
506
+ }, { mode: 'immediate' })
507
+ }
508
+
509
+ function update(shareId, changes, { expectedRevision = null } = {}) {
510
+ assertSafeShareId(shareId)
511
+ if (!isPlainObject(changes)) throw invalid('Share update changes must be an object')
512
+ const timestamp = now()
513
+ return storage.transaction((database) => {
514
+ const row = currentRow(database, shareId)
515
+ if (!row || row.deleted_at !== null) throw notFound(shareId)
516
+ assertCas(database, shareId, expectedRevision)
517
+ const existing = mapShareRow(row)
518
+ if (existing.supersededAt) {
519
+ const error = new Error('Superseded shares cannot be updated')
520
+ error.statusCode = 409
521
+ error.errorCode = 'SHARE_NOT_ACTIVE'
522
+ throw error
523
+ }
524
+ if (existing.revokedAt) throw notActive(shareId, 'revoked')
525
+ if (existing.expiresAt && Date.parse(existing.expiresAt) <= Date.now()) throw notActive(shareId, 'expired')
526
+
527
+ const passwordProvided = Object.prototype.hasOwnProperty.call(changes, 'passwordHash')
528
+ || Object.prototype.hasOwnProperty.call(changes, 'passwordSalt')
529
+ || Object.prototype.hasOwnProperty.call(changes, 'passwordVersion')
530
+ const nextPermission = changes.permission === undefined ? existing.permission : changes.permission
531
+ if (nextPermission !== 'read' && nextPermission !== 'operate') throw invalid('Invalid share permission')
532
+ const willHavePassword = passwordProvided ? Boolean(changes.passwordHash) : Boolean(existing.passwordHash)
533
+ if (nextPermission === 'operate' && !willHavePassword) throw invalid('Editable shares require a non-empty password')
534
+ const nextAllowCloudUsage = nextPermission === 'operate'
535
+ && (changes.allowCloudUsage === undefined ? existing.allowCloudUsage : changes.allowCloudUsage === true)
536
+ const nextScope = changes.scope === undefined ? existing.scope : changes.scope
537
+ if (nextScope !== 'global' && nextScope !== 'project') throw invalid('Invalid share scope')
538
+ if (nextScope === 'project' && !(changes.projectId || existing.projectId)) throw invalid('Project shares require a project id')
539
+ if (nextScope === 'global' && changes.projectId) throw invalid('Global shares cannot have a projectId')
540
+
541
+ const merged = {
542
+ ...existing,
543
+ permission: nextPermission,
544
+ titleSnapshot: changes.titleSnapshot === undefined ? existing.titleSnapshot : changes.titleSnapshot,
545
+ scope: nextScope,
546
+ projectId: nextScope === 'project' ? (changes.projectId ?? existing.projectId) : undefined,
547
+ expiresAt: changes.expiresAt === undefined ? existing.expiresAt : optionalTimestamp(changes.expiresAt, 'expiresAt'),
548
+ allowCloudUsage: nextAllowCloudUsage,
549
+ lastUpdatedFromHost: changes.lastUpdatedFromHost === undefined ? existing.lastUpdatedFromHost : changes.lastUpdatedFromHost,
550
+ updatedAt: timestamp,
551
+ authVersion: passwordProvided ? (existing.authVersion || 1) + 1 : existing.authVersion,
552
+ tokens: passwordProvided ? [] : existing.tokens,
553
+ }
554
+ if (passwordProvided) {
555
+ merged.passwordHash = changes.passwordHash === undefined ? undefined : (typeof changes.passwordHash === 'string' && changes.passwordHash.trim() ? changes.passwordHash : undefined)
556
+ merged.passwordSalt = changes.passwordSalt === undefined ? undefined : (typeof changes.passwordSalt === 'string' && changes.passwordSalt.trim() ? changes.passwordSalt : undefined)
557
+ merged.passwordVersion = changes.passwordVersion === undefined ? undefined : changes.passwordVersion
558
+ if (merged.passwordHash && !merged.passwordSalt) throw invalid('Share password hash requires a password salt')
559
+ }
560
+ const nextRevision = Number(row.revision) + 1
561
+ insertShareRow(database, merged, nextRevision, existing.createdAt, timestamp)
562
+ replaceTokens(database, shareId, merged.tokens)
563
+ enqueueShareMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
564
+ return { ...merged, revision: nextRevision }
565
+ }, { mode: 'immediate' })
566
+ }
567
+
568
+ function revoke(shareId, { expectedRevision = null } = {}) {
569
+ assertSafeShareId(shareId)
570
+ const timestamp = now()
571
+ return storage.transaction((database) => {
572
+ const row = currentRow(database, shareId)
573
+ if (!row || row.deleted_at !== null) throw notFound(shareId)
574
+ assertCas(database, shareId, expectedRevision)
575
+ const existing = mapShareRow(row)
576
+ const merged = {
577
+ ...existing,
578
+ revokedAt: existing.revokedAt || timestamp,
579
+ updatedAt: timestamp,
580
+ authVersion: existing.authVersion + 1,
581
+ tokens: [],
582
+ }
583
+ const nextRevision = Number(row.revision) + 1
584
+ insertShareRow(database, merged, nextRevision, existing.createdAt, timestamp)
585
+ replaceTokens(database, shareId, [])
586
+ enqueueShareMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
587
+ return { ...merged, revision: nextRevision }
588
+ }, { mode: 'immediate' })
589
+ }
590
+
591
+ function restore(shareId, { expiresAt, expectedRevision = null } = {}) {
592
+ assertSafeShareId(shareId)
593
+ const timestamp = now()
594
+ if (expiresAt !== undefined && expiresAt !== null) optionalTimestamp(expiresAt, 'expiresAt')
595
+ return storage.transaction((database) => {
596
+ const row = currentRow(database, shareId)
597
+ if (!row || row.deleted_at !== null) throw notFound(shareId)
598
+ assertCas(database, shareId, expectedRevision)
599
+ const existing = mapShareRow(row)
600
+ if (existing.supersededAt) {
601
+ const error = new Error('Superseded shares cannot be restored')
602
+ error.statusCode = 409
603
+ error.errorCode = 'SHARE_NOT_ACTIVE'
604
+ throw error
605
+ }
606
+ const merged = {
607
+ ...existing,
608
+ revokedAt: undefined,
609
+ expiresAt: expiresAt === undefined || expiresAt === null ? undefined : expiresAt,
610
+ updatedAt: timestamp,
611
+ authVersion: existing.authVersion + 1,
612
+ tokens: [],
613
+ }
614
+ const nextRevision = Number(row.revision) + 1
615
+ insertShareRow(database, merged, nextRevision, existing.createdAt, timestamp)
616
+ replaceTokens(database, shareId, [])
617
+ enqueueShareMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
618
+ return { ...merged, revision: nextRevision }
619
+ }, { mode: 'immediate' })
620
+ }
621
+
622
+ function deleteShare(shareId, { expectedRevision = null } = {}) {
623
+ assertSafeShareId(shareId)
624
+ const timestamp = now()
625
+ return storage.transaction((database) => {
626
+ const row = currentRow(database, shareId)
627
+ if (!row || row.deleted_at !== null) throw notFound(shareId)
628
+ assertCas(database, shareId, expectedRevision)
629
+ const existing = mapShareRow(row)
630
+ const nextRevision = Number(row.revision) + 1
631
+ database.prepare('UPDATE share_sessions SET deleted_at = ?, updated_at = ?, revision = ? WHERE share_id = ?')
632
+ .run(timestamp, timestamp, nextRevision, shareId)
633
+ database.prepare('DELETE FROM share_tokens WHERE share_id = ?').run(shareId)
634
+ enqueueShareMirror(database, { ...existing, revision: nextRevision }, 'delete', timestamp)
635
+ return true
636
+ }, { mode: 'immediate' })
637
+ }
638
+
639
+ function pruneTokens(shareId, { expectedRevision = null } = {}) {
640
+ assertSafeShareId(shareId)
641
+ const timestamp = now()
642
+ return storage.transaction((database) => {
643
+ const row = currentRow(database, shareId)
644
+ if (!row || row.deleted_at !== null) return 0
645
+ assertCas(database, shareId, expectedRevision)
646
+ const removed = Number(database.prepare(`DELETE FROM share_tokens WHERE share_id = ? AND expires_at IS NOT NULL AND expires_at <= ?`)
647
+ .run(shareId, timestamp).changes)
648
+ if (removed === 0) return 0
649
+ const existing = mapShareRow(row)
650
+ const merged = { ...existing, tokens: attachTokens([existing], tokenRowsFor(database, [shareId]))[0].tokens }
651
+ const nextRevision = Number(row.revision) + 1
652
+ insertShareRow(database, merged, nextRevision, existing.createdAt, timestamp)
653
+ enqueueShareMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
654
+ return removed
655
+ }, { mode: 'immediate' })
656
+ }
657
+
658
+ function issueToken(shareId, { expectedRevision = null } = {}) {
659
+ assertSafeShareId(shareId)
660
+ const timestamp = now()
661
+ const secret = randomBytes(32).toString('base64url')
662
+ const token = `${shareId}.${secret}`
663
+ const tokenHash = createHash('sha256').update(secret).digest('base64url')
664
+ const expiresAt = new Date(Date.parse(timestamp) + 7 * 24 * 60 * 60 * 1000).toISOString()
665
+ return storage.transaction((database) => {
666
+ const row = currentRow(database, shareId)
667
+ if (!row || row.deleted_at !== null) throw notFound(shareId)
668
+ assertCas(database, shareId, expectedRevision)
669
+ const existing = mapShareRow(row)
670
+ if (existing.supersededAt) throw notActive(shareId, 'superseded')
671
+ if (existing.revokedAt) throw notActive(shareId, 'revoked')
672
+ if (existing.expiresAt && Date.parse(existing.expiresAt) <= Date.now()) throw notActive(shareId, 'expired')
673
+ const tokenAuthVersion = existing.authVersion || 1
674
+ const activeTokens = attachTokens([existing], tokenRowsFor(database, [shareId]))[0].tokens
675
+ .filter((entry) => !entry.expiresAt || Date.parse(entry.expiresAt) > Date.now())
676
+ activeTokens.push({ tokenHash, issuedAt: timestamp, expiresAt, authVersion: tokenAuthVersion })
677
+ const kept = activeTokens.slice(-MAX_SHARE_TOKENS)
678
+ const merged = {
679
+ ...existing,
680
+ accessCount: existing.accessCount + 1,
681
+ lastAccessedAt: timestamp,
682
+ tokens: kept,
683
+ }
684
+ const nextRevision = Number(row.revision) + 1
685
+ insertShareRow(database, merged, nextRevision, existing.createdAt, timestamp)
686
+ replaceTokens(database, shareId, kept)
687
+ enqueueShareMirror(database, { ...merged, revision: nextRevision }, 'upsert', timestamp)
688
+ return { token, share: { ...merged, revision: nextRevision } }
689
+ }, { mode: 'immediate' })
690
+ }
691
+
692
+ function verifyToken(record, token) {
693
+ if (!record || typeof token !== 'string') return false
694
+ const separator = token.indexOf('.')
695
+ if (separator <= 0) return false
696
+ const tokenShareId = token.slice(0, separator)
697
+ const secret = token.slice(separator + 1)
698
+ if (tokenShareId !== record.id || !secret) return false
699
+ const actualHash = createHash('sha256').update(secret).digest('base64url')
700
+ const authVersion = record.authVersion || 1
701
+ const nowMs = Date.now()
702
+ const tokenRecords = (Array.isArray(record.tokens) ? record.tokens : [])
703
+ .filter((entry) => entry && typeof entry.tokenHash === 'string'
704
+ && (!entry.expiresAt || Date.parse(entry.expiresAt) > nowMs))
705
+ return tokenRecords.some((entry) => (entry.authVersion || 1) === authVersion && safeHashEqual(entry.tokenHash, actualHash))
706
+ }
707
+
708
+ function replaceAll(inputs, { expectedCount, expectedDigest, storageState, beforeCommit } = {}) {
709
+ if (!Array.isArray(inputs)) throw invalid('Share records must be an array')
710
+ const records = inputs.map(normalizeShareRecord)
711
+ const ids = new Set()
712
+ for (const record of records) {
713
+ if (ids.has(record.id)) throw invalid(`Duplicate share id: ${record.id}`)
714
+ ids.add(record.id)
715
+ }
716
+ const timestamp = now()
717
+ return storage.transaction((database) => {
718
+ database.exec('DELETE FROM share_sessions; DELETE FROM share_tokens; DELETE FROM share_json_mirror_queue;')
719
+ for (const record of records) {
720
+ insertShareRow(database, { ...record, updatedAt: record.updatedAt || timestamp }, 1, record.createdAt || timestamp, record.updatedAt || timestamp)
721
+ replaceTokens(database, record.id, record.tokens)
722
+ enqueueShareMirror(database, { ...record, revision: 1 }, 'upsert', timestamp)
723
+ }
724
+ const digest = shareSnapshotDigest(records)
725
+ if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Share replace count verification failed')
726
+ if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Share replace digest verification failed')
727
+ updateStorageState(database, storageState ? { ...storageState, shareCount: records.length, digest, updatedAt: timestamp } : null, timestamp)
728
+ beforeCommit?.(database)
729
+ return records.length
730
+ }, { mode: 'immediate' })
731
+ }
732
+
733
+ function exportSnapshot() {
734
+ return storage.transaction((database) => {
735
+ const rows = database.prepare(`SELECT ${SHARE_COLUMNS} FROM share_sessions WHERE deleted_at IS NULL ORDER BY share_id`).all()
736
+ const records = attachTokens(rows.map(mapShareRow), tokenRowsFor(database, rows.map((row) => row.share_id)))
737
+ const digest = shareSnapshotDigest(records)
738
+ return { records, count: records.length, digest }
739
+ }, { mode: 'deferred' })
740
+ }
741
+
742
+ function count() {
743
+ return Number(storage.prepare('SELECT COUNT(*) AS count FROM share_sessions WHERE deleted_at IS NULL').get().count)
744
+ }
745
+
746
+ function digest() {
747
+ return shareSnapshotDigest(attachTokens(
748
+ storage.prepare(`SELECT ${SHARE_COLUMNS} FROM share_sessions WHERE deleted_at IS NULL ORDER BY share_id`).all().map(mapShareRow),
749
+ storage.prepare(`SELECT share_id, token_hash, issued_at, expires_at, auth_version FROM share_tokens ORDER BY issued_at, token_hash`).all(),
750
+ ))
751
+ }
752
+
753
+ function verifyIntegrity({ quickCheck = false } = {}) {
754
+ if (quickCheck) {
755
+ const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
756
+ if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
757
+ }
758
+ const rows = storage.prepare(`SELECT ${SHARE_COLUMNS} FROM share_sessions`).all()
759
+ let invalidRecords = 0
760
+ let invalidDigests = 0
761
+ let invalidPermissions = 0
762
+ let invalidScopes = 0
763
+ for (const row of rows) {
764
+ if (row.deleted_at !== null) continue
765
+ try {
766
+ const record = attachTokens([mapShareRow(row)], tokenRowsFor(storage, [row.share_id]))[0]
767
+ normalizeShareRecord({ ...record, revision: undefined, deletedAt: undefined })
768
+ if (row.record_digest !== shareRecordDigest(record)) invalidDigests += 1
769
+ if (row.permission !== 'read' && row.permission !== 'operate') invalidPermissions += 1
770
+ if (row.scope !== 'global' && row.scope !== 'project') invalidScopes += 1
771
+ } catch {
772
+ invalidRecords += 1
773
+ }
774
+ }
775
+ const tokenCounts = storage.prepare('SELECT share_id, COUNT(*) AS count FROM share_tokens GROUP BY share_id').all()
776
+ const overLimitTokens = tokenCounts.filter((row) => Number(row.count) > MAX_SHARE_TOKENS).length
777
+ const orphanTokens = Number(storage.prepare(`
778
+ SELECT COUNT(*) AS count FROM share_tokens t
779
+ LEFT JOIN share_sessions s ON s.share_id = t.share_id
780
+ WHERE s.share_id IS NULL
781
+ `).get().count)
782
+ const tokenAuthVersionMismatch = Number(storage.prepare(`
783
+ SELECT COUNT(*) AS count FROM share_tokens t
784
+ JOIN share_sessions s ON s.share_id = t.share_id
785
+ WHERE t.auth_version != s.auth_version AND s.deleted_at IS NULL
786
+ `).get().count)
787
+ return {
788
+ ok: invalidRecords === 0 && invalidDigests === 0 && invalidPermissions === 0 && invalidScopes === 0
789
+ && overLimitTokens === 0 && orphanTokens === 0 && tokenAuthVersionMismatch === 0,
790
+ count: Number(storage.prepare('SELECT COUNT(*) AS count FROM share_sessions WHERE deleted_at IS NULL').get().count),
791
+ digest: digest(),
792
+ invalidRecords,
793
+ invalidDigests,
794
+ invalidPermissions,
795
+ invalidScopes,
796
+ overLimitTokens,
797
+ orphanTokens,
798
+ tokenAuthVersionMismatch,
799
+ }
800
+ }
801
+
802
+ function listMirrorQueue() {
803
+ return storage.prepare('SELECT * FROM share_json_mirror_queue ORDER BY updated_at, share_id').all().map((row) => ({
804
+ shareId: row.share_id,
805
+ operation: row.operation,
806
+ record: row.share_json ? JSON.parse(row.share_json) : null,
807
+ attempts: Number(row.attempts),
808
+ lastError: row.last_error,
809
+ updatedAt: row.updated_at,
810
+ }))
811
+ }
812
+
813
+ function acknowledgeMirror(entry) {
814
+ return Number(storage.prepare('DELETE FROM share_json_mirror_queue WHERE share_id = ?')
815
+ .run(entry.shareId).changes) === 1
816
+ }
817
+
818
+ function failMirror(entry, error) {
819
+ storage.prepare(`UPDATE share_json_mirror_queue SET attempts = attempts + 1, last_error = ?, updated_at = ?
820
+ WHERE share_id = ?`)
821
+ .run(String(error?.message || error).slice(0, 1000), now(), entry.shareId)
822
+ }
823
+
824
+ return Object.freeze({
825
+ get,
826
+ list,
827
+ create,
828
+ update,
829
+ revoke,
830
+ restore,
831
+ delete: deleteShare,
832
+ issueToken,
833
+ verifyToken,
834
+ pruneTokens,
835
+ replaceAll,
836
+ exportSnapshot,
837
+ verifyIntegrity,
838
+ count,
839
+ digest,
840
+ listMirrorQueue,
841
+ acknowledgeMirror,
842
+ failMirror,
843
+ })
844
+ }
845
+
846
+ function safeHashEqual(expectedHash, actualHash) {
847
+ if (!expectedHash || !actualHash) return false
848
+ const expected = Buffer.from(expectedHash, 'base64url')
849
+ const actual = Buffer.from(actualHash, 'base64url')
850
+ if (expected.length !== actual.length) return false
851
+ let difference = 0
852
+ for (let index = 0; index < expected.length; index += 1) difference |= expected[index] ^ actual[index]
853
+ return difference === 0
854
+ }