dscs-server 0.0.1 → 0.1.0

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/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
- 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)
107
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
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`).run(DEFAULT_DATASET, input.sessionId, createdAt, formatVersion, manifest.lastSeq, combined.length, headChainHash, JSON.stringify(manifest), digest, revision, now);
113
- return {
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,
@@ -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
@@ -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
  }));
@@ -1,4 +1,5 @@
1
1
  export * from './batches.js';
2
+ export * from './blob-batch.js';
2
3
  export * from './chain.js';
3
4
  export * from './crypto.js';
4
5
  export * from './frames.js';
@@ -1,5 +1,5 @@
1
- export const API_PREFIX = '/v1';
2
- export const PROTOCOL_VERSION = 2;
1
+ export const API_PREFIX = '/v2';
2
+ export const PROTOCOL_VERSION = 3;
3
3
  export function errorBody(code, message) {
4
4
  return {
5
5
  ok: false,
@@ -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 frameKey = frameKeyOf(sessionKey, chainHash);
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(SEALED_VERSION, 4);
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(plain),
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: SEALED_VERSION,
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) throw new Error(`不支持的封帧版本:${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 plain;
130
+ let payload;
105
131
  try {
106
- plain = Buffer.concat([
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 (sha256(plain) !== position.chainHash) throw new Error('明文哈希与清单不符');
114
- return plain;
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,12 +1,33 @@
1
1
  {
2
2
  "name": "dscs-server",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
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": "./dist/index.js"
30
+ "dscs-server": "dist/index.js"
10
31
  },
11
32
  "files": [
12
33
  "dist",