sh3-server 0.8.2 → 0.9.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.
Files changed (42) hide show
  1. package/app/assets/index-DKuJNK2S.js +17 -0
  2. package/app/assets/index-DKuJNK2S.js.map +1 -0
  3. package/app/assets/index-DkC3EpjJ.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
@@ -4,7 +4,6 @@ import { mkdirSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { scopeRequired, tenantRequired } from './scope.js';
6
6
  import { pathToFileURL } from 'node:url';
7
- import { getSyncBundle, createSyncHandle, createSyncRegistry } from 'sh3-core/server-sync';
8
7
  /** Middleware requiring the caller's scope set to include admin:*. */
9
8
  export function adminOnly(_keys, settings) {
10
9
  return async (c, next) => {
@@ -16,36 +15,62 @@ export function adminOnly(_keys, settings) {
16
15
  return c.json({ error: 'Admin privileges required' }, 403);
17
16
  };
18
17
  }
19
- const PERMISSION_DOCUMENTS_SYNC = 'documents:sync';
20
18
  /**
21
- * Build a ServerShardContext object for the given shard, wiring
22
- * sync/syncRegistry based on the declared permissions.
19
+ * Build the shard-facing TenantDocumentAPI for a given calling shard, tenant,
20
+ * and permission set. Each operation is permission-checked at call time so
21
+ * grants can change between boot and request.
23
22
  */
24
- export function buildShardCtx(shardId, dataDir, permissions, keys, settings, wsRegister, documentBackend) {
25
- const hasSync = permissions.includes(PERMISSION_DOCUMENTS_SYNC);
26
- const ctx = {
27
- shardId,
28
- dataDir,
29
- permissions,
30
- adminOnly: adminOnly(keys, settings),
31
- scopeRequired,
32
- tenantRequired,
33
- wsRegister,
34
- };
35
- ctx.sync = async (tenantId, connectorId) => {
36
- if (!hasSync) {
37
- throw new Error(`Shard "${shardId}" cannot call ctx.sync — missing '${PERMISSION_DOCUMENTS_SYNC}' permission in manifest.`);
38
- }
39
- const { engine, registry } = await getSyncBundle(documentBackend, tenantId);
40
- return createSyncHandle({ tenantId, connectorId, engine, registry });
41
- };
42
- ctx.syncRegistry = (tenantId) => {
43
- if (!hasSync) {
44
- throw new Error(`Shard "${shardId}" cannot call ctx.syncRegistry — missing '${PERMISSION_DOCUMENTS_SYNC}' permission in manifest.`);
45
- }
46
- return createSyncRegistry(documentBackend, tenantId);
23
+ function makeTenantDocumentAPI(store, tenant, callingShardId, permissions) {
24
+ const hasSyncPeer = permissions.includes('sync:peer');
25
+ const hasSyncPolicy = permissions.includes('sync:policy');
26
+ return {
27
+ read: (shardId, path) => store.read(tenant, shardId, path),
28
+ exists: (shardId, path) => store.exists(tenant, shardId, path),
29
+ list: (shardId) => store.list(tenant, shardId),
30
+ listAll: () => store.listAll(tenant),
31
+ write: (shardId, path, content, metadata) => {
32
+ if (shardId !== callingShardId && !hasSyncPeer) {
33
+ throw new Error(`Shard "${callingShardId}" cannot write to shard "${shardId}" without sync:peer`);
34
+ }
35
+ return store.write(tenant, shardId, path, content, metadata);
36
+ },
37
+ delete: (shardId, path) => {
38
+ if (shardId !== callingShardId && !hasSyncPeer) {
39
+ throw new Error(`Shard "${callingShardId}" cannot delete from shard "${shardId}" without sync:peer`);
40
+ }
41
+ return store.delete(tenant, shardId, path);
42
+ },
43
+ applyFromPeer: (input) => {
44
+ if (!hasSyncPeer)
45
+ throw new Error('sync:peer permission required');
46
+ return store.applyFromPeer(tenant, {
47
+ ...input,
48
+ content: input.content,
49
+ });
50
+ },
51
+ getTick: () => {
52
+ if (!hasSyncPeer)
53
+ throw new Error('sync:peer permission required');
54
+ return store.getTick(tenant);
55
+ },
56
+ readPolicy: () => store.readPolicy(tenant),
57
+ writePolicy: (policy) => {
58
+ if (!hasSyncPolicy)
59
+ throw new Error('sync:policy permission required');
60
+ return store.writePolicy(tenant, policy);
61
+ },
62
+ listConflicts: () => {
63
+ if (!hasSyncPeer)
64
+ throw new Error('sync:peer permission required');
65
+ return store.listConflicts(tenant);
66
+ },
67
+ readConflict: (shardId, path) => {
68
+ if (!hasSyncPeer)
69
+ throw new Error('sync:peer permission required');
70
+ return store.readConflict(tenant, shardId, path);
71
+ },
72
+ resolveConflict: (shardId, path, choice) => store.resolveConflict(tenant, shardId, path, choice),
47
73
  };
48
- return ctx;
49
74
  }
50
75
  /**
51
76
  * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
@@ -60,27 +85,14 @@ export class ShardRouter {
60
85
  async mount(shardId, serverJsPath, ctx) {
61
86
  const fileUrl = pathToFileURL(serverJsPath).href + `?t=${Date.now()}`;
62
87
  const mod = await import(fileUrl);
63
- const shard = mod.default ?? mod;
88
+ const shard = (mod.default ?? mod);
64
89
  if (typeof shard.id !== 'string' || typeof shard.routes !== 'function') {
65
90
  throw new Error(`${shardId}/server.js invalid export shape — expected { id, routes }`);
66
91
  }
67
- const shardDataDir = join(ctx.pkgDir, 'data');
68
- const manifestPath = join(ctx.pkgDir, 'manifest.json');
69
- let permissions = [];
70
- try {
71
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
72
- permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
73
- }
74
- catch {
75
- // Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
76
- }
77
92
  const router = new Hono();
78
- const shardCtx = buildShardCtx(shard.id, shardDataDir, permissions, ctx.keys, ctx.settings, ctx.wsRegister, ctx.documentBackend);
79
- await shard.routes(router, shardCtx);
80
- // Create data dir only after routes() succeeds — so a failure
81
- // doesn't leave behind a dir that prevents install rollback cleanup.
82
- mkdirSync(shardDataDir, { recursive: true });
83
- this.shards.set(shardId, router);
93
+ await shard.routes(router, this.#buildContext(shard.id, ctx));
94
+ mkdirSync(join(ctx.pkgDir, 'data'), { recursive: true });
95
+ this.shards.set(shardId, { app: router, shard });
84
96
  console.log(`[sh3] ${shardId} — server routes mounted at /api/${shardId}/`);
85
97
  }
86
98
  /**
@@ -92,6 +104,13 @@ export class ShardRouter {
92
104
  if (typeof mod.id !== 'string' || typeof mod.routes !== 'function') {
93
105
  throw new Error(`${shardId} static mount — expected { id, routes }, got ${typeof mod}`);
94
106
  }
107
+ const router = new Hono();
108
+ await mod.routes(router, this.#buildContext(mod.id, ctx));
109
+ mkdirSync(join(ctx.pkgDir, 'data'), { recursive: true });
110
+ this.shards.set(shardId, { app: router, shard: mod });
111
+ console.log(`[sh3] ${shardId} — static server routes mounted at /api/${shardId}/`);
112
+ }
113
+ #buildContext(shardId, ctx) {
95
114
  const shardDataDir = join(ctx.pkgDir, 'data');
96
115
  const manifestPath = join(ctx.pkgDir, 'manifest.json');
97
116
  let permissions = [];
@@ -102,20 +121,53 @@ export class ShardRouter {
102
121
  catch {
103
122
  // Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
104
123
  }
105
- const router = new Hono();
106
- const shardCtx = buildShardCtx(mod.id, shardDataDir, permissions, ctx.keys, ctx.settings, ctx.wsRegister, ctx.documentBackend);
107
- await mod.routes(router, shardCtx);
108
- mkdirSync(shardDataDir, { recursive: true });
109
- this.shards.set(shardId, router);
110
- console.log(`[sh3] ${shardId} — static server routes mounted at /api/${shardId}/`);
124
+ const docStore = ctx.docStore;
125
+ // Hono's MiddlewareHandler uses a concrete Context generic that isn't
126
+ // assignable to sh3-core's framework-agnostic stand-in (c: unknown).
127
+ // Cast once at assembly — shards only call the handlers, never introspect.
128
+ const ctxOut = {
129
+ shardId,
130
+ dataDir: shardDataDir,
131
+ permissions,
132
+ adminOnly: adminOnly(ctx.keys, ctx.settings),
133
+ scopeRequired,
134
+ tenantRequired,
135
+ wsRegister: ctx.wsRegister,
136
+ tenants: () => docStore.listTenantsSync(),
137
+ documents: (tenant) => makeTenantDocumentAPI(docStore, tenant, shardId, permissions),
138
+ setPeerRole: (tenant, role) => {
139
+ if (!permissions.includes('sync:peer'))
140
+ return; // silent no-op
141
+ docStore.roles.set(tenant, role);
142
+ },
143
+ };
144
+ return ctxOut;
111
145
  }
112
- /** Remove a shard's routes. */
113
- unmount(shardId) {
114
- const removed = this.shards.delete(shardId);
115
- if (removed) {
116
- console.log(`[sh3] ${shardId} — server routes unmounted`);
146
+ /** Remove a shard's routes. Calls `teardown()` if the shard defined one. */
147
+ async unmount(shardId) {
148
+ const entry = this.shards.get(shardId);
149
+ if (!entry)
150
+ return false;
151
+ try {
152
+ await entry.shard.teardown?.();
153
+ }
154
+ catch (err) {
155
+ console.error(`[sh3] teardown failed for shard "${shardId}":`, err);
156
+ }
157
+ this.shards.delete(shardId);
158
+ console.log(`[sh3] ${shardId} — server routes unmounted`);
159
+ return true;
160
+ }
161
+ /** Call teardown on every mounted shard without clearing the registry. */
162
+ async unmountAll() {
163
+ for (const [id, entry] of this.shards) {
164
+ try {
165
+ await entry.shard.teardown?.();
166
+ }
167
+ catch (err) {
168
+ console.error(`[sh3] teardown failed for shard "${id}":`, err);
169
+ }
117
170
  }
118
- return removed;
119
171
  }
120
172
  /**
121
173
  * Hono handler for the wildcard route.
@@ -124,8 +176,8 @@ export class ShardRouter {
124
176
  handler() {
125
177
  return async (c, next) => {
126
178
  const shardId = c.req.param('shardId');
127
- const shardApp = this.shards.get(shardId);
128
- if (!shardApp) {
179
+ const entry = this.shards.get(shardId);
180
+ if (!entry) {
129
181
  return next();
130
182
  }
131
183
  try {
@@ -143,7 +195,7 @@ export class ShardRouter {
143
195
  });
144
196
  // Forward session from upstream sessionAuth so shard middleware can see it
145
197
  const env = { ...c.env, session: c.get('session') ?? null };
146
- return await shardApp.fetch(strippedRequest, env);
198
+ return await entry.app.fetch(strippedRequest, env);
147
199
  }
148
200
  catch (err) {
149
201
  console.error(`[sh3] Shard "${shardId}" runtime error:`, err);
@@ -155,8 +207,8 @@ export class ShardRouter {
155
207
  listRoutes() {
156
208
  const routes = [];
157
209
  const seen = new Set();
158
- for (const [shardId, app] of this.shards) {
159
- for (const r of app.routes) {
210
+ for (const [shardId, entry] of this.shards) {
211
+ for (const r of entry.app.routes) {
160
212
  const path = `/api/${shardId}${r.path}`;
161
213
  const key = `${r.method} ${path}`;
162
214
  if (seen.has(key))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-server",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sh3-server": "dist/cli.js"