sh3-server 0.19.6 → 0.20.2

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 (43) hide show
  1. package/app/assets/{icons-nOyIoORC.svg → icons-OMmH0JiM.svg} +5 -0
  2. package/app/assets/index-C6sN7l5X.js +26 -0
  3. package/app/assets/index-C6sN7l5X.js.map +1 -0
  4. package/app/assets/index-DbnwAWBS.css +1 -0
  5. package/app/index.html +2 -2
  6. package/dist/auth.d.ts +7 -11
  7. package/dist/auth.js +7 -19
  8. package/dist/cli.js +2 -2
  9. package/dist/doc-store/store.d.ts +12 -0
  10. package/dist/doc-store/store.js +172 -3
  11. package/dist/index.d.ts +5 -2
  12. package/dist/index.js +21 -12
  13. package/dist/middleware/project-allowlist.d.ts +4 -0
  14. package/dist/middleware/project-allowlist.js +26 -12
  15. package/dist/mounts/resolver.d.ts +21 -0
  16. package/dist/mounts/resolver.js +41 -0
  17. package/dist/mounts/routes.d.ts +4 -0
  18. package/dist/mounts/routes.js +136 -0
  19. package/dist/mounts/store.d.ts +30 -0
  20. package/dist/mounts/store.js +115 -0
  21. package/dist/routes/admin.d.ts +3 -1
  22. package/dist/routes/admin.js +6 -1
  23. package/dist/routes/boot.d.ts +7 -1
  24. package/dist/routes/boot.js +13 -4
  25. package/dist/routes/docs.js +83 -2
  26. package/dist/routes/projects.d.ts +1 -3
  27. package/dist/routes/projects.js +1 -3
  28. package/dist/scope.d.ts +1 -2
  29. package/dist/scope.js +1 -5
  30. package/dist/settings.d.ts +0 -1
  31. package/dist/settings.js +0 -4
  32. package/dist/shard-router.d.ts +1 -1
  33. package/dist/shard-router.js +23 -4
  34. package/dist/tenant-fs/http.d.ts +0 -2
  35. package/dist/tenant-fs/http.js +1 -1
  36. package/dist/tenant-fs/session-required.d.ts +1 -3
  37. package/dist/tenant-fs/session-required.js +1 -4
  38. package/dist/users.d.ts +14 -1
  39. package/dist/users.js +34 -0
  40. package/package.json +1 -1
  41. package/app/assets/index--m0u3gjJ.js +0 -21
  42. package/app/assets/index--m0u3gjJ.js.map +0 -1
  43. package/app/assets/index-DIpoXNrk.css +0 -1
@@ -6,8 +6,8 @@
6
6
  * conflict bucket on Mode B mismatch. Consumed by the docs HTTP router
7
7
  * and by the ServerShardContext.documents(tenant) API.
8
8
  */
9
- import { readFile, writeFile, mkdir, rm, readdir, stat, rename as fsRename } from 'node:fs/promises';
10
- import { readdirSync, existsSync } from 'node:fs';
9
+ import { readFile, writeFile, mkdir, rm, rmdir as fsRmdir, readdir, stat, rename as fsRename } from 'node:fs/promises';
10
+ import { readdirSync, existsSync, statSync } from 'node:fs';
11
11
  import { dirname, join, relative } from 'node:path';
12
12
  import { resolveSyncMode } from './policy.js';
13
13
  import { filterReservedMeta } from './reserved.js';
@@ -18,15 +18,25 @@ export class TenantDocStore {
18
18
  #tick;
19
19
  roles;
20
20
  #conflicts;
21
+ #mountResolver;
21
22
  constructor(deps) {
22
23
  this.#dataDir = deps.dataDir;
23
24
  this.#policy = deps.policy;
24
25
  this.#tick = deps.tick;
25
26
  this.roles = deps.roles;
26
27
  this.#conflicts = deps.conflicts;
28
+ this.#mountResolver = deps.mountResolver;
27
29
  }
28
30
  /** Root filesystem directory this store reads/writes under. */
29
31
  get dataDir() { return this.#dataDir; }
32
+ /** Set the mount resolver after construction (used when the resolver is created after the store). */
33
+ setMountResolver(resolver) {
34
+ this.#mountResolver = resolver;
35
+ }
36
+ /** Mount resolver wired by setMountResolver, or undefined when running without the mount system. */
37
+ get mountResolver() {
38
+ return this.#mountResolver;
39
+ }
30
40
  /** Synchronous tenant enumeration for shard-ctx's `tenants()` entry point. */
31
41
  listTenantsSync() {
32
42
  const root = join(this.#dataDir, 'docs');
@@ -61,8 +71,27 @@ export class TenantDocStore {
61
71
  #contentPath(tenant, shardId, path) {
62
72
  return join(this.#dataDir, 'docs', tenant, shardId, path);
63
73
  }
74
+ #isMountPath(shardId) {
75
+ return shardId === 'mounts' && this.#mountResolver !== undefined;
76
+ }
77
+ #resolveMountPath(tenant, path) {
78
+ const resolved = this.#mountResolver.resolve(tenant, path);
79
+ if (resolved.kind === 'mount')
80
+ return resolved.realPath;
81
+ throw new Error(resolved.kind === 'mount-unresolved' ? resolved.error : 'Invalid mount path');
82
+ }
64
83
  // ---------- reads ----------
65
84
  async read(tenant, shardId, path) {
85
+ if (this.#isMountPath(shardId)) {
86
+ try {
87
+ return await readFile(this.#resolveMountPath(tenant, path), 'utf-8');
88
+ }
89
+ catch (err) {
90
+ if (err?.code === 'ENOENT')
91
+ return null;
92
+ throw err;
93
+ }
94
+ }
66
95
  try {
67
96
  return await readFile(this.#contentPath(tenant, shardId, path), 'utf-8');
68
97
  }
@@ -76,6 +105,15 @@ export class TenantDocStore {
76
105
  return readMeta(this.#dataDir, tenant, shardId, path);
77
106
  }
78
107
  async exists(tenant, shardId, path) {
108
+ if (this.#isMountPath(shardId)) {
109
+ try {
110
+ await stat(this.#resolveMountPath(tenant, path));
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
79
117
  try {
80
118
  await stat(this.#contentPath(tenant, shardId, path));
81
119
  return true;
@@ -85,6 +123,29 @@ export class TenantDocStore {
85
123
  }
86
124
  }
87
125
  async list(tenant, shardId) {
126
+ if (this.#isMountPath(shardId)) {
127
+ const mountIds = this.#mountResolver.listTenantMountIds(tenant);
128
+ const result = [];
129
+ for (const mountId of mountIds) {
130
+ const mount = this.#mountResolver.getMount(mountId);
131
+ if (!mount)
132
+ continue;
133
+ try {
134
+ const entries = readdirSync(mount.path, { withFileTypes: true });
135
+ for (const e of entries) {
136
+ const full = join(mount.path, e.name);
137
+ const s = statSync(full);
138
+ result.push({
139
+ path: `mounts/${mountId}/${e.name}`,
140
+ size: s.size,
141
+ lastModified: s.mtimeMs,
142
+ });
143
+ }
144
+ }
145
+ catch { /* mount path not available, skip */ }
146
+ }
147
+ return result;
148
+ }
88
149
  const root = join(this.#dataDir, 'docs', tenant, shardId);
89
150
  return this.#enumerate(root, root, tenant, shardId);
90
151
  }
@@ -97,7 +158,7 @@ export class TenantDocStore {
97
158
  }
98
159
  catch (err) {
99
160
  if (err?.code === 'ENOENT')
100
- return [];
161
+ entries = [];
101
162
  throw err;
102
163
  }
103
164
  for (const e of entries) {
@@ -110,6 +171,29 @@ export class TenantDocStore {
110
171
  out.push({ ...m, shardId: e.name });
111
172
  }
112
173
  }
174
+ // Append mount entries
175
+ if (this.#mountResolver) {
176
+ const mountIds = this.#mountResolver.listTenantMountIds(tenant);
177
+ for (const mountId of mountIds) {
178
+ const mount = this.#mountResolver.getMount(mountId);
179
+ if (!mount)
180
+ continue;
181
+ try {
182
+ const entries = readdirSync(mount.path, { withFileTypes: true });
183
+ for (const e of entries) {
184
+ const full = join(mount.path, e.name);
185
+ const s = statSync(full);
186
+ out.push({
187
+ shardId: 'mounts',
188
+ path: `mounts/${mountId}/${e.name}`,
189
+ size: s.size,
190
+ lastModified: s.mtimeMs,
191
+ });
192
+ }
193
+ }
194
+ catch { /* skip unavailable mount */ }
195
+ }
196
+ }
113
197
  return out;
114
198
  }
115
199
  async #enumerate(current, base, tenant, shardId) {
@@ -153,6 +237,12 @@ export class TenantDocStore {
153
237
  }
154
238
  // ---------- Mode A write ----------
155
239
  async write(tenant, shardId, path, content, metadata) {
240
+ if (this.#isMountPath(shardId)) {
241
+ const realPath = this.#resolveMountPath(tenant, path);
242
+ await mkdir(dirname(realPath), { recursive: true });
243
+ await writeFile(realPath, content);
244
+ return { version: 1, syncState: 'synced' };
245
+ }
156
246
  const role = this.roles.get(tenant);
157
247
  const policy = await this.#policy.get(tenant);
158
248
  const syncMode = resolveSyncMode(policy, path);
@@ -192,6 +282,16 @@ export class TenantDocStore {
192
282
  }
193
283
  // ---------- Mode A delete ----------
194
284
  async delete(tenant, shardId, path) {
285
+ if (this.#isMountPath(shardId)) {
286
+ try {
287
+ await rm(this.#resolveMountPath(tenant, path));
288
+ }
289
+ catch (err) {
290
+ if (err?.code !== 'ENOENT')
291
+ throw err;
292
+ }
293
+ return;
294
+ }
195
295
  const role = this.roles.get(tenant);
196
296
  const prev = await readMeta(this.#dataDir, tenant, shardId, path);
197
297
  const prevKnown = typeof prev?.lastKnownVersion === 'number' ? prev.lastKnownVersion : 0;
@@ -227,6 +327,13 @@ export class TenantDocStore {
227
327
  }
228
328
  // ---------- Mode A rename ----------
229
329
  async rename(tenant, shardId, oldPath, newPath) {
330
+ if (this.#isMountPath(shardId)) {
331
+ const oldReal = this.#resolveMountPath(tenant, oldPath);
332
+ const newReal = this.#resolveMountPath(tenant, newPath);
333
+ await mkdir(dirname(newReal), { recursive: true });
334
+ await fsRename(oldReal, newReal);
335
+ return;
336
+ }
230
337
  const oldCp = this.#contentPath(tenant, shardId, oldPath);
231
338
  const newCp = this.#contentPath(tenant, shardId, newPath);
232
339
  if (!(await this.exists(tenant, shardId, oldPath))) {
@@ -269,6 +376,68 @@ export class TenantDocStore {
269
376
  if (role === 'primary')
270
377
  await this.#tick.bump(tenant);
271
378
  }
379
+ // ---------- Folder ops ----------
380
+ async mkdir(tenant, shardId, path) {
381
+ const abs = this.#contentPath(tenant, shardId, path);
382
+ if (existsSync(abs)) {
383
+ const st = statSync(abs);
384
+ if (st.isFile()) {
385
+ throw new Error(`Cannot mkdir ${path}: a document occupies this path`);
386
+ }
387
+ return;
388
+ }
389
+ await mkdir(abs, { recursive: true });
390
+ }
391
+ async rmdir(tenant, shardId, path, opts) {
392
+ const abs = this.#contentPath(tenant, shardId, path);
393
+ if (!existsSync(abs))
394
+ return;
395
+ if (!opts.recursive) {
396
+ const entries = await readdir(abs);
397
+ if (entries.length > 0) {
398
+ throw new Error(`Cannot rmdir ${path}: folder is not empty`);
399
+ }
400
+ }
401
+ if (opts.recursive) {
402
+ await rm(abs, { recursive: true, force: true });
403
+ }
404
+ else {
405
+ await fsRmdir(abs);
406
+ }
407
+ }
408
+ async renameFolder(tenant, shardId, oldPath, newPath) {
409
+ const oldAbs = this.#contentPath(tenant, shardId, oldPath);
410
+ const newAbs = this.#contentPath(tenant, shardId, newPath);
411
+ if (!existsSync(oldAbs)) {
412
+ throw new Error(`Cannot rename folder ${oldPath}: does not exist`);
413
+ }
414
+ if (existsSync(newAbs)) {
415
+ throw new Error(`Cannot rename folder to ${newPath}: already exists`);
416
+ }
417
+ await mkdir(dirname(newAbs), { recursive: true });
418
+ await fsRename(oldAbs, newAbs);
419
+ // Migrate meta sidecars: they live at {dataDir}/docs/{tenant}/__meta__/{shardId}/{docPath}.meta.json
420
+ // which is outside the content folder, so fs.rename above doesn't move them.
421
+ const metaRoot = join(this.#dataDir, 'docs', tenant, '__meta__', shardId);
422
+ const oldMetaDir = join(metaRoot, oldPath);
423
+ if (existsSync(oldMetaDir)) {
424
+ const newMetaDir = join(metaRoot, newPath);
425
+ await mkdir(dirname(newMetaDir), { recursive: true });
426
+ await fsRename(oldMetaDir, newMetaDir);
427
+ }
428
+ }
429
+ async listFolders(tenant, shardId, prefix) {
430
+ const abs = prefix
431
+ ? this.#contentPath(tenant, shardId, prefix)
432
+ : join(this.#dataDir, 'docs', tenant, shardId);
433
+ if (!existsSync(abs))
434
+ return [];
435
+ const entries = await readdir(abs, { withFileTypes: true });
436
+ return entries
437
+ .filter((e) => e.isDirectory())
438
+ .map((e) => e.name)
439
+ .sort();
440
+ }
272
441
  // ---------- Mode B — applyFromPeer ----------
273
442
  /**
274
443
  * Apply a document version received from a peer.
package/dist/index.d.ts CHANGED
@@ -17,8 +17,11 @@ export interface ServerOptions {
17
17
  dataDir?: string;
18
18
  /** Directory containing the built SH3 frontend. Default: './dist' */
19
19
  distDir?: string;
20
- /** Disable auth enforcement (for Tauri sidecar / local-owner mode). */
21
- noAuth?: boolean;
20
+ /**
21
+ * Desktop sidecar mode. Persists a synthetic `local` admin user and lets
22
+ * /api/boot auto-mint sessions for it. Replaces the legacy noAuth flag.
23
+ */
24
+ localMode?: boolean;
22
25
  }
23
26
  export declare function createServer(options?: ServerOptions): Promise<{
24
27
  app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
package/dist/index.js CHANGED
@@ -35,6 +35,8 @@ import { ShardRouter, adminOnly } from './shard-router.js';
35
35
  import { scopeRequired, requireCallerScope } from './scope.js';
36
36
  import { registerTenantFsRoutes } from './tenant-fs/index.js';
37
37
  import shellShardServer from './shell-shard/index.js';
38
+ import { MountStore } from './mounts/store.js';
39
+ import { MountedPathResolver } from './mounts/resolver.js';
38
40
  export async function createServer(options = {}) {
39
41
  const port = options.port ?? 3000;
40
42
  const dataDir = options.dataDir ?? './data';
@@ -53,9 +55,17 @@ export async function createServer(options = {}) {
53
55
  const users = new UserStore(dataDir);
54
56
  const settings = new SettingsStore(dataDir);
55
57
  const projects = new ProjectStore(dataDir);
56
- // --no-auth: disable auth enforcement (Tauri sidecar / local-owner mode)
57
- if (options.noAuth) {
58
- settings.update({ auth: { required: false, guestAllowed: true } });
58
+ // Mount system
59
+ const mountStore = new MountStore(dataDir);
60
+ const mountResolver = new MountedPathResolver(mountStore);
61
+ // --local: bootstrap the synthetic owner so guards see a real admin session.
62
+ if (options.localMode) {
63
+ users.upsertSynthetic({
64
+ id: 'local',
65
+ username: 'local',
66
+ displayName: 'Local Owner',
67
+ role: 'admin',
68
+ });
59
69
  }
60
70
  const sessions = new SessionStore(settings.get().auth.sessionTTL);
61
71
  const app = new Hono();
@@ -113,16 +123,16 @@ export async function createServer(options = {}) {
113
123
  }
114
124
  app.get('/api/version', (c) => c.json({ version: serverVersion }));
115
125
  // Boot config
116
- app.route('/api/boot', createBootRouter(sessions, users, settings, serverVersion));
126
+ app.route('/api/boot', createBootRouter(sessions, users, settings, serverVersion, options.localMode ?? false));
117
127
  // Auth endpoints (login, logout, register, verify)
118
128
  app.route('/api/auth', createAuthRouter(keys, users, sessions, settings));
119
129
  // --- Session-gated routes ---
120
- app.use('/api/*', sessionAuth(sessions, settings, keys));
130
+ app.use('/api/*', sessionAuth(sessions, keys));
121
131
  app.use('/api/*', resolveCaller(keys, projects));
122
132
  // Document backend API — gated by sessionAuth + resolveCaller (mounted above).
123
133
  // The router itself enforces scopeAccessMatch and the __-prefix reservation.
124
- // Settings is threaded so scopeAccessMatch respects open / no-auth mode.
125
134
  const docStore = createScopedDocStore(dataDir);
135
+ docStore.setMountResolver(mountResolver);
126
136
  app.route('/api/docs', createDocsRouter(docStore, {
127
137
  settings,
128
138
  projectStore: projects,
@@ -183,12 +193,12 @@ export async function createServer(options = {}) {
183
193
  });
184
194
  // --- Admin-gated routes ---
185
195
  // Admin API
186
- const adminRouter = createAdminRouter(users, settings, sessions, keys, dataDir);
187
- app.use('/api/admin/*', adminAuth(sessions, keys, settings));
196
+ const adminRouter = createAdminRouter(users, settings, sessions, keys, dataDir, mountStore, mountResolver);
197
+ app.use('/api/admin/*', adminAuth());
188
198
  app.route('/api/admin', adminRouter);
189
199
  // Package management (install/uninstall) — admin-gated
190
- app.use('/api/packages/install', adminAuth(sessions, keys, settings));
191
- app.use('/api/packages/uninstall', adminAuth(sessions, keys, settings));
200
+ app.use('/api/packages/install', adminAuth());
201
+ app.use('/api/packages/uninstall', adminAuth());
192
202
  const frameworkShardIds = ['__sh3core__', 'shell', 'sh3-store', 'sh3-admin'];
193
203
  app.route('/api/packages', createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, docStore, frameworkShardIds));
194
204
  // Serve client bundles from discovered packages
@@ -213,7 +223,7 @@ export async function createServer(options = {}) {
213
223
  shardId: 'shell',
214
224
  dataDir: shellDataDir,
215
225
  permissions: [],
216
- adminOnly: adminOnly(keys, settings),
226
+ adminOnly: adminOnly(),
217
227
  scopeRequired,
218
228
  tenantRequired: requireCallerScope,
219
229
  wsRegister,
@@ -225,7 +235,6 @@ export async function createServer(options = {}) {
225
235
  registerTenantFsRoutes(app, {
226
236
  dataDir,
227
237
  rootBase: settings.get().tenants?.rootBase ?? '',
228
- settings,
229
238
  maxReadBytes: 10 * 1024 * 1024,
230
239
  });
231
240
  // Dynamic shard routes (packages). The catch-all comes after static
@@ -5,6 +5,10 @@
5
5
  * only target shards owned by allowlisted apps, plus the framework shards
6
6
  * listed in FRAMEWORK_SHARDS. Personal scopes are not subject to allowlists.
7
7
  *
8
+ * An empty appAllowlist means no restrictions — all apps can write to the
9
+ * project. When at least one app is listed, only that app's required shards
10
+ * (plus framework shards) are permitted.
11
+ *
8
12
  * Reads (GET / HEAD) are unrestricted; the membership check in
9
13
  * scopeAccessMatch already gates access to project data. This middleware
10
14
  * is layered on top to scope-down what apps can persist into the
@@ -5,6 +5,10 @@
5
5
  * only target shards owned by allowlisted apps, plus the framework shards
6
6
  * listed in FRAMEWORK_SHARDS. Personal scopes are not subject to allowlists.
7
7
  *
8
+ * An empty appAllowlist means no restrictions — all apps can write to the
9
+ * project. When at least one app is listed, only that app's required shards
10
+ * (plus framework shards) are permitted.
11
+ *
8
12
  * Reads (GET / HEAD) are unrestricted; the membership check in
9
13
  * scopeAccessMatch already gates access to project data. This middleware
10
14
  * is layered on top to scope-down what apps can persist into the
@@ -32,18 +36,28 @@ export function projectAppAllowlist(opts) {
32
36
  if (FRAMEWORK_SHARDS.includes(shard))
33
37
  return next();
34
38
  const allowed = new Set(FRAMEWORK_SHARDS);
35
- for (const appId of project.appAllowlist) {
36
- const app = opts.appRegistry.get(appId);
37
- if (!app)
38
- continue;
39
- for (const s of app.manifest.requiredShards)
40
- allowed.add(s);
39
+ // Empty allowlist means no restrictions — all apps can write.
40
+ if (project.appAllowlist.length > 0) {
41
+ for (const appId of project.appAllowlist) {
42
+ const app = opts.appRegistry.get(appId);
43
+ if (app) {
44
+ for (const s of app.manifest.requiredShards)
45
+ allowed.add(s);
46
+ }
47
+ else {
48
+ // App not found in the server manifest registry — fall back to
49
+ // allowing the appId itself as a shard id (common convention).
50
+ allowed.add(appId);
51
+ }
52
+ }
53
+ if (!allowed.has(shard)) {
54
+ return c.json({
55
+ error: `Shard "${shard}" is not in the project's app allowlist`,
56
+ project: project.id,
57
+ }, 403);
58
+ }
41
59
  }
42
- if (allowed.has(shard))
43
- return next();
44
- return c.json({
45
- error: `Shard "${shard}" is not in the project's app allowlist`,
46
- project: project.id,
47
- }, 403);
60
+ // Empty allowlist or shard in allowed set → pass.
61
+ return next();
48
62
  };
49
63
  }
@@ -0,0 +1,21 @@
1
+ import type { MountStore, MountRecord } from './store.js';
2
+ export type MountStatus = 'resolved' | 'unresolved' | 'error';
3
+ export type ResolvedPath = {
4
+ kind: 'mount';
5
+ mountId: string;
6
+ realPath: string;
7
+ } | {
8
+ kind: 'native';
9
+ } | {
10
+ kind: 'mount-unresolved';
11
+ mountId: string;
12
+ error: string;
13
+ };
14
+ export declare class MountedPathResolver {
15
+ #private;
16
+ constructor(store: MountStore);
17
+ resolve(tenantId: string, docPath: string): ResolvedPath;
18
+ probe(mountId: string): MountStatus;
19
+ listTenantMountIds(tenantId: string): string[];
20
+ getMount(mountId: string): MountRecord | null;
21
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve as pathResolve } from 'node:path';
3
+ export class MountedPathResolver {
4
+ #store;
5
+ constructor(store) {
6
+ this.#store = store;
7
+ }
8
+ resolve(tenantId, docPath) {
9
+ const mountMatch = docPath.match(/^mounts\/([^/]+)(?:\/(.*))?$/);
10
+ if (!mountMatch)
11
+ return { kind: 'native' };
12
+ const mountId = mountMatch[1];
13
+ const subPath = mountMatch[2] ?? '';
14
+ const mount = this.#store.get(mountId);
15
+ if (!mount) {
16
+ return { kind: 'mount-unresolved', mountId, error: `Mount "${mountId}" not found` };
17
+ }
18
+ if (!this.#store.isAttached(mountId, tenantId)) {
19
+ return { kind: 'mount-unresolved', mountId, error: `Mount "${mountId}" not attached to tenant "${tenantId}"` };
20
+ }
21
+ const realPath = subPath ? join(mount.path, subPath) : mount.path;
22
+ return { kind: 'mount', mountId, realPath: pathResolve(realPath) };
23
+ }
24
+ probe(mountId) {
25
+ const mount = this.#store.get(mountId);
26
+ if (!mount)
27
+ return 'error';
28
+ try {
29
+ return existsSync(mount.path) ? 'resolved' : 'unresolved';
30
+ }
31
+ catch {
32
+ return 'error';
33
+ }
34
+ }
35
+ listTenantMountIds(tenantId) {
36
+ return this.#store.listTenantAttachments(tenantId).map(a => a.mountId);
37
+ }
38
+ getMount(mountId) {
39
+ return this.#store.get(mountId);
40
+ }
41
+ }
@@ -0,0 +1,4 @@
1
+ import { Hono } from 'hono';
2
+ import type { MountStore } from './store.js';
3
+ import type { MountedPathResolver } from './resolver.js';
4
+ export declare function createMountsRouter(store: MountStore, resolver: MountedPathResolver): Hono;
@@ -0,0 +1,136 @@
1
+ import { Hono } from 'hono';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export function createMountsRouter(store, resolver) {
5
+ const router = new Hono();
6
+ // --- Mounts CRUD ---
7
+ router.get('/mounts', (c) => {
8
+ const mounts = store.list().map(m => ({
9
+ ...m,
10
+ status: resolver.probe(m.id),
11
+ attachmentCount: store.listAttachments(m.id).length,
12
+ }));
13
+ return c.json(mounts);
14
+ });
15
+ router.get('/mounts/:id', (c) => {
16
+ const id = c.req.param('id');
17
+ const mount = store.get(id);
18
+ if (!mount)
19
+ return c.json({ error: 'Mount not found' }, 404);
20
+ return c.json({
21
+ ...mount,
22
+ status: resolver.probe(id),
23
+ attachmentCount: store.listAttachments(id).length,
24
+ });
25
+ });
26
+ router.post('/mounts', async (c) => {
27
+ let body;
28
+ try {
29
+ body = await c.req.json();
30
+ }
31
+ catch {
32
+ return c.json({ error: 'Invalid JSON body' }, 400);
33
+ }
34
+ if (!body.id || !body.path) {
35
+ return c.json({ error: 'id and path required' }, 400);
36
+ }
37
+ try {
38
+ const mount = store.create({ id: body.id, label: body.label, path: body.path });
39
+ const status = resolver.probe(mount.id);
40
+ const response = { ...mount, status };
41
+ if (status !== 'resolved') {
42
+ response.warning = `Path "${mount.path}" does not exist on the server. Mount created but will be unavailable until the path exists.`;
43
+ }
44
+ return c.json(response, 201);
45
+ }
46
+ catch (err) {
47
+ return c.json({ error: err instanceof Error ? err.message : 'Failed to create mount' }, 409);
48
+ }
49
+ });
50
+ router.put('/mounts/:id', async (c) => {
51
+ const id = c.req.param('id');
52
+ let body;
53
+ try {
54
+ body = await c.req.json();
55
+ }
56
+ catch {
57
+ return c.json({ error: 'Invalid JSON body' }, 400);
58
+ }
59
+ const updated = store.update(id, body ?? {});
60
+ if (!updated)
61
+ return c.json({ error: 'Mount not found' }, 404);
62
+ return c.json({ ...updated, status: resolver.probe(id) });
63
+ });
64
+ router.delete('/mounts/:id', (c) => {
65
+ const id = c.req.param('id');
66
+ if (!store.delete(id))
67
+ return c.json({ error: 'Mount not found' }, 404);
68
+ return c.body(null, 204);
69
+ });
70
+ // --- Browse mount directory ---
71
+ router.get('/mounts/:id/browse', (c) => {
72
+ const id = c.req.param('id');
73
+ const mount = store.get(id);
74
+ if (!mount)
75
+ return c.json({ error: 'Mount not found' }, 404);
76
+ try {
77
+ const entries = readdirSync(mount.path, { withFileTypes: true });
78
+ const listing = entries.map(e => ({
79
+ name: e.name,
80
+ kind: e.isDirectory() ? 'directory' : 'file',
81
+ ...(e.isFile() ? { size: statSync(join(mount.path, e.name)).size } : {}),
82
+ }));
83
+ return c.json(listing);
84
+ }
85
+ catch {
86
+ return c.json({ error: `Cannot read mount path: ${mount.path}` }, 503);
87
+ }
88
+ });
89
+ // --- Attachments CRUD ---
90
+ router.get('/mount-attachments', (c) => {
91
+ const mountId = c.req.query('mountId');
92
+ const tenantId = c.req.query('tenantId');
93
+ if (mountId)
94
+ return c.json(store.listAttachments(mountId));
95
+ if (tenantId)
96
+ return c.json(store.listTenantAttachments(tenantId));
97
+ return c.json({ error: 'Provide mountId or tenantId query param' }, 400);
98
+ });
99
+ router.post('/mount-attachments', async (c) => {
100
+ let body;
101
+ try {
102
+ body = await c.req.json();
103
+ }
104
+ catch {
105
+ return c.json({ error: 'Invalid JSON body' }, 400);
106
+ }
107
+ if (!body.mountId || !body.tenantId) {
108
+ return c.json({ error: 'mountId and tenantId required' }, 400);
109
+ }
110
+ if (!store.get(body.mountId)) {
111
+ return c.json({ error: `Mount "${body.mountId}" not found` }, 404);
112
+ }
113
+ const att = store.attach(body.mountId, body.tenantId);
114
+ return c.json(att, 201);
115
+ });
116
+ router.delete('/mount-attachments', async (c) => {
117
+ let body;
118
+ try {
119
+ body = await c.req.json();
120
+ }
121
+ catch {
122
+ return c.json({ error: 'Invalid JSON body' }, 400);
123
+ }
124
+ if (!body.mountId || !body.tenantId) {
125
+ return c.json({ error: 'mountId and tenantId required' }, 400);
126
+ }
127
+ store.detach(body.mountId, body.tenantId);
128
+ return c.body(null, 204);
129
+ });
130
+ // --- Per-tenant attachment listing ---
131
+ router.get('/tenants/:id/attachments', (c) => {
132
+ const tenantId = c.req.param('id');
133
+ return c.json(store.listTenantAttachments(tenantId));
134
+ });
135
+ return router;
136
+ }
@@ -0,0 +1,30 @@
1
+ export interface MountRecord {
2
+ id: string;
3
+ label?: string;
4
+ path: string;
5
+ createdAt: string;
6
+ updatedAt: string;
7
+ }
8
+ export interface MountAttachment {
9
+ mountId: string;
10
+ tenantId: string;
11
+ attachedAt: string;
12
+ }
13
+ export declare class MountStore {
14
+ #private;
15
+ constructor(dataDir: string);
16
+ create(input: {
17
+ id: string;
18
+ label?: string;
19
+ path: string;
20
+ }): MountRecord;
21
+ get(id: string): MountRecord | null;
22
+ list(): MountRecord[];
23
+ update(id: string, patch: Partial<Pick<MountRecord, 'label' | 'path'>>): MountRecord | null;
24
+ delete(id: string): boolean;
25
+ attach(mountId: string, tenantId: string): MountAttachment;
26
+ detach(mountId: string, tenantId: string): void;
27
+ listAttachments(mountId: string): MountAttachment[];
28
+ listTenantAttachments(tenantId: string): MountAttachment[];
29
+ isAttached(mountId: string, tenantId: string): boolean;
30
+ }