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
package/dist/packages.js CHANGED
@@ -18,7 +18,7 @@ function isValidId(id) {
18
18
  * For each valid package, mount server routes (if server.js exists) and
19
19
  * return the full list of discovered packages.
20
20
  */
21
- export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegister, documentBackend) {
21
+ export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegister, docStore) {
22
22
  const packagesDir = join(dataDir, 'packages');
23
23
  if (!existsSync(packagesDir)) {
24
24
  mkdirSync(packagesDir, { recursive: true });
@@ -68,7 +68,7 @@ export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegis
68
68
  };
69
69
  if (hasServer) {
70
70
  try {
71
- await shardRouter.mount(manifest.id, serverJs, { pkgDir, keys, settings, wsRegister, documentBackend });
71
+ await shardRouter.mount(manifest.id, serverJs, { pkgDir, keys, settings, wsRegister, docStore });
72
72
  }
73
73
  catch (err) {
74
74
  console.warn(`[sh3] ${manifest.id}/server.js failed to load:`, err);
@@ -193,7 +193,7 @@ export function validateRequiredShards(manifest, knownShards) {
193
193
  * Returns a Hono router with POST /install and POST /uninstall.
194
194
  * Protected by the blanket `/api/*` auth middleware already applied upstream.
195
195
  */
196
- export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, documentBackend, frameworkShardIds = []) {
196
+ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, docStore, frameworkShardIds = []) {
197
197
  const router = new Hono();
198
198
  router.post('/install', async (c) => {
199
199
  const form = await c.req.formData();
@@ -267,7 +267,7 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
267
267
  writeFileSync(join(pkgDir, 'server.js'), buf);
268
268
  // Hot-mount server routes
269
269
  try {
270
- await shardRouter.mount(id, join(pkgDir, 'server.js'), { pkgDir, keys, settings, wsRegister, documentBackend });
270
+ await shardRouter.mount(id, join(pkgDir, 'server.js'), { pkgDir, keys, settings, wsRegister, docStore });
271
271
  }
272
272
  catch (err) {
273
273
  // Roll back entire install — broken server bundle must not be half-installed
@@ -297,7 +297,7 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
297
297
  return c.json({ error: `Package "${id}" not found` }, 404);
298
298
  }
299
299
  // Unmount server routes immediately
300
- shardRouter.unmount(id);
300
+ await shardRouter.unmount(id);
301
301
  // Remove code files
302
302
  rmSync(manifestPath, { force: true });
303
303
  const clientPath = join(pkgDir, 'client.js');
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * Document backend API routes.
3
3
  *
4
- * Maps the DocumentBackend interface to HTTP endpoints backed by the
5
- * local filesystem. Files are stored at {dataDir}/docs/{tenant}/{shard}/{path}.
4
+ * Delegates every read/write/list/delete to the TenantDocStore, which owns
5
+ * the metadata-sidecar write pipeline (version bump, syncMode cache, tick
6
+ * advance, conflict bucket). This router is intentionally thin: it validates
7
+ * auth and path shape, then calls the store.
6
8
  *
7
- * GET /api/docs/:tenant/_shards → listAllShards
8
- * GET /api/docs/:tenant/_all → listAllDocuments
9
+ * GET /api/docs/:tenant/_shards → listAll (shard ids)
10
+ * GET /api/docs/:tenant/_all → listAll
9
11
  * GET /api/docs/:tenant/:shard → list
10
12
  * GET /api/docs/:tenant/:shard/*path → read
11
13
  * HEAD /api/docs/:tenant/:shard/*path → exists
12
- * PUT /api/docs/:tenant/:shard/*path → write (auth required)
13
- * DELETE /api/docs/:tenant/:shard/*path → delete (auth required)
14
+ * PUT /api/docs/:tenant/:shard/*path → write
15
+ * DELETE /api/docs/:tenant/:shard/*path → delete
14
16
  */
15
17
  import { Hono } from 'hono';
16
- export declare function createDocsRouter(dataDir: string): Hono;
18
+ import type { SettingsStore } from '../settings.js';
19
+ import type { TenantDocStore } from '../doc-store/index.js';
20
+ export declare function createDocsRouter(store: TenantDocStore, settings?: SettingsStore): Hono;
@@ -1,162 +1,128 @@
1
1
  /**
2
2
  * Document backend API routes.
3
3
  *
4
- * Maps the DocumentBackend interface to HTTP endpoints backed by the
5
- * local filesystem. Files are stored at {dataDir}/docs/{tenant}/{shard}/{path}.
4
+ * Delegates every read/write/list/delete to the TenantDocStore, which owns
5
+ * the metadata-sidecar write pipeline (version bump, syncMode cache, tick
6
+ * advance, conflict bucket). This router is intentionally thin: it validates
7
+ * auth and path shape, then calls the store.
6
8
  *
7
- * GET /api/docs/:tenant/_shards → listAllShards
8
- * GET /api/docs/:tenant/_all → listAllDocuments
9
+ * GET /api/docs/:tenant/_shards → listAll (shard ids)
10
+ * GET /api/docs/:tenant/_all → listAll
9
11
  * GET /api/docs/:tenant/:shard → list
10
12
  * GET /api/docs/:tenant/:shard/*path → read
11
13
  * HEAD /api/docs/:tenant/:shard/*path → exists
12
- * PUT /api/docs/:tenant/:shard/*path → write (auth required)
13
- * DELETE /api/docs/:tenant/:shard/*path → delete (auth required)
14
+ * PUT /api/docs/:tenant/:shard/*path → write
15
+ * DELETE /api/docs/:tenant/:shard/*path → delete
14
16
  */
15
17
  import { Hono } from 'hono';
16
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, readdirSync, } from 'node:fs';
17
- import { join, dirname, relative } from 'node:path';
18
- export function createDocsRouter(dataDir) {
18
+ import { tenantParamMatch } from '../scope.js';
19
+ export function createDocsRouter(store, settings) {
19
20
  const router = new Hono();
20
- const docsDir = join(dataDir, 'docs');
21
- function resolvePath(tenant, shard, filePath) {
22
- // Prevent path traversal
23
- const resolved = join(docsDir, tenant, shard, filePath);
24
- if (!resolved.startsWith(join(docsDir, tenant, shard))) {
25
- throw new Error('Path traversal detected');
26
- }
27
- return resolved;
21
+ router.use('/:tenant/*', tenantParamMatch('tenant', settings));
22
+ router.use('/:tenant', tenantParamMatch('tenant', settings));
23
+ function isReservedShardId(shard) {
24
+ return shard.startsWith('__');
28
25
  }
29
- function collectFiles(dir, base) {
30
- const results = [];
31
- if (!existsSync(dir))
32
- return results;
33
- const entries = readdirSync(dir, { withFileTypes: true });
34
- for (const entry of entries) {
35
- const full = join(dir, entry.name);
36
- if (entry.isDirectory()) {
37
- results.push(...collectFiles(full, base));
38
- }
39
- else {
40
- const stat = statSync(full);
41
- results.push({
42
- path: relative(base, full).replace(/\\/g, '/'),
43
- size: stat.size,
44
- lastModified: stat.mtimeMs,
45
- });
46
- }
47
- }
48
- return results;
49
- }
50
- // List all shard ids that have content for a tenant.
51
- router.get('/:tenant/_shards', (c) => {
26
+ // Shards list
27
+ router.get('/:tenant/_shards', async (c) => {
52
28
  const { tenant } = c.req.param();
53
- const tenantDir = join(docsDir, tenant);
54
- if (!existsSync(tenantDir))
55
- return c.json([]);
56
- const entries = readdirSync(tenantDir, { withFileTypes: true });
57
- const shards = entries.filter((e) => e.isDirectory()).map((e) => e.name);
58
- return c.json(shards);
29
+ const all = await store.listAll(tenant);
30
+ const seen = new Set();
31
+ for (const e of all)
32
+ seen.add(e.shardId);
33
+ return c.json([...seen].filter((id) => !isReservedShardId(id)));
59
34
  });
60
- // Tenant-wide document list with shardId attached on each entry.
61
- router.get('/:tenant/_all', (c) => {
35
+ // All docs
36
+ router.get('/:tenant/_all', async (c) => {
62
37
  const { tenant } = c.req.param();
63
- const tenantDir = join(docsDir, tenant);
64
- if (!existsSync(tenantDir))
65
- return c.json([]);
66
- const entries = readdirSync(tenantDir, { withFileTypes: true });
67
- const out = [];
68
- for (const entry of entries) {
69
- if (!entry.isDirectory())
70
- continue;
71
- const shardDir = join(tenantDir, entry.name);
72
- for (const f of collectFiles(shardDir, shardDir)) {
73
- out.push({ ...f, shardId: entry.name });
74
- }
75
- }
76
- return c.json(out);
38
+ return c.json(await store.listAll(tenant));
77
39
  });
78
- // List documents for a tenant/shard
79
- router.get('/:tenant/:shard', (c) => {
40
+ // Per-shard list
41
+ router.get('/:tenant/:shard', async (c) => {
80
42
  const { tenant, shard } = c.req.param();
81
- const dir = join(docsDir, tenant, shard);
82
- const files = collectFiles(dir, dir);
83
- return c.json(files);
43
+ if (isReservedShardId(shard))
44
+ return c.notFound();
45
+ return c.json(await store.list(tenant, shard));
84
46
  });
85
- // Read a document
86
- router.get('/:tenant/:shard/*', (c) => {
47
+ // Read
48
+ router.get('/:tenant/:shard/*', async (c) => {
87
49
  const { tenant, shard } = c.req.param();
50
+ if (isReservedShardId(shard))
51
+ return c.notFound();
88
52
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
89
53
  if (!filePath)
90
54
  return c.json({ error: 'Missing file path' }, 400);
91
- let resolved;
92
- try {
93
- resolved = resolvePath(tenant, shard, filePath);
94
- }
95
- catch {
96
- return c.json({ error: 'Invalid path' }, 400);
55
+ if (c.req.query('meta') === '1') {
56
+ const meta = await store.readMeta(tenant, shard, filePath);
57
+ if (!meta)
58
+ return c.json({ exists: false });
59
+ const payload = { exists: true, ...meta };
60
+ if (meta.syncState === 'conflict') {
61
+ const cf = await store.readConflict(tenant, shard, filePath);
62
+ if (cf) {
63
+ payload.branches = cf.branches.map((b) => ({
64
+ origin: b.origin,
65
+ version: b.version,
66
+ at: b.at,
67
+ }));
68
+ }
69
+ }
70
+ return c.json(payload);
97
71
  }
98
- if (!existsSync(resolved)) {
72
+ const content = await store.read(tenant, shard, filePath);
73
+ if (content === null)
99
74
  return c.notFound();
100
- }
101
- const content = readFileSync(resolved);
102
- // Detect binary vs text heuristically: if the file can be decoded as
103
- // UTF-8 without replacement characters, treat as text.
104
- const text = new TextDecoder('utf-8', { fatal: true });
105
- try {
106
- const str = text.decode(content);
107
- return c.text(str);
108
- }
109
- catch {
110
- return new Response(content, {
111
- headers: { 'Content-Type': 'application/octet-stream' },
112
- });
113
- }
75
+ return c.text(content);
114
76
  });
115
- // Check existence via HEAD request
116
- router.on('HEAD', '/:tenant/:shard/*', (c) => {
77
+ // Exists
78
+ router.on('HEAD', '/:tenant/:shard/*', async (c) => {
117
79
  const { tenant, shard } = c.req.param();
80
+ if (isReservedShardId(shard))
81
+ return c.notFound();
118
82
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
119
- let resolved;
120
- try {
121
- resolved = resolvePath(tenant, shard, filePath);
83
+ return new Response(null, {
84
+ status: (await store.exists(tenant, shard, filePath)) ? 200 : 404,
85
+ });
86
+ });
87
+ // Conflict resolve — must match before the generic PUT so the `/resolve`
88
+ // suffix isn't captured as part of the file path.
89
+ router.post('/:tenant/:shard/*', async (c) => {
90
+ const { tenant, shard } = c.req.param();
91
+ if (isReservedShardId(shard))
92
+ return c.json({ error: 'Reserved shard id' }, 400);
93
+ const rawPath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
94
+ if (!rawPath.endsWith('/resolve')) {
95
+ return c.json({ error: 'Unknown docs POST endpoint' }, 404);
122
96
  }
123
- catch {
124
- return new Response(null, { status: 400 });
97
+ const filePath = rawPath.replace(/\/resolve$/, '');
98
+ if (!filePath)
99
+ return c.json({ error: 'Missing file path' }, 400);
100
+ const body = await c.req.json().catch(() => null);
101
+ if (!body || typeof body.choice === 'undefined') {
102
+ return c.json({ error: 'Body must include { choice }' }, 400);
125
103
  }
126
- return new Response(null, { status: existsSync(resolved) ? 200 : 404 });
104
+ await store.resolveConflict(tenant, shard, filePath, body.choice);
105
+ return c.json({ ok: true });
127
106
  });
128
- // Write a document (auth required via middleware)
107
+ // Write
129
108
  router.put('/:tenant/:shard/*', async (c) => {
130
109
  const { tenant, shard } = c.req.param();
110
+ if (isReservedShardId(shard))
111
+ return c.json({ error: 'Reserved shard id' }, 400);
131
112
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
132
113
  if (!filePath)
133
114
  return c.json({ error: 'Missing file path' }, 400);
134
- let resolved;
135
- try {
136
- resolved = resolvePath(tenant, shard, filePath);
137
- }
138
- catch {
139
- return c.json({ error: 'Invalid path' }, 400);
140
- }
141
- mkdirSync(dirname(resolved), { recursive: true });
142
- const body = await c.req.arrayBuffer();
143
- writeFileSync(resolved, Buffer.from(body));
144
- return c.json({ ok: true });
115
+ const body = await c.req.text();
116
+ const result = await store.write(tenant, shard, filePath, body);
117
+ return c.json({ ok: true, ...result });
145
118
  });
146
- // Delete a document (auth required via middleware)
147
- router.delete('/:tenant/:shard/*', (c) => {
119
+ // Delete
120
+ router.delete('/:tenant/:shard/*', async (c) => {
148
121
  const { tenant, shard } = c.req.param();
122
+ if (isReservedShardId(shard))
123
+ return c.json({ error: 'Reserved shard id' }, 400);
149
124
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
150
- let resolved;
151
- try {
152
- resolved = resolvePath(tenant, shard, filePath);
153
- }
154
- catch {
155
- return c.json({ error: 'Invalid path' }, 400);
156
- }
157
- if (existsSync(resolved)) {
158
- unlinkSync(resolved);
159
- }
125
+ await store.delete(tenant, shard, filePath);
160
126
  return c.json({ ok: true });
161
127
  });
162
128
  return router;
@@ -62,7 +62,8 @@ export function createKeysRouter(keys, onRevoke) {
62
62
  shardId: body.shardId,
63
63
  label: body.label,
64
64
  scopes: body.scopes,
65
- connectorId: body.connectorId,
65
+ peerRole: body.peerRole,
66
+ peerId: body.peerId,
66
67
  expiresIn: body.expiresIn,
67
68
  tenantId: caller.tenantId,
68
69
  userId: caller.userId,
@@ -102,7 +103,8 @@ export function createKeysRouter(keys, onRevoke) {
102
103
  ownerUserId: entry.userId,
103
104
  mintedByShardId: entry.shardId,
104
105
  scopes: entry.scopes,
105
- connectorId: entry.connectorId,
106
+ peerRole: entry.peerRole,
107
+ peerId: entry.peerId,
106
108
  expiresAt,
107
109
  });
108
110
  return c.json({ id: row.id, key: row.key });
package/dist/scope.d.ts CHANGED
@@ -5,5 +5,7 @@
5
5
  * tenantRequired — 401 unless caller.tenantId is non-null.
6
6
  */
7
7
  import type { MiddlewareHandler } from 'hono';
8
+ import type { SettingsStore } from './settings.js';
8
9
  export declare function scopeRequired(scope: string): MiddlewareHandler;
9
10
  export declare const tenantRequired: MiddlewareHandler;
11
+ export declare function tenantParamMatch(paramName: string, settings?: SettingsStore): MiddlewareHandler;
package/dist/scope.js CHANGED
@@ -23,3 +23,23 @@ export const tenantRequired = async (c, next) => {
23
23
  return c.json({ error: 'Tenant-scoped credentials required' }, 401);
24
24
  return next();
25
25
  };
26
+ export function tenantParamMatch(paramName, settings) {
27
+ return async (c, next) => {
28
+ // Open / no-auth mode: admin has explicitly disabled tenant isolation.
29
+ // Mirrors sessionAuth's open-mode bypass; required for Tauri sidecar mode.
30
+ if (settings && !settings.get().auth.required)
31
+ return next();
32
+ const caller = c.get('caller');
33
+ if (!caller)
34
+ return c.json({ error: 'Caller not resolved' }, 500);
35
+ if (caller.scopes.includes('admin:*'))
36
+ return next();
37
+ if (!caller.tenantId)
38
+ return c.json({ error: 'Tenant-scoped credentials required' }, 401);
39
+ const requested = c.req.param(paramName);
40
+ if (caller.tenantId !== requested) {
41
+ return c.json({ error: `Caller tenant does not match :${paramName}` }, 403);
42
+ }
43
+ return next();
44
+ };
45
+ }
@@ -2,12 +2,12 @@ import { Hono } from 'hono';
2
2
  import type { MiddlewareHandler } from 'hono';
3
3
  import type { KeyStore } from './keys.js';
4
4
  import type { SettingsStore } from './settings.js';
5
+ import type { TenantDocStore } from './doc-store/index.js';
5
6
  export interface MountContext {
6
7
  pkgDir: string;
7
8
  keys: KeyStore;
8
9
  settings: SettingsStore;
9
- /** The server's document backend, for sync handle construction. */
10
- documentBackend: import('sh3-core/server-sync').DocumentBackend;
10
+ docStore: TenantDocStore;
11
11
  /**
12
12
  * Register a WebSocket upgrade handler on a path under this shard's
13
13
  * route prefix. The returned value is a Hono middleware handler that
@@ -49,16 +49,12 @@ export interface MountContext {
49
49
  }
50
50
  /** Middleware requiring the caller's scope set to include admin:*. */
51
51
  export declare function adminOnly(_keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
52
- /**
53
- * Build a ServerShardContext object for the given shard, wiring
54
- * sync/syncRegistry based on the declared permissions.
55
- */
56
- export declare function buildShardCtx(shardId: string, dataDir: string, permissions: string[], keys: KeyStore, settings: SettingsStore, wsRegister: MountContext['wsRegister'], documentBackend: MountContext['documentBackend']): Record<string, unknown>;
57
52
  /**
58
53
  * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
59
54
  * and delegates requests from a single wildcard route.
60
55
  */
61
56
  export declare class ShardRouter {
57
+ #private;
62
58
  private shards;
63
59
  /**
64
60
  * Import a server.js bundle and mount its routes.
@@ -73,9 +69,12 @@ export declare class ShardRouter {
73
69
  mountStatic(shardId: string, mod: {
74
70
  id: string;
75
71
  routes: (router: Hono, ctx: any) => void | Promise<void>;
72
+ teardown?: () => void | Promise<void>;
76
73
  }, ctx: MountContext): Promise<void>;
77
- /** Remove a shard's routes. */
78
- unmount(shardId: string): boolean;
74
+ /** Remove a shard's routes. Calls `teardown()` if the shard defined one. */
75
+ unmount(shardId: string): Promise<boolean>;
76
+ /** Call teardown on every mounted shard without clearing the registry. */
77
+ unmountAll(): Promise<void>;
79
78
  /**
80
79
  * Hono handler for the wildcard route.
81
80
  * Looks up the shard sub-app and delegates with path stripping.