sh3-server 0.8.1 → 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.
- package/app/assets/index-DKuJNK2S.js +17 -0
- package/app/assets/index-DKuJNK2S.js.map +1 -0
- package/app/assets/index-DkC3EpjJ.css +1 -0
- package/app/index.html +2 -2
- package/dist/auth.d.ts +10 -3
- package/dist/auth.js +14 -22
- package/dist/caller.d.ts +17 -0
- package/dist/caller.js +55 -0
- 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 +10 -0
- package/dist/fs-backend.js +105 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +32 -5
- package/dist/keys.d.ts +35 -19
- package/dist/keys.js +187 -49
- package/dist/migrations/sync-grants.d.ts +7 -0
- package/dist/migrations/sync-grants.js +27 -0
- package/dist/packages.d.ts +3 -2
- package/dist/packages.js +5 -5
- package/dist/routes/admin.js +7 -3
- package/dist/routes/docs.d.ts +11 -7
- package/dist/routes/docs.js +88 -122
- package/dist/routes/keys.d.ts +21 -0
- package/dist/routes/keys.js +166 -0
- package/dist/scope.d.ts +11 -0
- package/dist/scope.js +45 -0
- package/dist/shard-router.d.ts +10 -4
- package/dist/shard-router.js +130 -49
- package/dist/shell-shard/index.d.ts +4 -1
- package/package.json +1 -1
- package/app/assets/index-C3rCTpjL.js +0 -17
- package/app/assets/index-C3rCTpjL.js.map +0 -1
- package/app/assets/index-GfhVhkjD.css +0 -1
package/dist/shard-router.js
CHANGED
|
@@ -1,31 +1,77 @@
|
|
|
1
1
|
// packages/sh3-server/src/shard-router.ts
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { scopeRequired, tenantRequired } from './scope.js';
|
|
5
6
|
import { pathToFileURL } from 'node:url';
|
|
6
|
-
/** Middleware
|
|
7
|
-
export function adminOnly(
|
|
7
|
+
/** Middleware requiring the caller's scope set to include admin:*. */
|
|
8
|
+
export function adminOnly(_keys, settings) {
|
|
8
9
|
return async (c, next) => {
|
|
9
|
-
|
|
10
|
-
if (!settings.get().auth.required) {
|
|
10
|
+
if (!settings.get().auth.required)
|
|
11
11
|
return next();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const session = c.get('session') ?? c.env?.session;
|
|
15
|
-
if (session?.role === 'admin') {
|
|
12
|
+
const caller = c.get('caller');
|
|
13
|
+
if (caller?.scopes.includes('admin:*'))
|
|
16
14
|
return next();
|
|
17
|
-
}
|
|
18
|
-
// Fallback: API key (for external tools / CLI)
|
|
19
|
-
const authHeader = c.req.header('Authorization');
|
|
20
|
-
if (authHeader?.startsWith('Bearer sh3_')) {
|
|
21
|
-
const token = authHeader.slice(7);
|
|
22
|
-
if (keys.validate(token)) {
|
|
23
|
-
return next();
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
15
|
return c.json({ error: 'Admin privileges required' }, 403);
|
|
27
16
|
};
|
|
28
17
|
}
|
|
18
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
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),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
29
75
|
/**
|
|
30
76
|
* Dynamic shard route manager. Holds a Map of shard Hono sub-apps
|
|
31
77
|
* and delegates requests from a single wildcard route.
|
|
@@ -39,23 +85,14 @@ export class ShardRouter {
|
|
|
39
85
|
async mount(shardId, serverJsPath, ctx) {
|
|
40
86
|
const fileUrl = pathToFileURL(serverJsPath).href + `?t=${Date.now()}`;
|
|
41
87
|
const mod = await import(fileUrl);
|
|
42
|
-
const shard = mod.default ?? mod;
|
|
88
|
+
const shard = (mod.default ?? mod);
|
|
43
89
|
if (typeof shard.id !== 'string' || typeof shard.routes !== 'function') {
|
|
44
90
|
throw new Error(`${shardId}/server.js invalid export shape — expected { id, routes }`);
|
|
45
91
|
}
|
|
46
|
-
const shardDataDir = join(ctx.pkgDir, 'data');
|
|
47
92
|
const router = new Hono();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
adminOnly: adminOnly(ctx.keys, ctx.settings),
|
|
52
|
-
wsRegister: ctx.wsRegister,
|
|
53
|
-
};
|
|
54
|
-
await shard.routes(router, shardCtx);
|
|
55
|
-
// Create data dir only after routes() succeeds — so a failure
|
|
56
|
-
// doesn't leave behind a dir that prevents install rollback cleanup.
|
|
57
|
-
mkdirSync(shardDataDir, { recursive: true });
|
|
58
|
-
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 });
|
|
59
96
|
console.log(`[sh3] ${shardId} — server routes mounted at /api/${shardId}/`);
|
|
60
97
|
}
|
|
61
98
|
/**
|
|
@@ -67,26 +104,70 @@ export class ShardRouter {
|
|
|
67
104
|
if (typeof mod.id !== 'string' || typeof mod.routes !== 'function') {
|
|
68
105
|
throw new Error(`${shardId} static mount — expected { id, routes }, got ${typeof mod}`);
|
|
69
106
|
}
|
|
70
|
-
const shardDataDir = join(ctx.pkgDir, 'data');
|
|
71
107
|
const router = new Hono();
|
|
72
|
-
|
|
73
|
-
|
|
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) {
|
|
114
|
+
const shardDataDir = join(ctx.pkgDir, 'data');
|
|
115
|
+
const manifestPath = join(ctx.pkgDir, 'manifest.json');
|
|
116
|
+
let permissions = [];
|
|
117
|
+
try {
|
|
118
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
119
|
+
permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
|
|
123
|
+
}
|
|
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,
|
|
74
130
|
dataDir: shardDataDir,
|
|
131
|
+
permissions,
|
|
75
132
|
adminOnly: adminOnly(ctx.keys, ctx.settings),
|
|
133
|
+
scopeRequired,
|
|
134
|
+
tenantRequired,
|
|
76
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
|
+
},
|
|
77
143
|
};
|
|
78
|
-
|
|
79
|
-
mkdirSync(shardDataDir, { recursive: true });
|
|
80
|
-
this.shards.set(shardId, router);
|
|
81
|
-
console.log(`[sh3] ${shardId} — static server routes mounted at /api/${shardId}/`);
|
|
144
|
+
return ctxOut;
|
|
82
145
|
}
|
|
83
|
-
/** Remove a shard's routes. */
|
|
84
|
-
unmount(shardId) {
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
|
|
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
|
+
}
|
|
88
170
|
}
|
|
89
|
-
return removed;
|
|
90
171
|
}
|
|
91
172
|
/**
|
|
92
173
|
* Hono handler for the wildcard route.
|
|
@@ -95,8 +176,8 @@ export class ShardRouter {
|
|
|
95
176
|
handler() {
|
|
96
177
|
return async (c, next) => {
|
|
97
178
|
const shardId = c.req.param('shardId');
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
179
|
+
const entry = this.shards.get(shardId);
|
|
180
|
+
if (!entry) {
|
|
100
181
|
return next();
|
|
101
182
|
}
|
|
102
183
|
try {
|
|
@@ -114,7 +195,7 @@ export class ShardRouter {
|
|
|
114
195
|
});
|
|
115
196
|
// Forward session from upstream sessionAuth so shard middleware can see it
|
|
116
197
|
const env = { ...c.env, session: c.get('session') ?? null };
|
|
117
|
-
return await
|
|
198
|
+
return await entry.app.fetch(strippedRequest, env);
|
|
118
199
|
}
|
|
119
200
|
catch (err) {
|
|
120
201
|
console.error(`[sh3] Shard "${shardId}" runtime error:`, err);
|
|
@@ -126,8 +207,8 @@ export class ShardRouter {
|
|
|
126
207
|
listRoutes() {
|
|
127
208
|
const routes = [];
|
|
128
209
|
const seen = new Set();
|
|
129
|
-
for (const [shardId,
|
|
130
|
-
for (const r of app.routes) {
|
|
210
|
+
for (const [shardId, entry] of this.shards) {
|
|
211
|
+
for (const r of entry.app.routes) {
|
|
131
212
|
const path = `/api/${shardId}${r.path}`;
|
|
132
213
|
const key = `${r.method} ${path}`;
|
|
133
214
|
if (seen.has(key))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import type { Context } from 'hono';
|
|
2
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
3
3
|
import type { WsLike } from './session-manager.js';
|
|
4
4
|
export interface ShellServerContext {
|
|
5
5
|
shardId: string;
|
|
@@ -8,6 +8,9 @@ export interface ShellServerContext {
|
|
|
8
8
|
tenantRootBase?: string;
|
|
9
9
|
adminOnly: any;
|
|
10
10
|
wsRegister: (onConnect: (ws: WsLike, c: Context) => void) => any;
|
|
11
|
+
permissions: string[];
|
|
12
|
+
scopeRequired: (scope: string) => MiddlewareHandler;
|
|
13
|
+
tenantRequired: MiddlewareHandler;
|
|
11
14
|
}
|
|
12
15
|
declare const _default: {
|
|
13
16
|
id: string;
|