dscs-server 0.0.0-stage → 0.0.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 CHANGED
@@ -1,3 +1,26 @@
1
- # Temporary Holding Version
1
+ # dscs-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH Session CloudSync 的自部署服务端。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ npm i -g dscs-server
9
+ dscs-server serve --data-dir /var/lib/dscs --host 127.0.0.1 --port 8787
10
+ ```
11
+
12
+ 首次启动会在控制台打印一次性初始化密钥,同时写入数据目录的 `onboarding-code.json`。打开 `http://127.0.0.1:8787/admin` 完成管理密码、配额和设备初始化。
13
+
14
+ Linux/macOS 也可以使用仓库的 `scripts/install.sh`;Windows 使用 `scripts/install.ps1`。安装脚本会把版本装到独立目录,并可选注册 systemd 或计划任务。
15
+
16
+ ## 要求
17
+
18
+ - Node.js >= 24;
19
+ - 默认只监听 `127.0.0.1`,公网部署请放在 TLS 反代后;
20
+ - 数据目录包含 SQLite 与 blob 文件,升级前请备份;
21
+ - 服务端不持有明文会话内容,只存不透明密文;
22
+ - 协议说明见仓库 `docs/architecture/PROTOCOL.md`。
23
+
24
+ ## 许可证
25
+
26
+ AGPL-3.0-only。
package/dist/blobs.js ADDED
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ export function blobPath(dataDir, sha256) {
5
+ return join(dataDir, 'blobs', sha256.slice(0, 2), sha256);
6
+ }
7
+ export async function putBlob(dataDir, bytes) {
8
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
9
+ const target = blobPath(dataDir, sha256);
10
+ try {
11
+ await stat(target);
12
+ return {
13
+ sha256,
14
+ bytes: bytes.length
15
+ };
16
+ } catch {}
17
+ await mkdir(dirname(target), {
18
+ recursive: true
19
+ });
20
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
21
+ await writeFile(temp, bytes);
22
+ try {
23
+ await rename(temp, target);
24
+ } catch (error) {
25
+ await unlink(temp).catch(()=>undefined);
26
+ try {
27
+ await stat(target);
28
+ } catch {
29
+ throw error;
30
+ }
31
+ }
32
+ return {
33
+ sha256,
34
+ bytes: bytes.length
35
+ };
36
+ }
37
+ export async function hasBlob(dataDir, sha256) {
38
+ try {
39
+ await stat(blobPath(dataDir, sha256));
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ export async function readBlob(dataDir, sha256) {
46
+ return readFile(blobPath(dataDir, sha256));
47
+ }
48
+
49
+
50
+ //# sourceURL=blobs.ts
package/dist/db.js ADDED
@@ -0,0 +1,262 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ export const DEFAULT_DATASET = 'default';
6
+ export const SERVER_SCHEMA_VERSION = 3;
7
+ const SCHEMA_VERSION = SERVER_SCHEMA_VERSION;
8
+ const SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS meta (
10
+ key TEXT PRIMARY KEY,
11
+ value TEXT NOT NULL
12
+ );
13
+
14
+ CREATE TABLE IF NOT EXISTS devices (
15
+ dataset TEXT NOT NULL,
16
+ device_id TEXT NOT NULL,
17
+ label TEXT NOT NULL,
18
+ token_hash TEXT NOT NULL,
19
+ created_at INTEGER NOT NULL,
20
+ last_seen_at INTEGER,
21
+ revoked_at INTEGER,
22
+ PRIMARY KEY (dataset, device_id)
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS sessions (
26
+ dataset TEXT NOT NULL,
27
+ session_id TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL,
29
+ format_version INTEGER NOT NULL,
30
+ last_seq INTEGER NOT NULL,
31
+ frame_count INTEGER NOT NULL,
32
+ head_chain_hash TEXT NOT NULL,
33
+ manifest_json TEXT NOT NULL,
34
+ manifest_digest TEXT NOT NULL,
35
+ revision INTEGER NOT NULL,
36
+ updated_at INTEGER NOT NULL,
37
+ deleted_at INTEGER,
38
+ PRIMARY KEY (dataset, session_id)
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS blobs (
42
+ sha256 TEXT PRIMARY KEY,
43
+ bytes INTEGER NOT NULL,
44
+ kind TEXT NOT NULL,
45
+ created_at INTEGER NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS leases (
49
+ dataset TEXT NOT NULL,
50
+ resource TEXT NOT NULL,
51
+ device_id TEXT NOT NULL,
52
+ client_id TEXT NOT NULL,
53
+ lease_until INTEGER NOT NULL,
54
+ PRIMARY KEY (dataset, resource)
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS vault_records (
58
+ dataset TEXT NOT NULL,
59
+ writer TEXT NOT NULL,
60
+ seq INTEGER NOT NULL,
61
+ prev_hash TEXT,
62
+ blob_sha256 TEXT NOT NULL,
63
+ bytes INTEGER NOT NULL,
64
+ created_at INTEGER NOT NULL,
65
+ sealed_json TEXT NOT NULL,
66
+ record_hash TEXT NOT NULL,
67
+ revision INTEGER NOT NULL,
68
+ PRIMARY KEY (dataset, writer, seq)
69
+ );
70
+
71
+ CREATE INDEX IF NOT EXISTS vault_records_revision ON vault_records (dataset, revision);
72
+
73
+ CREATE TABLE IF NOT EXISTS tombstones (
74
+ dataset TEXT NOT NULL,
75
+ session_id TEXT NOT NULL,
76
+ created_at INTEGER NOT NULL,
77
+ deleted_at INTEGER NOT NULL,
78
+ deleted_by TEXT NOT NULL,
79
+ PRIMARY KEY (dataset, session_id, created_at)
80
+ );
81
+ `;
82
+ function tableExists(handle, table) {
83
+ const row = handle.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
84
+ return row !== undefined;
85
+ }
86
+ function tableColumns(handle, table) {
87
+ const rows = handle.prepare(`PRAGMA table_info(${table})`).all();
88
+ return new Set(rows.map((row)=>row.name).filter((name)=>typeof name === 'string'));
89
+ }
90
+ function anyUserTable(handle) {
91
+ const row = handle.prepare("SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").get();
92
+ return Number(row?.count ?? 0) > 0;
93
+ }
94
+ function assertV2Sessions(handle) {
95
+ const columns = tableColumns(handle, 'sessions');
96
+ const required = [
97
+ 'created_at',
98
+ 'format_version',
99
+ 'last_seq',
100
+ 'frame_count',
101
+ 'head_chain_hash',
102
+ 'manifest_json',
103
+ 'manifest_digest',
104
+ 'revision',
105
+ 'updated_at'
106
+ ];
107
+ const missing = required.filter((column)=>!columns.has(column));
108
+ if (missing.length > 0) {
109
+ throw new Error(`不支持的旧数据库:sessions 缺少 ${missing.join(', ')};请先导出现有数据,再用新数据目录重建。`);
110
+ }
111
+ }
112
+ function backupBeforeMigration(handle, dataDir, version) {
113
+ const target = join(dataDir, `db.sqlite.bak-v${version}-${Date.now()}`);
114
+ handle.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
115
+ }
116
+ function migrateDatabase(handle, dataDir, version) {
117
+ if (version > SCHEMA_VERSION) {
118
+ throw new Error(`数据库结构版本 ${version} 高于服务端支持的 ${SCHEMA_VERSION};请升级服务端,不要降级覆盖。`);
119
+ }
120
+ if (version === SCHEMA_VERSION) {
121
+ handle.exec(SCHEMA);
122
+ return;
123
+ }
124
+ let sourceVersion = version;
125
+ if (version === 0) {
126
+ if (!anyUserTable(handle)) {
127
+ handle.exec(SCHEMA);
128
+ handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
129
+ return;
130
+ }
131
+ if (tableExists(handle, 'frames') || tableExists(handle, 'session_frames')) {
132
+ throw new Error('检测到未版本化的旧帧结构(frames/session_frames);该结构未作为发布版本支持,拒绝自动迁移。请导出数据后重建。');
133
+ }
134
+ if (!tableExists(handle, 'sessions')) {
135
+ handle.exec(SCHEMA);
136
+ handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
137
+ return;
138
+ }
139
+ sourceVersion = 2;
140
+ }
141
+ if (sourceVersion !== 2 && sourceVersion !== 3) {
142
+ throw new Error(`不支持的数据库结构版本 ${sourceVersion};请使用对应版本的服务端迁移。`);
143
+ }
144
+ assertV2Sessions(handle);
145
+ backupBeforeMigration(handle, dataDir, sourceVersion);
146
+ handle.exec('BEGIN IMMEDIATE');
147
+ try {
148
+ const leaseColumns = tableColumns(handle, 'leases');
149
+ if (!leaseColumns.has('resource') || !leaseColumns.has('client_id')) {
150
+ handle.exec('DROP TABLE IF EXISTS leases');
151
+ }
152
+ handle.exec(SCHEMA);
153
+ handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
154
+ handle.exec('COMMIT');
155
+ } catch (error) {
156
+ handle.exec('ROLLBACK');
157
+ throw error;
158
+ }
159
+ }
160
+ export function openDatabase(dataDir) {
161
+ mkdirSync(dataDir, {
162
+ recursive: true
163
+ });
164
+ const handle = new DatabaseSync(join(dataDir, 'db.sqlite'));
165
+ try {
166
+ handle.exec('PRAGMA journal_mode = WAL');
167
+ handle.exec('PRAGMA foreign_keys = ON');
168
+ const versionRow = handle.prepare('PRAGMA user_version').get();
169
+ migrateDatabase(handle, dataDir, Number(versionRow?.user_version ?? 0));
170
+ return {
171
+ handle,
172
+ close: ()=>handle.close()
173
+ };
174
+ } catch (error) {
175
+ handle.close();
176
+ throw error;
177
+ }
178
+ }
179
+ export function readMeta(db, key) {
180
+ const row = db.handle.prepare('SELECT value FROM meta WHERE key = ?').get(key);
181
+ return row?.value;
182
+ }
183
+ export function writeMeta(db, key, value) {
184
+ db.handle.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
185
+ }
186
+ export function ensureSetupToken(db) {
187
+ const existing = readMeta(db, 'setup_token');
188
+ if (existing !== undefined) return {
189
+ token: existing,
190
+ created: false
191
+ };
192
+ const token = randomBytes(32).toString('base64url');
193
+ writeMeta(db, 'setup_token', token);
194
+ return {
195
+ token,
196
+ created: true
197
+ };
198
+ }
199
+ export function isAdminInitialized(db) {
200
+ return readMeta(db, 'admin_initialized') === 'true';
201
+ }
202
+ export function markAdminInitialized(db) {
203
+ writeMeta(db, 'admin_initialized', 'true');
204
+ }
205
+ export function adminPassword(db) {
206
+ const hash = readMeta(db, 'admin_password_hash');
207
+ const salt = readMeta(db, 'admin_password_salt');
208
+ return hash === undefined || salt === undefined ? undefined : {
209
+ hash,
210
+ salt
211
+ };
212
+ }
213
+ export function setAdminPassword(db, hash, salt) {
214
+ writeMeta(db, 'admin_password_hash', hash);
215
+ writeMeta(db, 'admin_password_salt', salt);
216
+ }
217
+ export function clearAdminPassword(db) {
218
+ db.handle.prepare("DELETE FROM meta WHERE key IN ('admin_password_hash', 'admin_password_salt', 'admin_initialized')").run();
219
+ }
220
+ export function readMaxBytes(db, fallback) {
221
+ const value = readMeta(db, 'max_bytes');
222
+ const parsed = Number(value);
223
+ return value !== undefined && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
224
+ }
225
+ export function writeMaxBytes(db, maxBytes) {
226
+ writeMeta(db, 'max_bytes', String(maxBytes));
227
+ }
228
+ export function readRevision(db) {
229
+ return Number(readMeta(db, 'revision') ?? '0');
230
+ }
231
+ export function bumpRevision(db) {
232
+ const row = db.handle.prepare("INSERT INTO meta (key, value) VALUES ('revision', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT) RETURNING CAST(value AS INTEGER) AS revision").get();
233
+ return Number(row?.revision ?? 1);
234
+ }
235
+ export function usedBlobBytes(db) {
236
+ const row = db.handle.prepare('SELECT COALESCE(SUM(bytes), 0) AS total FROM blobs').get();
237
+ return Number(row?.total ?? 0);
238
+ }
239
+ export function withTransaction(db, fn) {
240
+ db.handle.exec('BEGIN IMMEDIATE');
241
+ try {
242
+ const result = fn();
243
+ db.handle.exec('COMMIT');
244
+ return result;
245
+ } catch (error) {
246
+ db.handle.exec('ROLLBACK');
247
+ throw error;
248
+ }
249
+ }
250
+ export function blobRecord(db, sha256) {
251
+ const row = db.handle.prepare('SELECT bytes, kind FROM blobs WHERE sha256 = ?').get(sha256);
252
+ return row === undefined ? undefined : {
253
+ bytes: Number(row.bytes),
254
+ kind: String(row.kind)
255
+ };
256
+ }
257
+ export function recordBlob(db, sha256, bytes, kind) {
258
+ db.handle.prepare('INSERT OR IGNORE INTO blobs (sha256, bytes, kind, created_at) VALUES (?, ?, ?, ?)').run(sha256, bytes, kind, Date.now());
259
+ }
260
+
261
+
262
+ //# sourceURL=db.ts