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.
Files changed (42) hide show
  1. package/app/assets/index-CfqiA9Wt.js +17 -0
  2. package/app/assets/index-CfqiA9Wt.js.map +1 -0
  3. package/app/assets/index-TUefqqjg.css +1 -0
  4. package/app/index.html +2 -2
  5. package/dist/caller.d.ts +2 -1
  6. package/dist/caller.js +2 -1
  7. package/dist/doc-store/conflicts.d.ts +19 -0
  8. package/dist/doc-store/conflicts.js +79 -0
  9. package/dist/doc-store/index.d.ts +11 -0
  10. package/dist/doc-store/index.js +22 -0
  11. package/dist/doc-store/meta.d.ts +11 -0
  12. package/dist/doc-store/meta.js +37 -0
  13. package/dist/doc-store/policy.d.ts +15 -0
  14. package/dist/doc-store/policy.js +85 -0
  15. package/dist/doc-store/reserved.d.ts +7 -0
  16. package/dist/doc-store/reserved.js +26 -0
  17. package/dist/doc-store/roles.d.ts +12 -0
  18. package/dist/doc-store/roles.js +15 -0
  19. package/dist/doc-store/store.d.ts +71 -0
  20. package/dist/doc-store/store.js +336 -0
  21. package/dist/doc-store/tick.d.ts +13 -0
  22. package/dist/doc-store/tick.js +52 -0
  23. package/dist/fs-backend.d.ts +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +30 -11
  26. package/dist/keys.d.ts +4 -2
  27. package/dist/keys.js +18 -3
  28. package/dist/migrations/sync-grants.d.ts +7 -0
  29. package/dist/migrations/sync-grants.js +27 -0
  30. package/dist/packages.d.ts +3 -4
  31. package/dist/packages.js +5 -5
  32. package/dist/routes/docs.d.ts +11 -7
  33. package/dist/routes/docs.js +88 -122
  34. package/dist/routes/keys.js +4 -2
  35. package/dist/scope.d.ts +2 -0
  36. package/dist/scope.js +20 -0
  37. package/dist/shard-router.d.ts +8 -9
  38. package/dist/shard-router.js +114 -62
  39. package/package.json +1 -1
  40. package/app/assets/index-Cb-zoqb1.js +0 -17
  41. package/app/assets/index-Cb-zoqb1.js.map +0 -1
  42. 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
+ }
@@ -6,5 +6,5 @@
6
6
  *
7
7
  * where docsDir = <dataDir>/docs
8
8
  */
9
- import type { DocumentBackend } from 'sh3-core/server-sync';
9
+ import type { DocumentBackend } from 'sh3-core';
10
10
  export declare function createFsDocumentBackend(dataDir: string): DocumentBackend;
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 { createFsDocumentBackend } from './fs-backend.js';
31
- import { ShardRouter, buildShardCtx } from './shard-router.js';
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, documentBackend, frameworkShardIds));
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
- const shellPermissions = [];
202
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
- await shellShardServer.routes(shellSubApp, buildShardCtx('shell', shellDataDir, shellPermissions, keys, settings, wsRegister, documentBackend));
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, documentBackend);
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
- connectorId?: string;
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
- connectorId?: string;
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
- connectorId: input.connectorId,
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
- this.#admin.push(...raw);
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
- this.#byTenant.set(tenantId, raw);
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
+ }
@@ -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, documentBackend: DocumentBackend): Promise<DiscoveredPackage[]>;
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, documentBackend: DocumentBackend, frameworkShardIds?: string[]): Hono;
56
+ export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, docStore: TenantDocStore, frameworkShardIds?: string[]): Hono;
58
57
  export {};