sh3-server 0.8.2 → 0.9.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/app/assets/index-CfqiA9Wt.js +17 -0
- package/app/assets/index-CfqiA9Wt.js.map +1 -0
- package/app/assets/index-TUefqqjg.css +1 -0
- package/app/index.html +2 -2
- package/dist/caller.d.ts +2 -1
- package/dist/caller.js +2 -1
- package/dist/doc-store/conflicts.d.ts +19 -0
- package/dist/doc-store/conflicts.js +79 -0
- package/dist/doc-store/index.d.ts +11 -0
- package/dist/doc-store/index.js +22 -0
- package/dist/doc-store/meta.d.ts +11 -0
- package/dist/doc-store/meta.js +37 -0
- package/dist/doc-store/policy.d.ts +15 -0
- package/dist/doc-store/policy.js +85 -0
- package/dist/doc-store/reserved.d.ts +7 -0
- package/dist/doc-store/reserved.js +26 -0
- package/dist/doc-store/roles.d.ts +12 -0
- package/dist/doc-store/roles.js +15 -0
- package/dist/doc-store/store.d.ts +71 -0
- package/dist/doc-store/store.js +336 -0
- package/dist/doc-store/tick.d.ts +13 -0
- package/dist/doc-store/tick.js +52 -0
- package/dist/fs-backend.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +30 -11
- package/dist/keys.d.ts +4 -2
- package/dist/keys.js +18 -3
- package/dist/migrations/sync-grants.d.ts +7 -0
- package/dist/migrations/sync-grants.js +27 -0
- package/dist/packages.d.ts +3 -4
- package/dist/packages.js +5 -5
- package/dist/routes/docs.d.ts +11 -7
- package/dist/routes/docs.js +88 -122
- package/dist/routes/keys.js +4 -2
- package/dist/scope.d.ts +2 -0
- package/dist/scope.js +20 -0
- package/dist/shard-router.d.ts +8 -9
- package/dist/shard-router.js +114 -62
- package/package.json +1 -1
- package/app/assets/index-Cb-zoqb1.js +0 -17
- package/app/assets/index-Cb-zoqb1.js.map +0 -1
- package/app/assets/index-DPcN5Lor.css +0 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantDocStore — single entry point for tenant-document operations.
|
|
3
|
+
*
|
|
4
|
+
* Owns the write pipeline: version bump (primary) or pending (replica),
|
|
5
|
+
* policy-resolved syncMode caching, reserved-metadata filter, tick advance,
|
|
6
|
+
* conflict bucket on Mode B mismatch. Consumed by the docs HTTP router
|
|
7
|
+
* and by the ServerShardContext.documents(tenant) API.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises';
|
|
10
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, relative } from 'node:path';
|
|
12
|
+
import { resolveSyncMode } from './policy.js';
|
|
13
|
+
import { filterReservedMeta } from './reserved.js';
|
|
14
|
+
import { readMeta, writeMeta } from './meta.js';
|
|
15
|
+
export class TenantDocStore {
|
|
16
|
+
#dataDir;
|
|
17
|
+
#policy;
|
|
18
|
+
#tick;
|
|
19
|
+
roles;
|
|
20
|
+
#conflicts;
|
|
21
|
+
constructor(deps) {
|
|
22
|
+
this.#dataDir = deps.dataDir;
|
|
23
|
+
this.#policy = deps.policy;
|
|
24
|
+
this.#tick = deps.tick;
|
|
25
|
+
this.roles = deps.roles;
|
|
26
|
+
this.#conflicts = deps.conflicts;
|
|
27
|
+
}
|
|
28
|
+
/** Root filesystem directory this store reads/writes under. */
|
|
29
|
+
get dataDir() { return this.#dataDir; }
|
|
30
|
+
/** Synchronous tenant enumeration for shard-ctx's `tenants()` entry point. */
|
|
31
|
+
listTenantsSync() {
|
|
32
|
+
const root = join(this.#dataDir, 'docs');
|
|
33
|
+
if (!existsSync(root))
|
|
34
|
+
return [];
|
|
35
|
+
return readdirSync(root, { withFileTypes: true })
|
|
36
|
+
.filter((e) => e.isDirectory())
|
|
37
|
+
.map((e) => e.name);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Persist `__sync__/policy.json` for a tenant and refresh the cached
|
|
41
|
+
* `syncMode` on every existing doc so subsequent reads match the new
|
|
42
|
+
* policy without a restart.
|
|
43
|
+
*/
|
|
44
|
+
async writePolicy(tenant, policy) {
|
|
45
|
+
const p = join(this.#dataDir, 'docs', tenant, '__sync__', 'policy.json');
|
|
46
|
+
await mkdir(dirname(p), { recursive: true });
|
|
47
|
+
await writeFile(p, JSON.stringify(policy, null, 2));
|
|
48
|
+
this.#policy.invalidate(tenant);
|
|
49
|
+
const all = await this.listAll(tenant);
|
|
50
|
+
for (const entry of all) {
|
|
51
|
+
const meta = await readMeta(this.#dataDir, tenant, entry.shardId, entry.path);
|
|
52
|
+
if (!meta)
|
|
53
|
+
continue;
|
|
54
|
+
const newMode = resolveSyncMode(policy, entry.path);
|
|
55
|
+
if (meta.syncMode !== newMode) {
|
|
56
|
+
await writeMeta(this.#dataDir, tenant, entry.shardId, entry.path, { ...meta, syncMode: newMode });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ---------- path helpers ----------
|
|
61
|
+
#contentPath(tenant, shardId, path) {
|
|
62
|
+
return join(this.#dataDir, 'docs', tenant, shardId, path);
|
|
63
|
+
}
|
|
64
|
+
// ---------- reads ----------
|
|
65
|
+
async read(tenant, shardId, path) {
|
|
66
|
+
try {
|
|
67
|
+
return await readFile(this.#contentPath(tenant, shardId, path), 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (err?.code === 'ENOENT')
|
|
71
|
+
return null;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async readMeta(tenant, shardId, path) {
|
|
76
|
+
return readMeta(this.#dataDir, tenant, shardId, path);
|
|
77
|
+
}
|
|
78
|
+
async exists(tenant, shardId, path) {
|
|
79
|
+
try {
|
|
80
|
+
await stat(this.#contentPath(tenant, shardId, path));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async list(tenant, shardId) {
|
|
88
|
+
const root = join(this.#dataDir, 'docs', tenant, shardId);
|
|
89
|
+
return this.#enumerate(root, root, tenant, shardId);
|
|
90
|
+
}
|
|
91
|
+
async listAll(tenant) {
|
|
92
|
+
const root = join(this.#dataDir, 'docs', tenant);
|
|
93
|
+
const out = [];
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err?.code === 'ENOENT')
|
|
100
|
+
return [];
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (!e.isDirectory())
|
|
105
|
+
continue;
|
|
106
|
+
if (e.name.startsWith('__'))
|
|
107
|
+
continue; // reserved
|
|
108
|
+
const shardDir = join(root, e.name);
|
|
109
|
+
for (const m of await this.#enumerate(shardDir, shardDir, tenant, e.name)) {
|
|
110
|
+
out.push({ ...m, shardId: e.name });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
async #enumerate(current, base, tenant, shardId) {
|
|
116
|
+
const out = [];
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err?.code === 'ENOENT')
|
|
123
|
+
return out;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
for (const e of entries) {
|
|
127
|
+
const full = join(current, e.name);
|
|
128
|
+
if (e.isDirectory()) {
|
|
129
|
+
out.push(...(await this.#enumerate(full, base, tenant, shardId)));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const rel = relative(base, full).split(/[/\\]/).join('/');
|
|
133
|
+
const s = await stat(full);
|
|
134
|
+
const meta = await readMeta(this.#dataDir, tenant, shardId, rel);
|
|
135
|
+
out.push({
|
|
136
|
+
path: rel,
|
|
137
|
+
size: s.size,
|
|
138
|
+
lastModified: s.mtimeMs,
|
|
139
|
+
...(meta ?? {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
// ---------- policy / tick passthroughs ----------
|
|
145
|
+
async getTick(tenant) {
|
|
146
|
+
return this.#tick.get(tenant);
|
|
147
|
+
}
|
|
148
|
+
async readPolicy(tenant) {
|
|
149
|
+
return this.#policy.get(tenant);
|
|
150
|
+
}
|
|
151
|
+
invalidatePolicy(tenant) {
|
|
152
|
+
this.#policy.invalidate(tenant);
|
|
153
|
+
}
|
|
154
|
+
// ---------- Mode A write ----------
|
|
155
|
+
async write(tenant, shardId, path, content, metadata) {
|
|
156
|
+
const role = this.roles.get(tenant);
|
|
157
|
+
const policy = await this.#policy.get(tenant);
|
|
158
|
+
const syncMode = resolveSyncMode(policy, path);
|
|
159
|
+
const prev = await readMeta(this.#dataDir, tenant, shardId, path);
|
|
160
|
+
const prevKnown = typeof prev?.lastKnownVersion === 'number' ? prev.lastKnownVersion : 0;
|
|
161
|
+
let version;
|
|
162
|
+
let syncState;
|
|
163
|
+
let lastKnownVersion;
|
|
164
|
+
if (role === 'primary') {
|
|
165
|
+
version = prevKnown + 1;
|
|
166
|
+
lastKnownVersion = version;
|
|
167
|
+
syncState = 'synced';
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// replica
|
|
171
|
+
version = prevKnown;
|
|
172
|
+
lastKnownVersion = prevKnown;
|
|
173
|
+
syncState = syncMode === 'local-only' ? 'synced' : 'pending';
|
|
174
|
+
}
|
|
175
|
+
// Write content first.
|
|
176
|
+
const cp = this.#contentPath(tenant, shardId, path);
|
|
177
|
+
await mkdir(dirname(cp), { recursive: true });
|
|
178
|
+
await writeFile(cp, content);
|
|
179
|
+
// Then metadata.
|
|
180
|
+
const custom = filterReservedMeta(metadata);
|
|
181
|
+
const mergedMeta = {
|
|
182
|
+
...custom,
|
|
183
|
+
version,
|
|
184
|
+
syncMode,
|
|
185
|
+
syncState,
|
|
186
|
+
lastKnownVersion,
|
|
187
|
+
};
|
|
188
|
+
await writeMeta(this.#dataDir, tenant, shardId, path, mergedMeta);
|
|
189
|
+
if (role === 'primary')
|
|
190
|
+
await this.#tick.bump(tenant);
|
|
191
|
+
return { version, syncState };
|
|
192
|
+
}
|
|
193
|
+
// ---------- Mode A delete ----------
|
|
194
|
+
async delete(tenant, shardId, path) {
|
|
195
|
+
const role = this.roles.get(tenant);
|
|
196
|
+
const prev = await readMeta(this.#dataDir, tenant, shardId, path);
|
|
197
|
+
const prevKnown = typeof prev?.lastKnownVersion === 'number' ? prev.lastKnownVersion : 0;
|
|
198
|
+
// Remove content.
|
|
199
|
+
try {
|
|
200
|
+
await rm(this.#contentPath(tenant, shardId, path));
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (err?.code !== 'ENOENT')
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
let version;
|
|
207
|
+
let syncState;
|
|
208
|
+
let lastKnownVersion;
|
|
209
|
+
if (role === 'primary') {
|
|
210
|
+
version = prevKnown + 1;
|
|
211
|
+
lastKnownVersion = version;
|
|
212
|
+
syncState = 'synced';
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
version = prevKnown;
|
|
216
|
+
lastKnownVersion = prevKnown;
|
|
217
|
+
syncState = 'pending';
|
|
218
|
+
}
|
|
219
|
+
await writeMeta(this.#dataDir, tenant, shardId, path, {
|
|
220
|
+
version,
|
|
221
|
+
lastKnownVersion,
|
|
222
|
+
syncState,
|
|
223
|
+
deleted: true,
|
|
224
|
+
});
|
|
225
|
+
if (role === 'primary')
|
|
226
|
+
await this.#tick.bump(tenant);
|
|
227
|
+
}
|
|
228
|
+
// ---------- Mode B — applyFromPeer ----------
|
|
229
|
+
async applyFromPeer(tenant, input) {
|
|
230
|
+
const { shardId, path, content, incomingVersion, expectedLocalVersion, origin } = input;
|
|
231
|
+
const prev = await readMeta(this.#dataDir, tenant, shardId, path);
|
|
232
|
+
const prevVersion = typeof prev?.version === 'number' ? prev.version : 0;
|
|
233
|
+
const prevState = prev?.syncState;
|
|
234
|
+
if (!prev) {
|
|
235
|
+
await this.#overwriteFromPeer(tenant, shardId, path, content, incomingVersion, origin, input.deleted);
|
|
236
|
+
return { applied: true, version: incomingVersion };
|
|
237
|
+
}
|
|
238
|
+
if (prevState === 'synced') {
|
|
239
|
+
if (prevVersion === expectedLocalVersion) {
|
|
240
|
+
await this.#overwriteFromPeer(tenant, shardId, path, content, incomingVersion, origin, input.deleted);
|
|
241
|
+
return { applied: true, version: incomingVersion };
|
|
242
|
+
}
|
|
243
|
+
return { applied: false, reason: 'stale' };
|
|
244
|
+
}
|
|
245
|
+
if (prevState === 'pending') {
|
|
246
|
+
const localContent = (await this.read(tenant, shardId, path)) ?? '';
|
|
247
|
+
await this.#conflicts.append(tenant, shardId, path, {
|
|
248
|
+
origin: 'local',
|
|
249
|
+
version: prevVersion,
|
|
250
|
+
content: localContent,
|
|
251
|
+
at: Date.now(),
|
|
252
|
+
});
|
|
253
|
+
await this.#conflicts.append(tenant, shardId, path, {
|
|
254
|
+
origin,
|
|
255
|
+
version: incomingVersion,
|
|
256
|
+
content: typeof content === 'string' ? content : content.toString('utf-8'),
|
|
257
|
+
at: Date.now(),
|
|
258
|
+
});
|
|
259
|
+
const merged = { ...(prev ?? {}), syncState: 'conflict' };
|
|
260
|
+
await writeMeta(this.#dataDir, tenant, shardId, path, merged);
|
|
261
|
+
return { applied: false, reason: 'conflict' };
|
|
262
|
+
}
|
|
263
|
+
// prevState === 'conflict'
|
|
264
|
+
await this.#conflicts.append(tenant, shardId, path, {
|
|
265
|
+
origin,
|
|
266
|
+
version: incomingVersion,
|
|
267
|
+
content: typeof content === 'string' ? content : content.toString('utf-8'),
|
|
268
|
+
at: Date.now(),
|
|
269
|
+
});
|
|
270
|
+
return { applied: false, reason: 'conflict-extended' };
|
|
271
|
+
}
|
|
272
|
+
async #overwriteFromPeer(tenant, shardId, path, content, version, origin, deleted) {
|
|
273
|
+
const cp = this.#contentPath(tenant, shardId, path);
|
|
274
|
+
if (deleted) {
|
|
275
|
+
try {
|
|
276
|
+
await rm(cp);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
if (err?.code !== 'ENOENT')
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
await mkdir(dirname(cp), { recursive: true });
|
|
285
|
+
await writeFile(cp, content);
|
|
286
|
+
}
|
|
287
|
+
const policy = await this.#policy.get(tenant);
|
|
288
|
+
const syncMode = resolveSyncMode(policy, path);
|
|
289
|
+
await writeMeta(this.#dataDir, tenant, shardId, path, {
|
|
290
|
+
version,
|
|
291
|
+
lastKnownVersion: version,
|
|
292
|
+
syncMode,
|
|
293
|
+
syncState: 'synced',
|
|
294
|
+
origin,
|
|
295
|
+
lastSyncedAt: Date.now(),
|
|
296
|
+
...(deleted ? { deleted: true } : {}),
|
|
297
|
+
});
|
|
298
|
+
// Primary accepting a push advances tick; replica pulling doesn't.
|
|
299
|
+
if (this.roles.get(tenant) === 'primary')
|
|
300
|
+
await this.#tick.bump(tenant);
|
|
301
|
+
}
|
|
302
|
+
// ---------- conflicts ----------
|
|
303
|
+
async listConflicts(tenant) {
|
|
304
|
+
return this.#conflicts.list(tenant);
|
|
305
|
+
}
|
|
306
|
+
async readConflict(tenant, shardId, path) {
|
|
307
|
+
return this.#conflicts.read(tenant, shardId, path);
|
|
308
|
+
}
|
|
309
|
+
async resolveConflict(tenant, shardId, path, choice) {
|
|
310
|
+
const cf = await this.#conflicts.read(tenant, shardId, path);
|
|
311
|
+
if (!cf)
|
|
312
|
+
return;
|
|
313
|
+
let content;
|
|
314
|
+
if (choice === 'local') {
|
|
315
|
+
const branch = cf.branches.find((b) => b.origin === 'local');
|
|
316
|
+
if (!branch)
|
|
317
|
+
throw new Error('No local branch to resolve');
|
|
318
|
+
content = branch.content;
|
|
319
|
+
}
|
|
320
|
+
else if (typeof choice === 'string') {
|
|
321
|
+
const branch = cf.branches.find((b) => b.origin === choice);
|
|
322
|
+
if (!branch)
|
|
323
|
+
throw new Error(`No branch with origin ${choice}`);
|
|
324
|
+
content = branch.content;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
content = choice;
|
|
328
|
+
}
|
|
329
|
+
// Clear the conflict FIRST so the subsequent write doesn't see stale state.
|
|
330
|
+
await this.#conflicts.clear(tenant, shardId, path);
|
|
331
|
+
// Force syncState back to 'synced' baseline so Mode A write can transition normally.
|
|
332
|
+
const prev = (await readMeta(this.#dataDir, tenant, shardId, path)) ?? {};
|
|
333
|
+
await writeMeta(this.#dataDir, tenant, shardId, path, { ...prev, syncState: 'synced' });
|
|
334
|
+
await this.write(tenant, shardId, path, content);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant monotonic tick counter. Lives at
|
|
3
|
+
* {dataDir}/docs/<tenant>/__sync__/tick.json => { tick: number, bumpedAt: number }
|
|
4
|
+
*
|
|
5
|
+
* Incremented on every version-advancing write. Never decreases. Persisted
|
|
6
|
+
* synchronously before write acknowledgement. In-memory cached after first read.
|
|
7
|
+
*/
|
|
8
|
+
export declare class TickCounter {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(dataDir: string);
|
|
11
|
+
get(tenant: string): Promise<number>;
|
|
12
|
+
bump(tenant: string): Promise<number>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant monotonic tick counter. Lives at
|
|
3
|
+
* {dataDir}/docs/<tenant>/__sync__/tick.json => { tick: number, bumpedAt: number }
|
|
4
|
+
*
|
|
5
|
+
* Incremented on every version-advancing write. Never decreases. Persisted
|
|
6
|
+
* synchronously before write acknowledgement. In-memory cached after first read.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
export class TickCounter {
|
|
11
|
+
#dataDir;
|
|
12
|
+
#byTenant = new Map();
|
|
13
|
+
constructor(dataDir) {
|
|
14
|
+
this.#dataDir = dataDir;
|
|
15
|
+
}
|
|
16
|
+
async get(tenant) {
|
|
17
|
+
if (this.#byTenant.has(tenant))
|
|
18
|
+
return this.#byTenant.get(tenant);
|
|
19
|
+
const loaded = await this.#load(tenant);
|
|
20
|
+
this.#byTenant.set(tenant, loaded);
|
|
21
|
+
return loaded;
|
|
22
|
+
}
|
|
23
|
+
async bump(tenant) {
|
|
24
|
+
const current = await this.get(tenant);
|
|
25
|
+
const next = current + 1;
|
|
26
|
+
this.#byTenant.set(tenant, next);
|
|
27
|
+
await this.#persist(tenant, next);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
#path(tenant) {
|
|
31
|
+
return join(this.#dataDir, 'docs', tenant, '__sync__', 'tick.json');
|
|
32
|
+
}
|
|
33
|
+
async #load(tenant) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await readFile(this.#path(tenant), 'utf-8');
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return typeof parsed.tick === 'number' && Number.isFinite(parsed.tick)
|
|
38
|
+
? Math.max(0, Math.floor(parsed.tick))
|
|
39
|
+
: 0;
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err?.code === 'ENOENT')
|
|
43
|
+
return 0;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async #persist(tenant, value) {
|
|
48
|
+
const p = this.#path(tenant);
|
|
49
|
+
await mkdir(dirname(p), { recursive: true });
|
|
50
|
+
await writeFile(p, JSON.stringify({ tick: value, bumpedAt: Date.now() }));
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/fs-backend.d.ts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export declare function createServer(options?: ServerOptions): Promise<{
|
|
|
29
29
|
users: UserStore;
|
|
30
30
|
sessions: SessionStore;
|
|
31
31
|
settings: SettingsStore;
|
|
32
|
+
docStore: import("./doc-store/store.js").TenantDocStore;
|
|
32
33
|
start(): Promise<void>;
|
|
33
34
|
}>;
|
|
34
35
|
export { KeyStore } from './keys.js';
|
package/dist/index.js
CHANGED
|
@@ -24,11 +24,13 @@ import { createAuthRouter } from './routes/auth.js';
|
|
|
24
24
|
import { createBootRouter } from './routes/boot.js';
|
|
25
25
|
import { createAdminRouter } from './routes/admin.js';
|
|
26
26
|
import { createDocsRouter } from './routes/docs.js';
|
|
27
|
+
import { createTenantDocStore } from './doc-store/index.js';
|
|
27
28
|
import { createEnvStateRouter } from './routes/env-state.js';
|
|
28
29
|
import { createKeysRouter } from './routes/keys.js';
|
|
29
30
|
import { loadPackages, scanPackages, servePackageBundles, createPackageManagementRoutes } from './packages.js';
|
|
30
|
-
import {
|
|
31
|
-
import { ShardRouter,
|
|
31
|
+
import { removeLegacyGrants } from './migrations/sync-grants.js';
|
|
32
|
+
import { ShardRouter, adminOnly } from './shard-router.js';
|
|
33
|
+
import { scopeRequired, tenantRequired } from './scope.js';
|
|
32
34
|
import { registerTenantFsRoutes } from './tenant-fs/index.js';
|
|
33
35
|
import shellShardServer from './shell-shard/index.js';
|
|
34
36
|
export async function createServer(options = {}) {
|
|
@@ -48,8 +50,6 @@ export async function createServer(options = {}) {
|
|
|
48
50
|
const keys = new KeyStore(dataDir);
|
|
49
51
|
const users = new UserStore(dataDir);
|
|
50
52
|
const settings = new SettingsStore(dataDir);
|
|
51
|
-
// Server-side document backend (used by ctx.sync / ctx.syncRegistry on server shards)
|
|
52
|
-
const documentBackend = createFsDocumentBackend(dataDir);
|
|
53
53
|
// --no-auth: disable auth enforcement (Tauri sidecar / local-owner mode)
|
|
54
54
|
if (options.noAuth) {
|
|
55
55
|
settings.update({ auth: { required: false, guestAllowed: true } });
|
|
@@ -114,11 +114,14 @@ export async function createServer(options = {}) {
|
|
|
114
114
|
app.route('/api/boot', createBootRouter(sessions, users, settings));
|
|
115
115
|
// Auth endpoints (login, logout, register, verify)
|
|
116
116
|
app.route('/api/auth', createAuthRouter(keys, users, sessions, settings));
|
|
117
|
-
// Document backend API
|
|
118
|
-
app.route('/api/docs', createDocsRouter(dataDir));
|
|
119
117
|
// --- Session-gated routes ---
|
|
120
118
|
app.use('/api/*', sessionAuth(sessions, settings));
|
|
121
119
|
app.use('/api/*', resolveCaller(keys));
|
|
120
|
+
// Document backend API — gated by sessionAuth + resolveCaller (mounted above).
|
|
121
|
+
// The router itself enforces tenantParamMatch and the __-prefix reservation.
|
|
122
|
+
// Settings is threaded so tenantParamMatch respects open / no-auth mode.
|
|
123
|
+
const docStore = createTenantDocStore(dataDir);
|
|
124
|
+
app.route('/api/docs', createDocsRouter(docStore, settings));
|
|
122
125
|
// Environment state API (per-shard server-backed config)
|
|
123
126
|
app.route('/api/env-state', createEnvStateRouter(dataDir));
|
|
124
127
|
// User-tenant key management (list/revoke). Mint lives at /api/shards-keys.
|
|
@@ -179,7 +182,7 @@ export async function createServer(options = {}) {
|
|
|
179
182
|
app.use('/api/packages/install', adminAuth(sessions, keys, settings));
|
|
180
183
|
app.use('/api/packages/uninstall', adminAuth(sessions, keys, settings));
|
|
181
184
|
const frameworkShardIds = ['__sh3core__', 'shell', 'sh3-store', 'sh3-admin'];
|
|
182
|
-
app.route('/api/packages', createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister,
|
|
185
|
+
app.route('/api/packages', createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, docStore, frameworkShardIds));
|
|
183
186
|
// Serve client bundles from discovered packages
|
|
184
187
|
servePackageBundles(app, dataDir, settings);
|
|
185
188
|
// Framework built-in: shell-shard server routes.
|
|
@@ -198,9 +201,15 @@ export async function createServer(options = {}) {
|
|
|
198
201
|
const shellDataDir = join(shellPkgDir, 'data');
|
|
199
202
|
mkdirSync(shellDataDir, { recursive: true });
|
|
200
203
|
const shellSubApp = new Hono();
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
await shellShardServer.routes(shellSubApp, {
|
|
205
|
+
shardId: 'shell',
|
|
206
|
+
dataDir: shellDataDir,
|
|
207
|
+
permissions: [],
|
|
208
|
+
adminOnly: adminOnly(keys, settings),
|
|
209
|
+
scopeRequired,
|
|
210
|
+
tenantRequired,
|
|
211
|
+
wsRegister,
|
|
212
|
+
});
|
|
204
213
|
app.route('/api/shell', shellSubApp);
|
|
205
214
|
}
|
|
206
215
|
// Framework-level tenant filesystem API — read-only, jailed to each user's documents.
|
|
@@ -223,7 +232,9 @@ export async function createServer(options = {}) {
|
|
|
223
232
|
users,
|
|
224
233
|
sessions,
|
|
225
234
|
settings,
|
|
235
|
+
docStore,
|
|
226
236
|
async start() {
|
|
237
|
+
removeLegacyGrants(dataDir);
|
|
227
238
|
// First-boot: generate admin key + admin user
|
|
228
239
|
if (keys.isEmpty()) {
|
|
229
240
|
const initial = keys.generate({ label: 'Initial admin key', tenantId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
|
|
@@ -254,7 +265,7 @@ export async function createServer(options = {}) {
|
|
|
254
265
|
console.log(`[sh3] Seeded official registry: ${registryUrl}`);
|
|
255
266
|
}
|
|
256
267
|
}
|
|
257
|
-
await loadPackages(shardRouter, dataDir, keys, settings, wsRegister,
|
|
268
|
+
await loadPackages(shardRouter, dataDir, keys, settings, wsRegister, docStore);
|
|
258
269
|
// Periodic session cleanup (every 15 minutes)
|
|
259
270
|
setInterval(() => sessions.cleanup(), 15 * 60 * 1000);
|
|
260
271
|
// API 404
|
|
@@ -277,6 +288,14 @@ export async function createServer(options = {}) {
|
|
|
277
288
|
console.log(`sh3-server listening on http://localhost:${port}`);
|
|
278
289
|
});
|
|
279
290
|
injectWebSocket(server);
|
|
291
|
+
const shutdown = async (sig) => {
|
|
292
|
+
console.log(`[sh3] received ${sig}, tearing down shards...`);
|
|
293
|
+
await shardRouter.unmountAll();
|
|
294
|
+
server.close();
|
|
295
|
+
process.exit(0);
|
|
296
|
+
};
|
|
297
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
298
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
280
299
|
},
|
|
281
300
|
};
|
|
282
301
|
}
|
package/dist/keys.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export interface ApiKey {
|
|
|
17
17
|
ownerUserId: string | null;
|
|
18
18
|
mintedByShardId: string | null;
|
|
19
19
|
scopes: string[];
|
|
20
|
-
|
|
20
|
+
peerRole?: 'primary' | 'replica';
|
|
21
|
+
peerId?: string;
|
|
21
22
|
createdAt: string;
|
|
22
23
|
expiresAt?: string;
|
|
23
24
|
}
|
|
@@ -28,7 +29,8 @@ export interface GenerateInput {
|
|
|
28
29
|
ownerUserId: string | null;
|
|
29
30
|
mintedByShardId: string | null;
|
|
30
31
|
scopes: string[];
|
|
31
|
-
|
|
32
|
+
peerRole?: 'primary' | 'replica';
|
|
33
|
+
peerId?: string;
|
|
32
34
|
expiresAt?: string;
|
|
33
35
|
}
|
|
34
36
|
export declare class KeyStore {
|
package/dist/keys.js
CHANGED
|
@@ -38,7 +38,8 @@ export class KeyStore {
|
|
|
38
38
|
ownerUserId: input.ownerUserId,
|
|
39
39
|
mintedByShardId: input.mintedByShardId,
|
|
40
40
|
scopes: [...input.scopes],
|
|
41
|
-
|
|
41
|
+
peerRole: input.peerRole,
|
|
42
|
+
peerId: input.peerId,
|
|
42
43
|
createdAt: new Date().toISOString(),
|
|
43
44
|
expiresAt: input.expiresAt,
|
|
44
45
|
};
|
|
@@ -123,7 +124,14 @@ export class KeyStore {
|
|
|
123
124
|
return;
|
|
124
125
|
try {
|
|
125
126
|
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
126
|
-
|
|
127
|
+
const dirty = raw.some((row) => 'connectorId' in row);
|
|
128
|
+
const cleaned = raw.map((row) => {
|
|
129
|
+
const { connectorId: _drop, ...rest } = row;
|
|
130
|
+
return rest;
|
|
131
|
+
});
|
|
132
|
+
this.#admin.push(...cleaned);
|
|
133
|
+
if (dirty)
|
|
134
|
+
this.#saveAdmin();
|
|
127
135
|
}
|
|
128
136
|
catch {
|
|
129
137
|
// Corrupt admin file — leave empty; the next generate() overwrites.
|
|
@@ -142,7 +150,14 @@ export class KeyStore {
|
|
|
142
150
|
continue;
|
|
143
151
|
try {
|
|
144
152
|
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
145
|
-
|
|
153
|
+
const dirty = raw.some((row) => 'connectorId' in row);
|
|
154
|
+
const cleaned = raw.map((row) => {
|
|
155
|
+
const { connectorId: _drop, ...rest } = row;
|
|
156
|
+
return rest;
|
|
157
|
+
});
|
|
158
|
+
this.#byTenant.set(tenantId, cleaned);
|
|
159
|
+
if (dirty)
|
|
160
|
+
this.#saveTenant(tenantId);
|
|
146
161
|
}
|
|
147
162
|
catch {
|
|
148
163
|
this.#byTenant.set(tenantId, []);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-019 §11.2: retire the legacy `grants/` namespace under the reserved
|
|
3
|
+
* `__sync__` shard id for every tenant. Idempotent; safe to run on every
|
|
4
|
+
* boot. Other `__sync__/` subdirectories are out of scope here — they
|
|
5
|
+
* disappear when the sync runtime is rebuilt in sh3-server.
|
|
6
|
+
*/
|
|
7
|
+
export declare function removeLegacyGrants(dataDir: string): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-019 §11.2: retire the legacy `grants/` namespace under the reserved
|
|
3
|
+
* `__sync__` shard id for every tenant. Idempotent; safe to run on every
|
|
4
|
+
* boot. Other `__sync__/` subdirectories are out of scope here — they
|
|
5
|
+
* disappear when the sync runtime is rebuilt in sh3-server.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export function removeLegacyGrants(dataDir) {
|
|
10
|
+
const docsDir = join(dataDir, 'docs');
|
|
11
|
+
if (!existsSync(docsDir))
|
|
12
|
+
return;
|
|
13
|
+
const tenants = readdirSync(docsDir, { withFileTypes: true })
|
|
14
|
+
.filter((e) => e.isDirectory())
|
|
15
|
+
.map((e) => e.name);
|
|
16
|
+
let removed = 0;
|
|
17
|
+
for (const tenant of tenants) {
|
|
18
|
+
const grants = join(docsDir, tenant, '__sync__', 'grants');
|
|
19
|
+
if (existsSync(grants)) {
|
|
20
|
+
rmSync(grants, { recursive: true, force: true });
|
|
21
|
+
removed += 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (removed > 0) {
|
|
25
|
+
console.log(`[sh3] migration: removed legacy __sync__/grants/ for ${removed} tenant(s)`);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/packages.d.ts
CHANGED
|
@@ -3,10 +3,9 @@ import type { KeyStore } from './keys.js';
|
|
|
3
3
|
import type { SettingsStore } from './settings.js';
|
|
4
4
|
import { ShardRouter } from './shard-router.js';
|
|
5
5
|
import type { MountContext } from './shard-router.js';
|
|
6
|
+
import type { TenantDocStore } from './doc-store/index.js';
|
|
6
7
|
/** Type of the `wsRegister` factory field from MountContext. */
|
|
7
8
|
type WsRegister = MountContext['wsRegister'];
|
|
8
|
-
/** Type of the `documentBackend` field from MountContext. */
|
|
9
|
-
type DocumentBackend = MountContext['documentBackend'];
|
|
10
9
|
export interface DiscoveredPackage {
|
|
11
10
|
id: string;
|
|
12
11
|
type: string;
|
|
@@ -21,7 +20,7 @@ export interface DiscoveredPackage {
|
|
|
21
20
|
* For each valid package, mount server routes (if server.js exists) and
|
|
22
21
|
* return the full list of discovered packages.
|
|
23
22
|
*/
|
|
24
|
-
export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister,
|
|
23
|
+
export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, docStore: TenantDocStore): Promise<DiscoveredPackage[]>;
|
|
25
24
|
/**
|
|
26
25
|
* Re-scan `<dataDir>/packages/` and return metadata for all valid packages.
|
|
27
26
|
* Unlike `loadPackages`, this does NOT mount server routes — it only reads
|
|
@@ -54,5 +53,5 @@ export declare function validateRequiredShards(manifest: Record<string, unknown>
|
|
|
54
53
|
* Returns a Hono router with POST /install and POST /uninstall.
|
|
55
54
|
* Protected by the blanket `/api/*` auth middleware already applied upstream.
|
|
56
55
|
*/
|
|
57
|
-
export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister,
|
|
56
|
+
export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, docStore: TenantDocStore, frameworkShardIds?: string[]): Hono;
|
|
58
57
|
export {};
|