@shawnstack/quickforge 1.7.6 → 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-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
- 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-D-J_8Smf.css +3 -0
- 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-BEV4_TXC.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-BTJ7mzk_.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/auto-compaction.mjs +43 -1
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/config.mjs +8 -16
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/context-usage.mjs +11 -4
- 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-Dl5Peqov.js +0 -1
- package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
- package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
- package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
- package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
- package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
- package/dist/assets/index-1ei6nhs-.css +0 -3
- package/dist/assets/index-BivLuZvX.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,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
|
+
}
|