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.
Files changed (48) 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/auth.d.ts +10 -3
  6. package/dist/auth.js +14 -22
  7. package/dist/caller.d.ts +17 -0
  8. package/dist/caller.js +55 -0
  9. package/dist/doc-store/conflicts.d.ts +19 -0
  10. package/dist/doc-store/conflicts.js +79 -0
  11. package/dist/doc-store/index.d.ts +11 -0
  12. package/dist/doc-store/index.js +22 -0
  13. package/dist/doc-store/meta.d.ts +11 -0
  14. package/dist/doc-store/meta.js +37 -0
  15. package/dist/doc-store/policy.d.ts +15 -0
  16. package/dist/doc-store/policy.js +85 -0
  17. package/dist/doc-store/reserved.d.ts +7 -0
  18. package/dist/doc-store/reserved.js +26 -0
  19. package/dist/doc-store/roles.d.ts +12 -0
  20. package/dist/doc-store/roles.js +15 -0
  21. package/dist/doc-store/store.d.ts +71 -0
  22. package/dist/doc-store/store.js +336 -0
  23. package/dist/doc-store/tick.d.ts +13 -0
  24. package/dist/doc-store/tick.js +52 -0
  25. package/dist/fs-backend.d.ts +10 -0
  26. package/dist/fs-backend.js +105 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +32 -5
  29. package/dist/keys.d.ts +35 -19
  30. package/dist/keys.js +187 -49
  31. package/dist/migrations/sync-grants.d.ts +7 -0
  32. package/dist/migrations/sync-grants.js +27 -0
  33. package/dist/packages.d.ts +3 -2
  34. package/dist/packages.js +5 -5
  35. package/dist/routes/admin.js +7 -3
  36. package/dist/routes/docs.d.ts +11 -7
  37. package/dist/routes/docs.js +88 -122
  38. package/dist/routes/keys.d.ts +21 -0
  39. package/dist/routes/keys.js +166 -0
  40. package/dist/scope.d.ts +11 -0
  41. package/dist/scope.js +45 -0
  42. package/dist/shard-router.d.ts +10 -4
  43. package/dist/shard-router.js +130 -49
  44. package/dist/shell-shard/index.d.ts +4 -1
  45. package/package.json +1 -1
  46. package/app/assets/index-C3rCTpjL.js +0 -17
  47. package/app/assets/index-C3rCTpjL.js.map +0 -1
  48. package/app/assets/index-GfhVhkjD.css +0 -1
@@ -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 that requires admin session OR valid API key. */
7
- export function adminOnly(keys, settings) {
7
+ /** Middleware requiring the caller's scope set to include admin:*. */
8
+ export function adminOnly(_keys, settings) {
8
9
  return async (c, next) => {
9
- // When auth is disabled (--no-auth), skip admin checks entirely
10
- if (!settings.get().auth.required) {
10
+ if (!settings.get().auth.required)
11
11
  return next();
12
- }
13
- // Session-based admin — forwarded from upstream sessionAuth via env
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
- const shardCtx = {
49
- shardId: shard.id,
50
- dataDir: shardDataDir,
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
- const shardCtx = {
73
- shardId: mod.id,
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
- await mod.routes(router, shardCtx);
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 removed = this.shards.delete(shardId);
86
- if (removed) {
87
- 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
+ }
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 shardApp = this.shards.get(shardId);
99
- if (!shardApp) {
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 shardApp.fetch(strippedRequest, env);
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, app] of this.shards) {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-server",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sh3-server": "dist/cli.js"