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
package/dist/shard-router.js
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
115
|
-
if (
|
|
116
|
-
|
|
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
|
|
128
|
-
if (!
|
|
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
|
|
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,
|
|
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))
|