sh3-server 0.19.5 → 0.20.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.
- package/app/assets/{icons-nOyIoORC.svg → icons-OMmH0JiM.svg} +5 -0
- package/app/assets/index-CgB99H18.js +21 -0
- package/app/assets/index-CgB99H18.js.map +1 -0
- package/app/assets/index-D0Q9JGHo.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 +6 -0
- package/dist/doc-store/store.js +108 -1
- 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 +27 -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-BUPUvuOA.css +0 -1
- package/app/assets/index-CygVqZor.js +0 -21
- package/app/assets/index-CygVqZor.js.map +0 -1
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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export class MountStore {
|
|
4
|
+
#mountsPath;
|
|
5
|
+
#attachmentsPath;
|
|
6
|
+
#mounts = [];
|
|
7
|
+
#attachments = [];
|
|
8
|
+
constructor(dataDir) {
|
|
9
|
+
this.#mountsPath = `${dataDir}/mounts.json`;
|
|
10
|
+
this.#attachmentsPath = `${dataDir}/mount-attachments.json`;
|
|
11
|
+
this.#load();
|
|
12
|
+
}
|
|
13
|
+
#load() {
|
|
14
|
+
if (existsSync(this.#mountsPath)) {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(readFileSync(this.#mountsPath, 'utf-8'));
|
|
17
|
+
this.#mounts = data.mounts ?? [];
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
this.#mounts = [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (existsSync(this.#attachmentsPath)) {
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(readFileSync(this.#attachmentsPath, 'utf-8'));
|
|
26
|
+
this.#attachments = data.attachments ?? [];
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
this.#attachments = [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
#saveMounts() {
|
|
34
|
+
mkdirSync(dirname(this.#mountsPath), { recursive: true });
|
|
35
|
+
writeFileSync(this.#mountsPath, JSON.stringify({ mounts: this.#mounts }, null, 2));
|
|
36
|
+
}
|
|
37
|
+
#saveAttachments() {
|
|
38
|
+
mkdirSync(dirname(this.#attachmentsPath), { recursive: true });
|
|
39
|
+
writeFileSync(this.#attachmentsPath, JSON.stringify({ attachments: this.#attachments }, null, 2));
|
|
40
|
+
}
|
|
41
|
+
create(input) {
|
|
42
|
+
if (this.#mounts.some(m => m.id === input.id)) {
|
|
43
|
+
throw new Error(`Mount "${input.id}" already exists`);
|
|
44
|
+
}
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
const record = {
|
|
47
|
+
id: input.id,
|
|
48
|
+
label: input.label,
|
|
49
|
+
path: input.path,
|
|
50
|
+
createdAt: now,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
};
|
|
53
|
+
this.#mounts.push(record);
|
|
54
|
+
this.#saveMounts();
|
|
55
|
+
return { ...record };
|
|
56
|
+
}
|
|
57
|
+
get(id) {
|
|
58
|
+
return this.#mounts.find(m => m.id === id) ?? null;
|
|
59
|
+
}
|
|
60
|
+
list() {
|
|
61
|
+
return [...this.#mounts];
|
|
62
|
+
}
|
|
63
|
+
update(id, patch) {
|
|
64
|
+
const mount = this.#mounts.find(m => m.id === id);
|
|
65
|
+
if (!mount)
|
|
66
|
+
return null;
|
|
67
|
+
if (patch.label !== undefined)
|
|
68
|
+
mount.label = patch.label;
|
|
69
|
+
if (patch.path !== undefined)
|
|
70
|
+
mount.path = patch.path;
|
|
71
|
+
mount.updatedAt = new Date().toISOString();
|
|
72
|
+
this.#saveMounts();
|
|
73
|
+
return { ...mount };
|
|
74
|
+
}
|
|
75
|
+
delete(id) {
|
|
76
|
+
const before = this.#mounts.length;
|
|
77
|
+
this.#mounts = this.#mounts.filter(m => m.id !== id);
|
|
78
|
+
if (this.#mounts.length < before) {
|
|
79
|
+
this.#attachments = this.#attachments.filter(a => a.mountId !== id);
|
|
80
|
+
this.#saveMounts();
|
|
81
|
+
this.#saveAttachments();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
attach(mountId, tenantId) {
|
|
87
|
+
const existing = this.#attachments.find(a => a.mountId === mountId && a.tenantId === tenantId);
|
|
88
|
+
if (existing)
|
|
89
|
+
return { ...existing };
|
|
90
|
+
const att = {
|
|
91
|
+
mountId,
|
|
92
|
+
tenantId,
|
|
93
|
+
attachedAt: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
this.#attachments.push(att);
|
|
96
|
+
this.#saveAttachments();
|
|
97
|
+
return { ...att };
|
|
98
|
+
}
|
|
99
|
+
detach(mountId, tenantId) {
|
|
100
|
+
const before = this.#attachments.length;
|
|
101
|
+
this.#attachments = this.#attachments.filter(a => !(a.mountId === mountId && a.tenantId === tenantId));
|
|
102
|
+
if (this.#attachments.length < before) {
|
|
103
|
+
this.#saveAttachments();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
listAttachments(mountId) {
|
|
107
|
+
return this.#attachments.filter(a => a.mountId === mountId);
|
|
108
|
+
}
|
|
109
|
+
listTenantAttachments(tenantId) {
|
|
110
|
+
return this.#attachments.filter(a => a.tenantId === tenantId);
|
|
111
|
+
}
|
|
112
|
+
isAttached(mountId, tenantId) {
|
|
113
|
+
return this.#attachments.some(a => a.mountId === mountId && a.tenantId === tenantId);
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/routes/admin.d.ts
CHANGED
|
@@ -7,4 +7,6 @@ import type { UserStore } from '../users.js';
|
|
|
7
7
|
import type { SettingsStore } from '../settings.js';
|
|
8
8
|
import type { SessionStore } from '../sessions.js';
|
|
9
9
|
import type { KeyStore } from '../keys.js';
|
|
10
|
-
|
|
10
|
+
import type { MountStore } from '../mounts/store.js';
|
|
11
|
+
import type { MountedPathResolver } from '../mounts/resolver.js';
|
|
12
|
+
export declare function createAdminRouter(users: UserStore, settings: SettingsStore, sessions: SessionStore, keys: KeyStore, dataDir: string, mountStore?: MountStore, mountResolver?: MountedPathResolver): Hono;
|
package/dist/routes/admin.js
CHANGED
|
@@ -6,7 +6,8 @@ import { Hono } from 'hono';
|
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
|
|
9
|
+
import { createMountsRouter } from '../mounts/routes.js';
|
|
10
|
+
export function createAdminRouter(users, settings, sessions, keys, dataDir, mountStore, mountResolver) {
|
|
10
11
|
const router = new Hono();
|
|
11
12
|
// --- Users CRUD ---
|
|
12
13
|
router.get('/users', (c) => {
|
|
@@ -126,5 +127,9 @@ export function createAdminRouter(users, settings, sessions, keys, dataDir) {
|
|
|
126
127
|
return c.json({ error: 'Key not found' }, 404);
|
|
127
128
|
return c.body(null, 204);
|
|
128
129
|
});
|
|
130
|
+
// Mount management routes (if stores provided)
|
|
131
|
+
if (mountStore && mountResolver) {
|
|
132
|
+
router.route('/', createMountsRouter(mountStore, mountResolver));
|
|
133
|
+
}
|
|
129
134
|
return router;
|
|
130
135
|
}
|
package/dist/routes/boot.d.ts
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Returns everything the client needs to decide how to render:
|
|
5
5
|
* auth requirements, current session, user, and tenant ID.
|
|
6
|
+
*
|
|
7
|
+
* When localMode is true (Tauri sidecar), the route ensures a session
|
|
8
|
+
* for the local owner: if no valid session is on the request, a new
|
|
9
|
+
* one is minted against the SessionStore and returned in the response
|
|
10
|
+
* body. The client then carries it as Authorization: Bearer on
|
|
11
|
+
* subsequent calls.
|
|
6
12
|
*/
|
|
7
13
|
import { Hono } from 'hono';
|
|
8
14
|
import type { SessionStore } from '../sessions.js';
|
|
9
15
|
import type { UserStore } from '../users.js';
|
|
10
16
|
import type { SettingsStore } from '../settings.js';
|
|
11
|
-
export declare function createBootRouter(sessions: SessionStore, users: UserStore, settings: SettingsStore, version: string): Hono;
|
|
17
|
+
export declare function createBootRouter(sessions: SessionStore, users: UserStore, settings: SettingsStore, version: string, localMode?: boolean): Hono;
|
package/dist/routes/boot.js
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Returns everything the client needs to decide how to render:
|
|
5
5
|
* auth requirements, current session, user, and tenant ID.
|
|
6
|
+
*
|
|
7
|
+
* When localMode is true (Tauri sidecar), the route ensures a session
|
|
8
|
+
* for the local owner: if no valid session is on the request, a new
|
|
9
|
+
* one is minted against the SessionStore and returned in the response
|
|
10
|
+
* body. The client then carries it as Authorization: Bearer on
|
|
11
|
+
* subsequent calls.
|
|
6
12
|
*/
|
|
7
13
|
import { Hono } from 'hono';
|
|
8
14
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
export function createBootRouter(sessions, users, settings, version) {
|
|
15
|
+
export function createBootRouter(sessions, users, settings, version, localMode = false) {
|
|
10
16
|
const router = new Hono();
|
|
11
17
|
router.get('/', (c) => {
|
|
12
18
|
const config = settings.get();
|
|
13
|
-
// Try to extract session
|
|
19
|
+
// Try to extract session from cookie or Authorization header
|
|
14
20
|
let session = null;
|
|
15
21
|
const cookie = c.req.raw.headers.get('cookie');
|
|
16
22
|
if (cookie) {
|
|
@@ -24,13 +30,17 @@ export function createBootRouter(sessions, users, settings, version) {
|
|
|
24
30
|
session = sessions.validate(auth.slice(7));
|
|
25
31
|
}
|
|
26
32
|
}
|
|
33
|
+
// localMode: ensure a session for the local owner.
|
|
34
|
+
if (localMode && !session) {
|
|
35
|
+
session = sessions.create('local', 'admin');
|
|
36
|
+
}
|
|
27
37
|
const user = session ? users.get(session.userId) : null;
|
|
28
38
|
// Determine tenant ID
|
|
29
39
|
let tenantId;
|
|
30
40
|
if (user) {
|
|
31
41
|
tenantId = user.id;
|
|
32
42
|
}
|
|
33
|
-
else if (
|
|
43
|
+
else if (config.auth.guestAllowed) {
|
|
34
44
|
tenantId = `guest_${randomUUID()}`;
|
|
35
45
|
}
|
|
36
46
|
else {
|
|
@@ -38,7 +48,6 @@ export function createBootRouter(sessions, users, settings, version) {
|
|
|
38
48
|
}
|
|
39
49
|
return c.json({
|
|
40
50
|
auth: {
|
|
41
|
-
required: config.auth.required,
|
|
42
51
|
guestAllowed: config.auth.guestAllowed,
|
|
43
52
|
selfRegistration: config.auth.selfRegistration,
|
|
44
53
|
},
|
package/dist/routes/docs.js
CHANGED
|
@@ -24,8 +24,8 @@ export function createDocsRouter(store, options = {}) {
|
|
|
24
24
|
? { settings: options }
|
|
25
25
|
: options;
|
|
26
26
|
const router = new Hono();
|
|
27
|
-
router.use('/:scope/*', scopeAccessMatch('scope'
|
|
28
|
-
router.use('/:scope', scopeAccessMatch('scope'
|
|
27
|
+
router.use('/:scope/*', scopeAccessMatch('scope'));
|
|
28
|
+
router.use('/:scope', scopeAccessMatch('scope'));
|
|
29
29
|
if (opts.projectStore && opts.appRegistry) {
|
|
30
30
|
router.use('/:scope/:shard/*', projectAppAllowlist({ projectStore: opts.projectStore, appRegistry: opts.appRegistry }));
|
|
31
31
|
}
|
|
@@ -157,6 +157,31 @@ export function createDocsRouter(store, options = {}) {
|
|
|
157
157
|
const meta = await store.readMeta(scope, shard, body.to);
|
|
158
158
|
return c.json({ ok: true, version: meta?.version });
|
|
159
159
|
}
|
|
160
|
+
if (rawPath.endsWith('/transfer')) {
|
|
161
|
+
const filePath = rawPath.replace(/\/transfer$/, '');
|
|
162
|
+
if (!filePath)
|
|
163
|
+
return c.json({ error: 'Missing file path' }, 400);
|
|
164
|
+
const body = await c.req.json().catch(() => null);
|
|
165
|
+
if (!body || typeof body.targetScope !== 'string' || body.targetScope.length === 0) {
|
|
166
|
+
return c.json({ error: 'Body must include { targetScope: string }' }, 400);
|
|
167
|
+
}
|
|
168
|
+
if (body.targetScope === scope) {
|
|
169
|
+
return c.json({ error: 'targetScope must differ from source scope' }, 400);
|
|
170
|
+
}
|
|
171
|
+
const caller = c.get('caller');
|
|
172
|
+
if (caller && !caller.accessibleScopes.includes(body.targetScope)) {
|
|
173
|
+
return c.json({ error: `Caller is not a member of scope "${body.targetScope}"` }, 403);
|
|
174
|
+
}
|
|
175
|
+
const targetShard = body.targetShardId ?? shard;
|
|
176
|
+
const content = await store.read(scope, shard, filePath);
|
|
177
|
+
if (content === null)
|
|
178
|
+
return c.json({ error: 'Source document not found' }, 404);
|
|
179
|
+
await store.write(body.targetScope, targetShard, filePath, content);
|
|
180
|
+
if (body.delete) {
|
|
181
|
+
await store.delete(scope, shard, filePath);
|
|
182
|
+
}
|
|
183
|
+
return c.json({ ok: true, [body.delete ? 'deleted' : 'copied']: true });
|
|
184
|
+
}
|
|
160
185
|
return c.json({ error: 'Unknown docs POST endpoint' }, 404);
|
|
161
186
|
});
|
|
162
187
|
// Write
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projects
|
|
2
|
+
* Projects routes — admin-only management + member-visible listing.
|
|
3
3
|
*
|
|
4
4
|
* - GET / — list projects the caller is a member of
|
|
5
|
-
* - GET /all — admin: list every project
|
|
6
|
-
* - GET /:id — fetch one (member or admin)
|
|
7
5
|
* - POST / — admin: create a project
|
|
8
6
|
* - PATCH /:id — admin: mutate a project
|
|
9
7
|
* - DELETE /:id — admin: delete a project
|
package/dist/routes/projects.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projects
|
|
2
|
+
* Projects routes — admin-only management + member-visible listing.
|
|
3
3
|
*
|
|
4
4
|
* - GET / — list projects the caller is a member of
|
|
5
|
-
* - GET /all — admin: list every project
|
|
6
|
-
* - GET /:id — fetch one (member or admin)
|
|
7
5
|
* - POST / — admin: create a project
|
|
8
6
|
* - PATCH /:id — admin: mutate a project
|
|
9
7
|
* - DELETE /:id — admin: delete a project
|