dscs-server 0.0.1 → 0.1.1
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/README.md +14 -0
- package/assets/favicon.svg +8 -0
- package/assets/logo-dark.svg +9 -0
- package/dist/db.js +72 -1
- package/dist/http.js +363 -112
- package/dist/index.js +0 -0
- package/dist/sessions.js +160 -9
- package/dist/shared/batches.js +24 -8
- package/dist/shared/blob-batch.js +85 -0
- package/dist/shared/chain.js +14 -1
- package/dist/shared/index.js +1 -0
- package/dist/shared/protocol.js +2 -2
- package/dist/shared/sealed-frame.js +50 -13
- package/package.json +24 -2
package/dist/sessions.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { assertChain, buildManifest, manifestDigest } from './shared/chain.js';
|
|
2
|
+
import { assertChain, buildManifest, manifestDigest, stableStringify } from './shared/chain.js';
|
|
3
|
+
import { sha256 } from './shared/crypto.js';
|
|
3
4
|
import { SyncError } from './shared/wire.js';
|
|
4
5
|
import { blobPath } from './blobs.js';
|
|
5
|
-
import { DEFAULT_DATASET, bumpRevision, readRevision, withTransaction } from './db.js';
|
|
6
|
+
import { DEFAULT_DATASET, bumpRevision, readAppendRequest, readRevision, withTransaction, writeAppendRequest } from './db.js';
|
|
6
7
|
import { requireLease as requireResourceLease, SYNC_RESOURCE } from './leases.js';
|
|
7
8
|
export const MAX_APPEND_BODY_BYTES = 8 * 1024 * 1024;
|
|
8
9
|
const HASH = /^[0-9a-f]{64}$/u;
|
|
@@ -13,6 +14,18 @@ function assertRef(frame, expectedIndex) {
|
|
|
13
14
|
if (!KEY_ID.test(frame.keyId)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 keyId 非法`);
|
|
14
15
|
if (!Number.isSafeInteger(frame.bytes) || frame.bytes <= 0) throw new SyncError('bad-request', `帧 ${expectedIndex} 的字节数非法`);
|
|
15
16
|
if (!Number.isSafeInteger(frame.sealedVersion) || frame.sealedVersion < 1) throw new SyncError('bad-request', `帧 ${expectedIndex} 的封帧版本非法`);
|
|
17
|
+
if (frame.compression === undefined) {
|
|
18
|
+
if (frame.sealedVersion !== 1 || frame.compressedHash !== undefined) {
|
|
19
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 缺少压缩元数据`);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
if (frame.compression !== 'zstd' || frame.sealedVersion < 2) {
|
|
23
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 的压缩元数据非法`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof frame.compressedHash !== 'string' || !HASH.test(frame.compressedHash)) {
|
|
26
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 的 compressedHash 非法`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
16
29
|
if (!Number.isSafeInteger(frame.seqFrom) || !Number.isSafeInteger(frame.seqTo)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 seq 非法`);
|
|
17
30
|
}
|
|
18
31
|
function toChain(frames) {
|
|
@@ -24,7 +37,7 @@ function toChain(frames) {
|
|
|
24
37
|
}));
|
|
25
38
|
}
|
|
26
39
|
function sessionRow(db, sessionId) {
|
|
27
|
-
return db.handle.prepare('SELECT created_at, format_version, last_seq, frame_count, head_chain_hash, manifest_json, manifest_digest, revision, updated_at, deleted_at FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
|
|
40
|
+
return db.handle.prepare('SELECT created_at, format_version, last_seq, frame_count, head_chain_hash, manifest_json, manifest_digest, revision, updated_at, deleted_at, compression, stored_bytes FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
|
|
28
41
|
}
|
|
29
42
|
function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey) {
|
|
30
43
|
assertChain(toChain(frames));
|
|
@@ -50,8 +63,47 @@ function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tail
|
|
|
50
63
|
headChainHash: frames[frames.length - 1].chainHash
|
|
51
64
|
};
|
|
52
65
|
}
|
|
66
|
+
function compressionOf(value) {
|
|
67
|
+
return value === 'zstd' || value === 'mixed' ? value : 'none';
|
|
68
|
+
}
|
|
69
|
+
function statsOf(frames) {
|
|
70
|
+
let compressed = 0;
|
|
71
|
+
let storedBytes = 0;
|
|
72
|
+
for (const frame of frames){
|
|
73
|
+
if (frame.compression === 'zstd') compressed += 1;
|
|
74
|
+
storedBytes += frame.bytes;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
compression: compressed === 0 ? 'none' : compressed === frames.length ? 'zstd' : 'mixed',
|
|
78
|
+
storedBytes
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function appendRequestHash(input) {
|
|
82
|
+
return sha256(stableStringify({
|
|
83
|
+
sessionId: input.sessionId,
|
|
84
|
+
baseIndex: input.baseIndex,
|
|
85
|
+
baseChainHash: input.baseChainHash,
|
|
86
|
+
createdAt: input.createdAt,
|
|
87
|
+
formatVersion: input.formatVersion,
|
|
88
|
+
frames: input.frames,
|
|
89
|
+
tailOpen: input.tailOpen ?? null,
|
|
90
|
+
safeThroughSeq: input.safeThroughSeq ?? null,
|
|
91
|
+
workspaceKey: input.workspaceKey ?? null
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
53
94
|
export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
54
95
|
return withTransaction(db, ()=>{
|
|
96
|
+
const idempotencyKey = input.idempotencyKey;
|
|
97
|
+
const requestHash = idempotencyKey === undefined ? undefined : appendRequestHash(input);
|
|
98
|
+
if (idempotencyKey !== undefined && requestHash !== undefined) {
|
|
99
|
+
const existing = readAppendRequest(db, input.deviceId, input.sessionId, idempotencyKey);
|
|
100
|
+
if (existing !== undefined) {
|
|
101
|
+
if (existing.requestHash !== requestHash) {
|
|
102
|
+
throw new SyncError('conflict', 'Idempotency-Key 已用于不同的 append 请求');
|
|
103
|
+
}
|
|
104
|
+
return JSON.parse(existing.resultJson);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
55
107
|
requireResourceLease(db, SYNC_RESOURCE, input.deviceId, input.clientId, now);
|
|
56
108
|
const row = sessionRow(db, input.sessionId);
|
|
57
109
|
let combined;
|
|
@@ -103,14 +155,16 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
|
103
155
|
input.frames.forEach((frame, offset)=>assertRef(frame, (row === undefined || replacingLifecycle ? 0 : input.baseIndex + 1) + offset));
|
|
104
156
|
const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey);
|
|
105
157
|
const revision = bumpRevision(db);
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
const stats = statsOf(combined);
|
|
159
|
+
db.handle.prepare(`INSERT INTO sessions (dataset, session_id, created_at, format_version, last_seq, frame_count, head_chain_hash, manifest_json, manifest_digest, revision, updated_at, deleted_at, compression, stored_bytes)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
108
161
|
ON CONFLICT(dataset, session_id) DO UPDATE SET
|
|
109
162
|
created_at = excluded.created_at, format_version = excluded.format_version, last_seq = excluded.last_seq,
|
|
110
163
|
frame_count = excluded.frame_count, head_chain_hash = excluded.head_chain_hash,
|
|
111
164
|
manifest_json = excluded.manifest_json, manifest_digest = excluded.manifest_digest,
|
|
112
|
-
revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL
|
|
113
|
-
|
|
165
|
+
revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL,
|
|
166
|
+
compression = excluded.compression, stored_bytes = excluded.stored_bytes`).run(DEFAULT_DATASET, input.sessionId, createdAt, formatVersion, manifest.lastSeq, combined.length, headChainHash, JSON.stringify(manifest), digest, revision, now, stats.compression, stats.storedBytes);
|
|
167
|
+
const summary = {
|
|
114
168
|
sessionId: input.sessionId,
|
|
115
169
|
revision,
|
|
116
170
|
batchCount: combined.length,
|
|
@@ -118,8 +172,14 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
|
118
172
|
manifestDigest: digest,
|
|
119
173
|
lastSeq: manifest.lastSeq,
|
|
120
174
|
updatedAt: now,
|
|
121
|
-
deletedAt: null
|
|
175
|
+
deletedAt: null,
|
|
176
|
+
compression: stats.compression,
|
|
177
|
+
storedBytes: stats.storedBytes
|
|
122
178
|
};
|
|
179
|
+
if (idempotencyKey !== undefined && requestHash !== undefined) {
|
|
180
|
+
writeAppendRequest(db, input.deviceId, input.sessionId, idempotencyKey, requestHash, JSON.stringify(summary), now);
|
|
181
|
+
}
|
|
182
|
+
return summary;
|
|
123
183
|
});
|
|
124
184
|
}
|
|
125
185
|
export function deleteSession(db, sessionId, deviceId, clientId, createdAt, now = Date.now()) {
|
|
@@ -140,7 +200,9 @@ export function deleteSession(db, sessionId, deviceId, clientId, createdAt, now
|
|
|
140
200
|
manifestDigest: row.manifest_digest,
|
|
141
201
|
lastSeq: row.last_seq,
|
|
142
202
|
updatedAt: now,
|
|
143
|
-
deletedAt: now
|
|
203
|
+
deletedAt: now,
|
|
204
|
+
compression: compressionOf(row.compression),
|
|
205
|
+
storedBytes: row.stored_bytes
|
|
144
206
|
};
|
|
145
207
|
});
|
|
146
208
|
}
|
|
@@ -162,6 +224,95 @@ export function listBatches(db, sessionId, from, limit) {
|
|
|
162
224
|
revision: row.revision
|
|
163
225
|
};
|
|
164
226
|
}
|
|
227
|
+
const V2_SUMMARY_SELECT = `s.session_id, s.created_at, s.format_version, s.last_seq, s.frame_count,
|
|
228
|
+
s.head_chain_hash, s.manifest_digest, s.revision, s.updated_at, s.deleted_at,
|
|
229
|
+
s.compression, s.stored_bytes,
|
|
230
|
+
json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key,
|
|
231
|
+
json_extract(s.manifest_json, '$.tailOpen') AS tail_open,
|
|
232
|
+
json_extract(s.manifest_json, '$.safeThroughSeq') AS safe_through_seq`;
|
|
233
|
+
function mapV2Summary(row) {
|
|
234
|
+
const safe = typeof row.safe_through_seq === 'number' && Number.isSafeInteger(row.safe_through_seq) ? row.safe_through_seq : row.last_seq;
|
|
235
|
+
return {
|
|
236
|
+
sessionId: row.session_id,
|
|
237
|
+
revision: row.revision,
|
|
238
|
+
formatVersion: row.format_version,
|
|
239
|
+
createdAt: row.created_at,
|
|
240
|
+
batchCount: row.frame_count,
|
|
241
|
+
lastSeq: row.last_seq,
|
|
242
|
+
headChainHash: row.head_chain_hash,
|
|
243
|
+
manifestDigest: row.manifest_digest,
|
|
244
|
+
workspaceKey: typeof row.workspace_key === 'string' && row.workspace_key !== '' ? row.workspace_key : null,
|
|
245
|
+
safeThroughSeq: safe,
|
|
246
|
+
tailOpen: Number(row.tail_open) === 1,
|
|
247
|
+
updatedAt: row.updated_at,
|
|
248
|
+
deletedAt: row.deleted_at,
|
|
249
|
+
compression: compressionOf(row.compression),
|
|
250
|
+
storedBytes: Number.isSafeInteger(row.stored_bytes) ? row.stored_bytes : 0
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export function listV2Sessions(db, after, limit) {
|
|
254
|
+
const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
|
|
255
|
+
FROM sessions s
|
|
256
|
+
WHERE s.dataset = ? AND s.deleted_at IS NULL AND s.session_id > ?
|
|
257
|
+
ORDER BY s.session_id ASC LIMIT ?`).all(DEFAULT_DATASET, after ?? '', limit + 1);
|
|
258
|
+
const visible = rows.slice(0, limit);
|
|
259
|
+
const last = visible[visible.length - 1];
|
|
260
|
+
return {
|
|
261
|
+
sessions: visible.map(mapV2Summary),
|
|
262
|
+
nextSessionId: rows.length > limit && last !== undefined ? last.session_id : null
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function listV2Changes(db, sinceRevision, sinceTombstone, limit) {
|
|
266
|
+
const highWater = Math.max(sinceRevision, readRevision(db));
|
|
267
|
+
const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
|
|
268
|
+
FROM sessions s
|
|
269
|
+
WHERE s.dataset = ? AND s.deleted_at IS NULL AND s.revision > ? AND s.revision <= ?
|
|
270
|
+
ORDER BY s.revision ASC LIMIT ?`).all(DEFAULT_DATASET, sinceRevision, highWater, limit + 1);
|
|
271
|
+
const tombstoneRows = db.handle.prepare(`SELECT session_id, created_at, deleted_at FROM tombstones
|
|
272
|
+
WHERE dataset = ? AND (
|
|
273
|
+
deleted_at > ? OR
|
|
274
|
+
(deleted_at = ? AND session_id > ?) OR
|
|
275
|
+
(deleted_at = ? AND session_id = ? AND created_at > ?)
|
|
276
|
+
)
|
|
277
|
+
ORDER BY deleted_at ASC, session_id ASC, created_at ASC LIMIT ?`).all(DEFAULT_DATASET, sinceTombstone.deletedAt, sinceTombstone.deletedAt, sinceTombstone.sessionId, sinceTombstone.deletedAt, sinceTombstone.sessionId, sinceTombstone.createdAt, limit + 1);
|
|
278
|
+
const visible = rows.slice(0, limit);
|
|
279
|
+
const visibleTombstones = tombstoneRows.slice(0, limit);
|
|
280
|
+
const last = visible[visible.length - 1];
|
|
281
|
+
const lastTombstone = visibleTombstones[visibleTombstones.length - 1];
|
|
282
|
+
const hasMore = rows.length > limit || tombstoneRows.length > limit;
|
|
283
|
+
return {
|
|
284
|
+
sessions: visible.map(mapV2Summary),
|
|
285
|
+
tombstones: visibleTombstones.map((row)=>({
|
|
286
|
+
sessionId: row.session_id,
|
|
287
|
+
createdAt: row.created_at,
|
|
288
|
+
deletedAt: row.deleted_at
|
|
289
|
+
})),
|
|
290
|
+
nextRevision: rows.length > limit && last !== undefined ? last.revision : highWater,
|
|
291
|
+
nextTombstone: lastTombstone === undefined ? sinceTombstone : {
|
|
292
|
+
deletedAt: lastTombstone.deleted_at,
|
|
293
|
+
sessionId: lastTombstone.session_id,
|
|
294
|
+
createdAt: lastTombstone.created_at
|
|
295
|
+
},
|
|
296
|
+
hasMore
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export function getV2SessionSummary(db, sessionId) {
|
|
300
|
+
const row = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
|
|
301
|
+
FROM sessions s
|
|
302
|
+
WHERE s.dataset = ? AND s.session_id = ?`).get(DEFAULT_DATASET, sessionId);
|
|
303
|
+
return row === undefined ? undefined : mapV2Summary(row);
|
|
304
|
+
}
|
|
305
|
+
export function listV2Batches(db, sessionId, from, limit) {
|
|
306
|
+
const row = sessionRow(db, sessionId);
|
|
307
|
+
if (row === undefined) return undefined;
|
|
308
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
309
|
+
return {
|
|
310
|
+
batches: manifest.frames.slice(from, from + limit),
|
|
311
|
+
batchCount: manifest.frames.length,
|
|
312
|
+
revision: row.revision,
|
|
313
|
+
manifestDigest: row.manifest_digest
|
|
314
|
+
};
|
|
315
|
+
}
|
|
165
316
|
export function adminSessions(db, limit = 500) {
|
|
166
317
|
const rows = db.handle.prepare(`SELECT s.session_id, s.created_at, s.format_version, s.frame_count, s.last_seq, s.head_chain_hash,
|
|
167
318
|
s.revision, s.updated_at, s.deleted_at,
|
package/dist/shared/batches.js
CHANGED
|
@@ -28,14 +28,16 @@ export function headerMetaOf(header, inheritedEventCount) {
|
|
|
28
28
|
export function headerChainHash(meta) {
|
|
29
29
|
return sha256(stableStringify(meta));
|
|
30
30
|
}
|
|
31
|
-
export function sealHeader(meta, sessionId, sessionKey) {
|
|
31
|
+
export function sealHeader(meta, sessionId, sessionKey, compression = 'none') {
|
|
32
32
|
const plain = Buffer.from(stableStringify(meta), 'utf8');
|
|
33
33
|
return sealFrame(plain, {
|
|
34
34
|
sessionId,
|
|
35
35
|
index: HEADER_INDEX,
|
|
36
36
|
seqFrom: HEADER_SEQ,
|
|
37
37
|
seqTo: HEADER_SEQ
|
|
38
|
-
}, sessionKey
|
|
38
|
+
}, sessionKey, compression === 'zstd' ? {
|
|
39
|
+
compression: 'zstd'
|
|
40
|
+
} : {});
|
|
39
41
|
}
|
|
40
42
|
export function openHeader(sealed, ref, sessionId, sessionKey) {
|
|
41
43
|
if (ref.index !== HEADER_INDEX || ref.seqFrom !== HEADER_SEQ || ref.seqTo !== HEADER_SEQ) {
|
|
@@ -46,7 +48,13 @@ export function openHeader(sealed, ref, sessionId, sessionKey) {
|
|
|
46
48
|
index: ref.index,
|
|
47
49
|
seqFrom: ref.seqFrom,
|
|
48
50
|
seqTo: ref.seqTo,
|
|
49
|
-
chainHash: ref.chainHash
|
|
51
|
+
chainHash: ref.chainHash,
|
|
52
|
+
...ref.compression === undefined ? {} : {
|
|
53
|
+
compression: ref.compression
|
|
54
|
+
},
|
|
55
|
+
...ref.compressedHash === undefined ? {} : {
|
|
56
|
+
compressedHash: ref.compressedHash
|
|
57
|
+
}
|
|
50
58
|
}, sessionKey);
|
|
51
59
|
return parseHeaderMeta(JSON.parse(plain.toString('utf8')));
|
|
52
60
|
}
|
|
@@ -108,7 +116,7 @@ export function serializeEventBatch(events) {
|
|
|
108
116
|
export function eventBatchHash(events) {
|
|
109
117
|
return sha256(serializeEventBatch(events));
|
|
110
118
|
}
|
|
111
|
-
export function sealEventBatchFromText(text, sessionId, index, seqFrom, seqTo, sessionKey) {
|
|
119
|
+
export function sealEventBatchFromText(text, sessionId, index, seqFrom, seqTo, sessionKey, compression = 'none') {
|
|
112
120
|
if (index < 1) throw new Error('事件批次的链下标必须 >= 1');
|
|
113
121
|
if (seqFrom > seqTo) throw new Error('事件批次的 seq 区间非法');
|
|
114
122
|
return sealFrame(Buffer.from(text, 'utf8'), {
|
|
@@ -116,15 +124,17 @@ export function sealEventBatchFromText(text, sessionId, index, seqFrom, seqTo, s
|
|
|
116
124
|
index,
|
|
117
125
|
seqFrom,
|
|
118
126
|
seqTo
|
|
119
|
-
}, sessionKey
|
|
127
|
+
}, sessionKey, compression === 'zstd' ? {
|
|
128
|
+
compression: 'zstd'
|
|
129
|
+
} : {});
|
|
120
130
|
}
|
|
121
|
-
export function sealEventBatch(events, sessionId, index, sessionKey) {
|
|
131
|
+
export function sealEventBatch(events, sessionId, index, sessionKey, compression = 'none') {
|
|
122
132
|
if (index < 1) throw new Error('事件批次的链下标必须 >= 1');
|
|
123
133
|
if (events.length === 0) throw new Error('不能封空批次');
|
|
124
134
|
const seqFrom = events[0].seq;
|
|
125
135
|
const seqTo = events[events.length - 1].seq;
|
|
126
136
|
assertContiguous(events, seqFrom, seqTo);
|
|
127
|
-
return sealEventBatchFromText(serializeEventBatch(events), sessionId, index, seqFrom, seqTo, sessionKey);
|
|
137
|
+
return sealEventBatchFromText(serializeEventBatch(events), sessionId, index, seqFrom, seqTo, sessionKey, compression);
|
|
128
138
|
}
|
|
129
139
|
export function openEventBatch(sealed, ref, sessionId, sessionKey) {
|
|
130
140
|
const plain = openSealedFrame(sealed, {
|
|
@@ -132,7 +142,13 @@ export function openEventBatch(sealed, ref, sessionId, sessionKey) {
|
|
|
132
142
|
index: ref.index,
|
|
133
143
|
seqFrom: ref.seqFrom,
|
|
134
144
|
seqTo: ref.seqTo,
|
|
135
|
-
chainHash: ref.chainHash
|
|
145
|
+
chainHash: ref.chainHash,
|
|
146
|
+
...ref.compression === undefined ? {} : {
|
|
147
|
+
compression: ref.compression
|
|
148
|
+
},
|
|
149
|
+
...ref.compressedHash === undefined ? {} : {
|
|
150
|
+
compressedHash: ref.compressedHash
|
|
151
|
+
}
|
|
136
152
|
}, sessionKey);
|
|
137
153
|
const events = parseEvents(JSON.parse(plain.toString('utf8')));
|
|
138
154
|
if (events.length !== ref.seqTo - ref.seqFrom + 1) throw new Error('批次事件数与 seq 区间不符');
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { sha256 } from './crypto.js';
|
|
2
|
+
export const BLOB_BATCH_CONTENT_TYPE = 'application/x-dsh-blob-batch';
|
|
3
|
+
export const BLOB_BATCH_HEADER_BYTES = 12;
|
|
4
|
+
export const BLOB_BATCH_ENTRY_HEADER_BYTES = 40;
|
|
5
|
+
export const MAX_BATCH_BLOBS = 64;
|
|
6
|
+
export const MAX_BATCH_BODY_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
export const MAX_BLOB_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
const MAGIC = Buffer.from('DSHB', 'ascii');
|
|
9
|
+
const VERSION = 1;
|
|
10
|
+
const KIND_TO_CODE = {
|
|
11
|
+
frame: 1,
|
|
12
|
+
attachment: 2,
|
|
13
|
+
vault: 3
|
|
14
|
+
};
|
|
15
|
+
const CODE_TO_KIND = {
|
|
16
|
+
1: 'frame',
|
|
17
|
+
2: 'attachment',
|
|
18
|
+
3: 'vault'
|
|
19
|
+
};
|
|
20
|
+
export function encodeBlobBatch(entries) {
|
|
21
|
+
if (entries.length < 1 || entries.length > MAX_BATCH_BLOBS) {
|
|
22
|
+
throw new Error(`批量 blob 条数必须在 1–${MAX_BATCH_BLOBS} 之间`);
|
|
23
|
+
}
|
|
24
|
+
let total = BLOB_BATCH_HEADER_BYTES;
|
|
25
|
+
for (const entry of entries){
|
|
26
|
+
if (entry.data.length === 0) throw new Error('批量 blob 不能包含空内容');
|
|
27
|
+
if (entry.data.length > MAX_BLOB_BYTES) throw new Error(`单个 blob 超过上限 ${MAX_BLOB_BYTES} 字节`);
|
|
28
|
+
total += BLOB_BATCH_ENTRY_HEADER_BYTES + entry.data.length;
|
|
29
|
+
}
|
|
30
|
+
if (total > MAX_BATCH_BODY_BYTES) throw new Error(`批量 blob 超过上限 ${MAX_BATCH_BODY_BYTES} 字节`);
|
|
31
|
+
const output = Buffer.allocUnsafe(total);
|
|
32
|
+
MAGIC.copy(output, 0);
|
|
33
|
+
output.writeUInt8(VERSION, 4);
|
|
34
|
+
output.writeUInt8(0, 5);
|
|
35
|
+
output.writeUInt16BE(0, 6);
|
|
36
|
+
output.writeUInt32BE(entries.length, 8);
|
|
37
|
+
let offset = BLOB_BATCH_HEADER_BYTES;
|
|
38
|
+
for (const entry of entries){
|
|
39
|
+
const digest = sha256(entry.data);
|
|
40
|
+
output.writeUInt8(KIND_TO_CODE[entry.kind], offset);
|
|
41
|
+
output.fill(0, offset + 1, offset + 4);
|
|
42
|
+
output.writeUInt32BE(entry.data.length, offset + 4);
|
|
43
|
+
Buffer.from(digest, 'hex').copy(output, offset + 8);
|
|
44
|
+
entry.data.copy(output, offset + BLOB_BATCH_ENTRY_HEADER_BYTES);
|
|
45
|
+
offset += BLOB_BATCH_ENTRY_HEADER_BYTES + entry.data.length;
|
|
46
|
+
}
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
export function decodeBlobBatch(bytes) {
|
|
50
|
+
if (bytes.length < BLOB_BATCH_HEADER_BYTES) throw new Error('批量 blob 头被截断');
|
|
51
|
+
if (!bytes.subarray(0, 4).equals(MAGIC)) throw new Error('不是 DSHB 批量 blob');
|
|
52
|
+
if (bytes.readUInt8(4) !== VERSION) throw new Error(`不支持的批量 blob 版本:${bytes.readUInt8(4)}`);
|
|
53
|
+
if (bytes.readUInt8(5) !== 0) throw new Error('批量 blob flags 非零');
|
|
54
|
+
if (bytes.readUInt16BE(6) !== 0) throw new Error('批量 blob reserved 非零');
|
|
55
|
+
const count = bytes.readUInt32BE(8);
|
|
56
|
+
if (count < 1 || count > MAX_BATCH_BLOBS) throw new Error(`批量 blob 条数非法:${count}`);
|
|
57
|
+
const entries = [];
|
|
58
|
+
let offset = BLOB_BATCH_HEADER_BYTES;
|
|
59
|
+
for(let index = 0; index < count; index += 1){
|
|
60
|
+
if (offset + BLOB_BATCH_ENTRY_HEADER_BYTES > bytes.length) throw new Error(`批量 blob 第 ${index} 条头被截断`);
|
|
61
|
+
const kind = CODE_TO_KIND[bytes.readUInt8(offset)];
|
|
62
|
+
if (kind === undefined) throw new Error(`批量 blob 第 ${index} 条类别非法`);
|
|
63
|
+
if (bytes.readUInt8(offset + 1) !== 0 || bytes.readUInt8(offset + 2) !== 0 || bytes.readUInt8(offset + 3) !== 0) {
|
|
64
|
+
throw new Error(`批量 blob 第 ${index} 条 reserved 非零`);
|
|
65
|
+
}
|
|
66
|
+
const length = bytes.readUInt32BE(offset + 4);
|
|
67
|
+
if (length === 0) throw new Error(`批量 blob 第 ${index} 条为空`);
|
|
68
|
+
if (length > MAX_BLOB_BYTES) throw new Error(`批量 blob 第 ${index} 条超过单 blob 上限`);
|
|
69
|
+
const start = offset + BLOB_BATCH_ENTRY_HEADER_BYTES;
|
|
70
|
+
const end = start + length;
|
|
71
|
+
if (end > bytes.length) throw new Error(`批量 blob 第 ${index} 条数据被截断`);
|
|
72
|
+
entries.push({
|
|
73
|
+
kind,
|
|
74
|
+
sha256: bytes.subarray(offset + 8, offset + 8 + 32).toString('hex'),
|
|
75
|
+
data: bytes.subarray(start, end)
|
|
76
|
+
});
|
|
77
|
+
offset = end;
|
|
78
|
+
}
|
|
79
|
+
if (offset !== bytes.length) throw new Error('批量 blob 尾部有多余字节');
|
|
80
|
+
if (bytes.length > MAX_BATCH_BODY_BYTES) throw new Error(`批量 blob 超过上限 ${MAX_BATCH_BODY_BYTES} 字节`);
|
|
81
|
+
return entries;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
//# sourceURL=blob-batch.ts
|
package/dist/shared/chain.js
CHANGED
|
@@ -90,6 +90,15 @@ export function assertManifest(manifest) {
|
|
|
90
90
|
manifest.frames.forEach((frame, position)=>{
|
|
91
91
|
if (frame.index !== position) throw new Error(`manifest 第 ${position} 条的 index 是 ${frame.index}`);
|
|
92
92
|
if (!HASH_PATTERN.test(frame.chainHash) || !HASH_PATTERN.test(frame.blobSha256)) throw new Error(`manifest 第 ${position} 条的哈希非法`);
|
|
93
|
+
if (frame.compression === undefined) {
|
|
94
|
+
if (frame.sealedVersion !== 1 || frame.compressedHash !== undefined) throw new Error(`manifest 第 ${position} 条缺少压缩元数据`);
|
|
95
|
+
} else {
|
|
96
|
+
if (frame.compression !== 'zstd') throw new Error(`manifest 第 ${position} 条的压缩方式非法`);
|
|
97
|
+
if (frame.sealedVersion < 2) throw new Error(`manifest 第 ${position} 条的压缩帧版本非法`);
|
|
98
|
+
if (frame.compressedHash === undefined || !HASH_PATTERN.test(frame.compressedHash)) {
|
|
99
|
+
throw new Error(`manifest 第 ${position} 条的 compressedHash 非法`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
93
102
|
});
|
|
94
103
|
const last = manifest.frames[manifest.frames.length - 1];
|
|
95
104
|
if ((last === undefined ? -1 : last.seqTo) !== manifest.lastSeq) throw new Error('manifest 的 lastSeq 与最后一帧不一致');
|
|
@@ -129,7 +138,11 @@ export function manifestDigest(manifest) {
|
|
|
129
138
|
sealedVersion: frame.sealedVersion,
|
|
130
139
|
keyId: frame.keyId,
|
|
131
140
|
seqFrom: frame.seqFrom,
|
|
132
|
-
seqTo: frame.seqTo
|
|
141
|
+
seqTo: frame.seqTo,
|
|
142
|
+
...frame.compression === undefined ? {} : {
|
|
143
|
+
compression: frame.compression,
|
|
144
|
+
compressedHash: frame.compressedHash ?? null
|
|
145
|
+
}
|
|
133
146
|
})),
|
|
134
147
|
deletedAt: manifest.deletedAt ?? null
|
|
135
148
|
}));
|
package/dist/shared/index.js
CHANGED
package/dist/shared/protocol.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
1
2
|
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
3
|
import { deriveSubkey, sha256, TAG_BYTES, KEY_BYTES, NONCE_BYTES } from './crypto.js';
|
|
3
4
|
export const SEALED_MAGIC = Buffer.from('DSHF', 'ascii');
|
|
4
5
|
export const SEALED_VERSION = 1;
|
|
6
|
+
export const SEALED_VERSION_ZSTD = 2;
|
|
5
7
|
export const KEY_ID_BYTES = 8;
|
|
6
8
|
export const SEALED_HEADER_BYTES = 4 + 1 + 1 + KEY_ID_BYTES + NONCE_BYTES;
|
|
7
9
|
const FIXED_NONCE = Buffer.alloc(NONCE_BYTES);
|
|
8
10
|
export function sessionKeyOf(master, sessionId) {
|
|
9
11
|
return deriveSubkey(master, `v1|session:${sessionId}`);
|
|
10
12
|
}
|
|
11
|
-
export function frameKeyOf(sessionKey, chainHash) {
|
|
13
|
+
export function frameKeyOf(sessionKey, chainHash, compression = 'none', compressedHash) {
|
|
14
|
+
if (compression === 'zstd') {
|
|
15
|
+
if (compressedHash === undefined || !/^[0-9a-f]{64}$/u.test(compressedHash)) {
|
|
16
|
+
throw new Error('zstd 帧缺少合法的 compressedHash');
|
|
17
|
+
}
|
|
18
|
+
return deriveSubkey(sessionKey, `v2|frame:zstd:${compressedHash}:${chainHash}`);
|
|
19
|
+
}
|
|
12
20
|
return deriveSubkey(sessionKey, `v1|frame:${chainHash}`);
|
|
13
21
|
}
|
|
14
22
|
export function keyIdOf(sessionKey) {
|
|
@@ -34,15 +42,19 @@ function buildAad(header, position) {
|
|
|
34
42
|
tail
|
|
35
43
|
]);
|
|
36
44
|
}
|
|
37
|
-
export function sealFrame(plain, position, sessionKey) {
|
|
45
|
+
export function sealFrame(plain, position, sessionKey, options = {}) {
|
|
38
46
|
if (sessionKey.length !== KEY_BYTES) throw new Error(`会话密钥必须是 ${KEY_BYTES} 字节`);
|
|
39
47
|
if (!Number.isSafeInteger(position.index) || position.index < 0) throw new Error('帧下标必须是非负整数');
|
|
48
|
+
const compression = options.compression ?? 'none';
|
|
40
49
|
const chainHash = sha256(plain);
|
|
41
|
-
const
|
|
50
|
+
const payload = compression === 'zstd' ? zstdCompressSync(plain) : plain;
|
|
51
|
+
const compressedHash = compression === 'zstd' ? sha256(payload) : undefined;
|
|
52
|
+
const frameKey = frameKeyOf(sessionKey, chainHash, compression, compressedHash);
|
|
42
53
|
const keyId = keyIdOf(sessionKey);
|
|
54
|
+
const version = compression === 'zstd' ? SEALED_VERSION_ZSTD : SEALED_VERSION;
|
|
43
55
|
const header = Buffer.alloc(SEALED_HEADER_BYTES);
|
|
44
56
|
SEALED_MAGIC.copy(header, 0);
|
|
45
|
-
header.writeUInt8(
|
|
57
|
+
header.writeUInt8(version, 4);
|
|
46
58
|
header.writeUInt8(0, 5);
|
|
47
59
|
keyId.copy(header, 6);
|
|
48
60
|
FIXED_NONCE.copy(header, 14);
|
|
@@ -53,7 +65,7 @@ export function sealFrame(plain, position, sessionKey) {
|
|
|
53
65
|
const cipher = createCipheriv('aes-256-gcm', frameKey, FIXED_NONCE);
|
|
54
66
|
cipher.setAAD(aad);
|
|
55
67
|
const body = Buffer.concat([
|
|
56
|
-
cipher.update(
|
|
68
|
+
cipher.update(payload),
|
|
57
69
|
cipher.final()
|
|
58
70
|
]);
|
|
59
71
|
const bytes = Buffer.concat([
|
|
@@ -68,10 +80,14 @@ export function sealFrame(plain, position, sessionKey) {
|
|
|
68
80
|
chainHash,
|
|
69
81
|
blobSha256: sha256(bytes),
|
|
70
82
|
bytes: bytes.length,
|
|
71
|
-
sealedVersion:
|
|
83
|
+
sealedVersion: version,
|
|
72
84
|
keyId: keyId.toString('hex'),
|
|
73
85
|
seqFrom: position.seqFrom,
|
|
74
|
-
seqTo: position.seqTo
|
|
86
|
+
seqTo: position.seqTo,
|
|
87
|
+
...compression === 'zstd' ? {
|
|
88
|
+
compression: 'zstd',
|
|
89
|
+
compressedHash: compressedHash
|
|
90
|
+
} : {}
|
|
75
91
|
}
|
|
76
92
|
};
|
|
77
93
|
}
|
|
@@ -79,7 +95,9 @@ export function parseSealedHeader(sealed) {
|
|
|
79
95
|
if (sealed.length < SEALED_HEADER_BYTES + TAG_BYTES) throw new Error('封帧字节被截断');
|
|
80
96
|
if (!sealed.subarray(0, 4).equals(SEALED_MAGIC)) throw new Error('不是 DSHF 封帧');
|
|
81
97
|
const version = sealed.readUInt8(4);
|
|
82
|
-
if (version !== SEALED_VERSION
|
|
98
|
+
if (version !== SEALED_VERSION && version !== SEALED_VERSION_ZSTD) {
|
|
99
|
+
throw new Error(`不支持的封帧版本:${version}`);
|
|
100
|
+
}
|
|
83
101
|
return {
|
|
84
102
|
version,
|
|
85
103
|
flags: sealed.readUInt8(5),
|
|
@@ -93,25 +111,44 @@ export function openSealedFrame(sealed, position, sessionKey) {
|
|
|
93
111
|
if (!header.keyId.equals(expectedKeyId)) {
|
|
94
112
|
throw new Error('封帧的 keyId 与当前密钥不符(请确认各设备同步口令、服务端地址与 salt 一致)');
|
|
95
113
|
}
|
|
114
|
+
const compression = header.version === SEALED_VERSION_ZSTD ? 'zstd' : 'none';
|
|
115
|
+
if (compression === 'zstd') {
|
|
116
|
+
if (position.compression !== 'zstd' || position.compressedHash === undefined) {
|
|
117
|
+
throw new Error('zstd 封帧缺少压缩元数据');
|
|
118
|
+
}
|
|
119
|
+
} else if (position.compression !== undefined || position.compressedHash !== undefined) {
|
|
120
|
+
throw new Error('未压缩封帧不应带压缩元数据');
|
|
121
|
+
}
|
|
96
122
|
const headerBytes = sealed.subarray(0, SEALED_HEADER_BYTES);
|
|
97
123
|
const aad = buildAad(headerBytes, position);
|
|
98
124
|
const body = sealed.subarray(SEALED_HEADER_BYTES, sealed.length - TAG_BYTES);
|
|
99
125
|
const tag = sealed.subarray(sealed.length - TAG_BYTES);
|
|
100
|
-
const frameKey = frameKeyOf(sessionKey, position.chainHash);
|
|
126
|
+
const frameKey = frameKeyOf(sessionKey, position.chainHash, compression, position.compressedHash);
|
|
101
127
|
const decipher = createDecipheriv('aes-256-gcm', frameKey, header.nonce);
|
|
102
128
|
decipher.setAAD(aad);
|
|
103
129
|
decipher.setAuthTag(tag);
|
|
104
|
-
let
|
|
130
|
+
let payload;
|
|
105
131
|
try {
|
|
106
|
-
|
|
132
|
+
payload = Buffer.concat([
|
|
107
133
|
decipher.update(body),
|
|
108
134
|
decipher.final()
|
|
109
135
|
]);
|
|
110
136
|
} catch {
|
|
111
137
|
throw new Error('封帧认证失败(密文、AAD 或密钥不匹配)');
|
|
112
138
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
139
|
+
if (compression === 'zstd') {
|
|
140
|
+
if (sha256(payload) !== position.compressedHash) throw new Error('压缩明文哈希与清单不符');
|
|
141
|
+
let plain;
|
|
142
|
+
try {
|
|
143
|
+
plain = zstdDecompressSync(payload);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error('zstd 解压失败');
|
|
146
|
+
}
|
|
147
|
+
if (sha256(plain) !== position.chainHash) throw new Error('解压后的明文哈希与清单不符');
|
|
148
|
+
return plain;
|
|
149
|
+
}
|
|
150
|
+
if (sha256(payload) !== position.chainHash) throw new Error('明文哈希与清单不符');
|
|
151
|
+
return payload;
|
|
115
152
|
}
|
|
116
153
|
|
|
117
154
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dscs-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "DSH Session CloudSync self-hosted server.",
|
|
7
7
|
"license": "AGPL-3.0-only",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/xfy2412/dsh-session-cloudsync.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/xfy2412/dsh-session-cloudsync#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/xfy2412/dsh-session-cloudsync/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"session",
|
|
21
|
+
"sync",
|
|
22
|
+
"self-hosted",
|
|
23
|
+
"e2ee",
|
|
24
|
+
"sqlite"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
8
29
|
"bin": {
|
|
9
|
-
"dscs-server": "
|
|
30
|
+
"dscs-server": "dist/index.js"
|
|
10
31
|
},
|
|
11
32
|
"files": [
|
|
12
33
|
"dist",
|
|
13
34
|
"web",
|
|
35
|
+
"assets",
|
|
14
36
|
"LICENSE",
|
|
15
37
|
"README.md"
|
|
16
38
|
],
|