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.
- package/app/assets/{icons-nOyIoORC.svg → icons-OMmH0JiM.svg} +5 -0
- package/app/assets/index-C6sN7l5X.js +26 -0
- package/app/assets/index-C6sN7l5X.js.map +1 -0
- package/app/assets/index-DbnwAWBS.css +1 -0
- package/app/index.html +2 -2
- package/dist/auth.d.ts +7 -11
- package/dist/auth.js +7 -19
- package/dist/cli.js +2 -2
- package/dist/doc-store/store.d.ts +12 -0
- package/dist/doc-store/store.js +172 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +21 -12
- package/dist/middleware/project-allowlist.d.ts +4 -0
- package/dist/middleware/project-allowlist.js +26 -12
- package/dist/mounts/resolver.d.ts +21 -0
- package/dist/mounts/resolver.js +41 -0
- package/dist/mounts/routes.d.ts +4 -0
- package/dist/mounts/routes.js +136 -0
- package/dist/mounts/store.d.ts +30 -0
- package/dist/mounts/store.js +115 -0
- package/dist/routes/admin.d.ts +3 -1
- package/dist/routes/admin.js +6 -1
- package/dist/routes/boot.d.ts +7 -1
- package/dist/routes/boot.js +13 -4
- package/dist/routes/docs.js +83 -2
- package/dist/routes/projects.d.ts +1 -3
- package/dist/routes/projects.js +1 -3
- package/dist/scope.d.ts +1 -2
- package/dist/scope.js +1 -5
- package/dist/settings.d.ts +0 -1
- package/dist/settings.js +0 -4
- package/dist/shard-router.d.ts +1 -1
- package/dist/shard-router.js +23 -4
- package/dist/tenant-fs/http.d.ts +0 -2
- package/dist/tenant-fs/http.js +1 -1
- package/dist/tenant-fs/session-required.d.ts +1 -3
- package/dist/tenant-fs/session-required.js +1 -4
- package/dist/users.d.ts +14 -1
- package/dist/users.js +34 -0
- package/package.json +1 -1
- package/app/assets/index--m0u3gjJ.js +0 -21
- package/app/assets/index--m0u3gjJ.js.map +0 -1
- package/app/assets/index-DIpoXNrk.css +0 -1
package/dist/doc-store/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
191
|
-
app.use('/api/packages/uninstall', adminAuth(
|
|
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(
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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,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
|
+
}
|