dscs-server 0.1.1 → 0.1.3
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 +2 -2
- package/dist/http.js +93 -2
- package/dist/sessions.js +37 -4
- package/dist/shared/chain.js +21 -0
- package/package.json +1 -1
- package/web/admin.html +27 -2
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
DSH Session CloudSync 的自部署服务端。
|
|
4
4
|
|
|
5
|
-
当前版本:**0.1.
|
|
6
|
-
旧的 `/v1/*` 已移除;0.0.x
|
|
5
|
+
当前版本:**0.1.3,v2-only**(`PROTOCOL_VERSION = 3`)。设备 API 只有 `/v2/*`,
|
|
6
|
+
旧的 `/v1/*` 已移除;0.0.x 插件不兼容,需要 0.1.0 起的插件。
|
|
7
7
|
|
|
8
8
|
## 能力
|
|
9
9
|
|
package/dist/http.js
CHANGED
|
@@ -223,6 +223,15 @@ function parseAppend(body) {
|
|
|
223
223
|
if (body.workspaceKey !== undefined && (typeof body.workspaceKey !== 'string' || !/^[A-Za-z0-9_-]{16,64}$/u.test(body.workspaceKey))) {
|
|
224
224
|
throw new SyncError('bad-request', 'workspaceKey 非法');
|
|
225
225
|
}
|
|
226
|
+
if (body.parentSession !== undefined && (typeof body.parentSession !== 'string' || body.parentSession === '')) {
|
|
227
|
+
throw new SyncError('bad-request', 'parentSession 必须是非空字符串');
|
|
228
|
+
}
|
|
229
|
+
if (body.origin !== undefined && (typeof body.origin !== 'string' || body.origin === '')) {
|
|
230
|
+
throw new SyncError('bad-request', 'origin 必须是非空字符串');
|
|
231
|
+
}
|
|
232
|
+
if (body.delegationDepth !== undefined && (!Number.isSafeInteger(body.delegationDepth) || Number(body.delegationDepth) < 0)) {
|
|
233
|
+
throw new SyncError('bad-request', 'delegationDepth 必须是非负整数');
|
|
234
|
+
}
|
|
226
235
|
return {
|
|
227
236
|
baseIndex: Number(body.baseIndex),
|
|
228
237
|
baseChainHash: baseChainHash,
|
|
@@ -237,6 +246,15 @@ function parseAppend(body) {
|
|
|
237
246
|
},
|
|
238
247
|
...body.workspaceKey === undefined ? {} : {
|
|
239
248
|
workspaceKey: body.workspaceKey
|
|
249
|
+
},
|
|
250
|
+
...body.parentSession === undefined ? {} : {
|
|
251
|
+
parentSession: body.parentSession
|
|
252
|
+
},
|
|
253
|
+
...body.origin === undefined ? {} : {
|
|
254
|
+
origin: body.origin
|
|
255
|
+
},
|
|
256
|
+
...body.delegationDepth === undefined ? {} : {
|
|
257
|
+
delegationDepth: Number(body.delegationDepth)
|
|
240
258
|
}
|
|
241
259
|
};
|
|
242
260
|
}
|
|
@@ -357,6 +375,26 @@ export function revokeDevice(db, deviceId) {
|
|
|
357
375
|
const result = db.handle.prepare('UPDATE devices SET revoked_at = ? WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL').run(Date.now(), DEFAULT_DATASET, deviceId);
|
|
358
376
|
return Number(result.changes) > 0;
|
|
359
377
|
}
|
|
378
|
+
export function renameDevice(db, deviceId, label) {
|
|
379
|
+
const result = db.handle.prepare('UPDATE devices SET label = ? WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL').run(label, DEFAULT_DATASET, deviceId);
|
|
380
|
+
if (Number(result.changes) === 0) return undefined;
|
|
381
|
+
const row = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at FROM devices WHERE dataset = ? AND device_id = ?').get(DEFAULT_DATASET, deviceId);
|
|
382
|
+
if (row === undefined) return undefined;
|
|
383
|
+
return {
|
|
384
|
+
deviceId: row.device_id,
|
|
385
|
+
label: row.label,
|
|
386
|
+
createdAt: row.created_at,
|
|
387
|
+
...row.last_seen_at === null ? {} : {
|
|
388
|
+
lastSeenAt: row.last_seen_at
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function requireDeviceLabel(value) {
|
|
393
|
+
const label = typeof value === 'string' ? value.trim() : '';
|
|
394
|
+
if (label === '') throw new SyncError('bad-request', '设备名不能为空');
|
|
395
|
+
if (label.length > 64) throw new SyncError('bad-request', '设备名最长 64 个字符');
|
|
396
|
+
return label;
|
|
397
|
+
}
|
|
360
398
|
export function createHttpServer(options) {
|
|
361
399
|
const { db } = options;
|
|
362
400
|
const version = options.version ?? SERVER_VERSION;
|
|
@@ -445,7 +483,10 @@ export function createHttpServer(options) {
|
|
|
445
483
|
'batches',
|
|
446
484
|
'batch-upload',
|
|
447
485
|
'batch-download',
|
|
448
|
-
'zstd-frame'
|
|
486
|
+
'zstd-frame',
|
|
487
|
+
'me-device-info',
|
|
488
|
+
'me-counts',
|
|
489
|
+
'device-rename'
|
|
449
490
|
],
|
|
450
491
|
limits: {
|
|
451
492
|
batchBytes: MAX_BATCH_BODY_BYTES,
|
|
@@ -1175,6 +1216,25 @@ export function createHttpServer(options) {
|
|
|
1175
1216
|
}) : json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1176
1217
|
}
|
|
1177
1218
|
},
|
|
1219
|
+
{
|
|
1220
|
+
method: 'PATCH',
|
|
1221
|
+
path: ADMIN_DEVICE_PATH,
|
|
1222
|
+
handler: async (req, res)=>{
|
|
1223
|
+
requireAdmin(req);
|
|
1224
|
+
const match = ADMIN_DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1225
|
+
if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1226
|
+
const body = await readJson(req);
|
|
1227
|
+
const label = requireDeviceLabel(body.label);
|
|
1228
|
+
const device = renameDevice(db, match[1], label);
|
|
1229
|
+
if (device === undefined) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1230
|
+
return json(res, 200, {
|
|
1231
|
+
ok: true,
|
|
1232
|
+
value: {
|
|
1233
|
+
device
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1178
1238
|
{
|
|
1179
1239
|
method: 'GET',
|
|
1180
1240
|
path: /^\/admin\/sessions$/u,
|
|
@@ -1198,11 +1258,42 @@ export function createHttpServer(options) {
|
|
|
1198
1258
|
role: 'admin'
|
|
1199
1259
|
}
|
|
1200
1260
|
});
|
|
1261
|
+
const counts = sessionCounts(db);
|
|
1262
|
+
const workspaceRow = db.handle.prepare("SELECT COUNT(DISTINCT json_extract(manifest_json, '$.workspaceKey')) AS n FROM sessions WHERE dataset = ? AND deleted_at IS NULL AND json_extract(manifest_json, '$.workspaceKey') IS NOT NULL").get(DEFAULT_DATASET);
|
|
1201
1263
|
return json(res, 200, {
|
|
1202
1264
|
ok: true,
|
|
1203
1265
|
value: {
|
|
1204
1266
|
role: 'device',
|
|
1205
|
-
device: auth.device
|
|
1267
|
+
device: auth.device,
|
|
1268
|
+
server: {
|
|
1269
|
+
version,
|
|
1270
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1271
|
+
quotaBytes: effectiveMaxBytes(),
|
|
1272
|
+
usedBytes: usedBlobBytes(db)
|
|
1273
|
+
},
|
|
1274
|
+
counts: {
|
|
1275
|
+
workspaces: Number(workspaceRow?.n ?? 0),
|
|
1276
|
+
sessions: counts.total - counts.deleted
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
},
|
|
1282
|
+
{
|
|
1283
|
+
method: 'PATCH',
|
|
1284
|
+
path: ME_PATH,
|
|
1285
|
+
handler: async (req, res)=>{
|
|
1286
|
+
const auth = authenticate(db, req);
|
|
1287
|
+
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '未知 token'));
|
|
1288
|
+
if (auth.role !== 'device') return json(res, 401, errorBody('unauthorized', '需要设备 token'));
|
|
1289
|
+
const body = await readJson(req);
|
|
1290
|
+
const label = requireDeviceLabel(body.label);
|
|
1291
|
+
const device = renameDevice(db, auth.device.deviceId, label);
|
|
1292
|
+
if (device === undefined) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1293
|
+
return json(res, 200, {
|
|
1294
|
+
ok: true,
|
|
1295
|
+
value: {
|
|
1296
|
+
device
|
|
1206
1297
|
}
|
|
1207
1298
|
});
|
|
1208
1299
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -39,7 +39,7 @@ function toChain(frames) {
|
|
|
39
39
|
function sessionRow(db, sessionId) {
|
|
40
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);
|
|
41
41
|
}
|
|
42
|
-
function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey) {
|
|
42
|
+
function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth) {
|
|
43
43
|
assertChain(toChain(frames));
|
|
44
44
|
const manifest = buildManifest({
|
|
45
45
|
sessionId,
|
|
@@ -55,6 +55,15 @@ function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tail
|
|
|
55
55
|
},
|
|
56
56
|
...workspaceKey === undefined ? {} : {
|
|
57
57
|
workspaceKey
|
|
58
|
+
},
|
|
59
|
+
...parentSession === undefined ? {} : {
|
|
60
|
+
parentSession
|
|
61
|
+
},
|
|
62
|
+
...origin === undefined ? {} : {
|
|
63
|
+
origin
|
|
64
|
+
},
|
|
65
|
+
...delegationDepth === undefined ? {} : {
|
|
66
|
+
delegationDepth
|
|
58
67
|
}
|
|
59
68
|
});
|
|
60
69
|
return {
|
|
@@ -88,7 +97,10 @@ function appendRequestHash(input) {
|
|
|
88
97
|
frames: input.frames,
|
|
89
98
|
tailOpen: input.tailOpen ?? null,
|
|
90
99
|
safeThroughSeq: input.safeThroughSeq ?? null,
|
|
91
|
-
workspaceKey: input.workspaceKey ?? null
|
|
100
|
+
workspaceKey: input.workspaceKey ?? null,
|
|
101
|
+
parentSession: input.parentSession ?? null,
|
|
102
|
+
origin: input.origin ?? null,
|
|
103
|
+
delegationDepth: input.delegationDepth ?? null
|
|
92
104
|
}));
|
|
93
105
|
}
|
|
94
106
|
export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
@@ -110,6 +122,9 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
|
110
122
|
let formatVersion = input.formatVersion;
|
|
111
123
|
let createdAt = input.createdAt;
|
|
112
124
|
let workspaceKey = input.workspaceKey;
|
|
125
|
+
let parentSession = input.parentSession;
|
|
126
|
+
let origin = input.origin;
|
|
127
|
+
let delegationDepth = input.delegationDepth;
|
|
113
128
|
if (row !== undefined && row.deleted_at !== null && row.created_at === input.createdAt) {
|
|
114
129
|
throw new SyncError('gone', `会话 ${input.sessionId} 的这个生命周期已被删除`);
|
|
115
130
|
}
|
|
@@ -134,6 +149,18 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
|
134
149
|
if (workspaceKey !== undefined && existing.workspaceKey === undefined) {
|
|
135
150
|
throw new SyncError('conflict', '已有会话缺少 workspaceKey,不能在后续 append 补写');
|
|
136
151
|
}
|
|
152
|
+
if (parentSession !== undefined && existing.parentSession !== undefined && parentSession !== existing.parentSession) {
|
|
153
|
+
throw new SyncError('conflict', 'append 的 parentSession 与已存 manifest 不一致');
|
|
154
|
+
}
|
|
155
|
+
if (origin !== undefined && existing.origin !== undefined && origin !== existing.origin) {
|
|
156
|
+
throw new SyncError('conflict', 'append 的 origin 与已存 manifest 不一致');
|
|
157
|
+
}
|
|
158
|
+
if (delegationDepth !== undefined && existing.delegationDepth !== undefined && delegationDepth !== existing.delegationDepth) {
|
|
159
|
+
throw new SyncError('conflict', 'append 的 delegationDepth 与已存 manifest 不一致');
|
|
160
|
+
}
|
|
161
|
+
if (parentSession === undefined) parentSession = existing.parentSession;
|
|
162
|
+
if (origin === undefined) origin = existing.origin;
|
|
163
|
+
if (delegationDepth === undefined) delegationDepth = existing.delegationDepth;
|
|
137
164
|
const start = input.baseIndex + 1;
|
|
138
165
|
if (start < 0 || start > existing.frames.length) throw new SyncError('diverged', `baseIndex=${input.baseIndex} 超出链长 ${existing.frames.length}`);
|
|
139
166
|
if (start < existing.frames.length - 1) throw new SyncError('diverged', '只能追加或替换尾帧');
|
|
@@ -153,7 +180,7 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
|
153
180
|
if (!existsSync(blobPath(dataDir, frame.blobSha256))) throw new SyncError('bad-request', `帧 ${frame.index} 的 blob 尚未上传`);
|
|
154
181
|
}
|
|
155
182
|
input.frames.forEach((frame, offset)=>assertRef(frame, (row === undefined || replacingLifecycle ? 0 : input.baseIndex + 1) + offset));
|
|
156
|
-
const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey);
|
|
183
|
+
const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth);
|
|
157
184
|
const revision = bumpRevision(db);
|
|
158
185
|
const stats = statsOf(combined);
|
|
159
186
|
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)
|
|
@@ -229,7 +256,10 @@ const V2_SUMMARY_SELECT = `s.session_id, s.created_at, s.format_version, s.last_
|
|
|
229
256
|
s.compression, s.stored_bytes,
|
|
230
257
|
json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key,
|
|
231
258
|
json_extract(s.manifest_json, '$.tailOpen') AS tail_open,
|
|
232
|
-
json_extract(s.manifest_json, '$.safeThroughSeq') AS safe_through_seq
|
|
259
|
+
json_extract(s.manifest_json, '$.safeThroughSeq') AS safe_through_seq,
|
|
260
|
+
json_extract(s.manifest_json, '$.parentSession') AS parent_session,
|
|
261
|
+
json_extract(s.manifest_json, '$.origin') AS origin,
|
|
262
|
+
json_extract(s.manifest_json, '$.delegationDepth') AS delegation_depth`;
|
|
233
263
|
function mapV2Summary(row) {
|
|
234
264
|
const safe = typeof row.safe_through_seq === 'number' && Number.isSafeInteger(row.safe_through_seq) ? row.safe_through_seq : row.last_seq;
|
|
235
265
|
return {
|
|
@@ -244,6 +274,9 @@ function mapV2Summary(row) {
|
|
|
244
274
|
workspaceKey: typeof row.workspace_key === 'string' && row.workspace_key !== '' ? row.workspace_key : null,
|
|
245
275
|
safeThroughSeq: safe,
|
|
246
276
|
tailOpen: Number(row.tail_open) === 1,
|
|
277
|
+
parentSession: typeof row.parent_session === 'string' && row.parent_session !== '' ? row.parent_session : null,
|
|
278
|
+
origin: typeof row.origin === 'string' && row.origin !== '' ? row.origin : null,
|
|
279
|
+
delegationDepth: typeof row.delegation_depth === 'number' && Number.isSafeInteger(row.delegation_depth) ? row.delegation_depth : null,
|
|
247
280
|
updatedAt: row.updated_at,
|
|
248
281
|
deletedAt: row.deleted_at,
|
|
249
282
|
compression: compressionOf(row.compression),
|
package/dist/shared/chain.js
CHANGED
|
@@ -82,6 +82,15 @@ export function buildManifest(input) {
|
|
|
82
82
|
...input.safeThroughSeq === undefined ? {} : {
|
|
83
83
|
safeThroughSeq: input.safeThroughSeq
|
|
84
84
|
},
|
|
85
|
+
...input.parentSession === undefined ? {} : {
|
|
86
|
+
parentSession: input.parentSession
|
|
87
|
+
},
|
|
88
|
+
...input.origin === undefined ? {} : {
|
|
89
|
+
origin: input.origin
|
|
90
|
+
},
|
|
91
|
+
...input.delegationDepth === undefined ? {} : {
|
|
92
|
+
delegationDepth: input.delegationDepth
|
|
93
|
+
},
|
|
85
94
|
frames: input.frames,
|
|
86
95
|
updatedAt: input.updatedAt
|
|
87
96
|
};
|
|
@@ -108,6 +117,15 @@ export function assertManifest(manifest) {
|
|
|
108
117
|
throw new Error('manifest 的 workspaceKey 非法');
|
|
109
118
|
}
|
|
110
119
|
if (manifest.tailOpen !== undefined && typeof manifest.tailOpen !== 'boolean') throw new Error('manifest 的 tailOpen 非法');
|
|
120
|
+
if (manifest.parentSession !== undefined && (typeof manifest.parentSession !== 'string' || manifest.parentSession === '')) {
|
|
121
|
+
throw new Error('manifest 的 parentSession 非法');
|
|
122
|
+
}
|
|
123
|
+
if (manifest.origin !== undefined && (typeof manifest.origin !== 'string' || manifest.origin === '')) {
|
|
124
|
+
throw new Error('manifest 的 origin 非法');
|
|
125
|
+
}
|
|
126
|
+
if (manifest.delegationDepth !== undefined && (!Number.isSafeInteger(manifest.delegationDepth) || manifest.delegationDepth < 0)) {
|
|
127
|
+
throw new Error('manifest 的 delegationDepth 非法');
|
|
128
|
+
}
|
|
111
129
|
}
|
|
112
130
|
function canonical(value) {
|
|
113
131
|
if (Array.isArray(value)) return value.map((entry)=>canonical(entry));
|
|
@@ -121,6 +139,9 @@ function canonical(value) {
|
|
|
121
139
|
export function stableStringify(value) {
|
|
122
140
|
return JSON.stringify(canonical(value));
|
|
123
141
|
}
|
|
142
|
+
export function markerEquivalent(local, remote) {
|
|
143
|
+
return local.type === 'session/end-seed' && remote.type === 'session/end-seed' && stableStringify(local.data) === stableStringify(remote.data);
|
|
144
|
+
}
|
|
124
145
|
export function manifestDigest(manifest) {
|
|
125
146
|
return sha256(stableStringify({
|
|
126
147
|
sessionId: manifest.sessionId,
|
package/package.json
CHANGED
package/web/admin.html
CHANGED
|
@@ -380,7 +380,7 @@
|
|
|
380
380
|
<div>
|
|
381
381
|
<span class="eyebrow">Overview</span>
|
|
382
382
|
<h2>同步概览</h2>
|
|
383
|
-
<p class="muted"
|
|
383
|
+
<p class="muted">自部署节点、设备与会话视图;设备可签发、改名、吊销。</p>
|
|
384
384
|
</div>
|
|
385
385
|
<div class="health"><i></i><span id="healthText">服务正常</span></div>
|
|
386
386
|
</section>
|
|
@@ -604,6 +604,31 @@ function renderDevices(devices) {
|
|
|
604
604
|
)
|
|
605
605
|
main.append(label, idLine, meta)
|
|
606
606
|
const actions = el('div', 'device-actions')
|
|
607
|
+
const rename = el('button', 'btn small', '改名')
|
|
608
|
+
rename.type = 'button'
|
|
609
|
+
rename.onclick = async () => {
|
|
610
|
+
const next = prompt('新设备名(1–64 个字符)', device.label || 'device')
|
|
611
|
+
if (next === null) return
|
|
612
|
+
const label = next.trim()
|
|
613
|
+
if (label === '') {
|
|
614
|
+
showToast('设备名不能为空', true)
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
rename.disabled = true
|
|
618
|
+
rename.textContent = '改名中…'
|
|
619
|
+
try {
|
|
620
|
+
await api('/admin/devices/' + encodeURIComponent(device.deviceId), {
|
|
621
|
+
method: 'PATCH',
|
|
622
|
+
body: JSON.stringify({ label }),
|
|
623
|
+
})
|
|
624
|
+
showToast('设备已改名')
|
|
625
|
+
await refresh(true)
|
|
626
|
+
} catch (error) {
|
|
627
|
+
showToast(error.message, true)
|
|
628
|
+
rename.disabled = false
|
|
629
|
+
rename.textContent = '改名'
|
|
630
|
+
}
|
|
631
|
+
}
|
|
607
632
|
const revoke = el('button', 'btn small danger', '吊销')
|
|
608
633
|
revoke.type = 'button'
|
|
609
634
|
revoke.onclick = async () => {
|
|
@@ -620,7 +645,7 @@ function renderDevices(devices) {
|
|
|
620
645
|
revoke.textContent = '吊销'
|
|
621
646
|
}
|
|
622
647
|
}
|
|
623
|
-
actions.append(revoke)
|
|
648
|
+
actions.append(rename, revoke)
|
|
624
649
|
row.append(main, actions)
|
|
625
650
|
return row
|
|
626
651
|
}))
|