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
|
@@ -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
|
}
|
|
@@ -51,6 +51,9 @@ export function createDocsRouter(store, options = {}) {
|
|
|
51
51
|
const { scope, shard } = c.req.param();
|
|
52
52
|
if (isReservedShardId(shard))
|
|
53
53
|
return c.notFound();
|
|
54
|
+
if (c.req.query('folders') === '1') {
|
|
55
|
+
return c.json(await store.listFolders(scope, shard, ''));
|
|
56
|
+
}
|
|
54
57
|
return c.json(await store.list(scope, shard));
|
|
55
58
|
});
|
|
56
59
|
// Branch content read — conflict-branch by origin. Registered before the
|
|
@@ -82,6 +85,9 @@ export function createDocsRouter(store, options = {}) {
|
|
|
82
85
|
const filePath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
|
|
83
86
|
if (!filePath)
|
|
84
87
|
return c.json({ error: 'Missing file path' }, 400);
|
|
88
|
+
if (c.req.query('folders') === '1') {
|
|
89
|
+
return c.json(await store.listFolders(scope, shard, filePath));
|
|
90
|
+
}
|
|
85
91
|
if (c.req.query('meta') === '1') {
|
|
86
92
|
const meta = await store.readMeta(scope, shard, filePath);
|
|
87
93
|
if (!meta)
|
|
@@ -157,6 +163,81 @@ export function createDocsRouter(store, options = {}) {
|
|
|
157
163
|
const meta = await store.readMeta(scope, shard, body.to);
|
|
158
164
|
return c.json({ ok: true, version: meta?.version });
|
|
159
165
|
}
|
|
166
|
+
if (rawPath.endsWith('/mkdir')) {
|
|
167
|
+
const folderPath = rawPath.replace(/\/mkdir$/, '');
|
|
168
|
+
if (!folderPath)
|
|
169
|
+
return c.json({ error: 'Missing folder path' }, 400);
|
|
170
|
+
try {
|
|
171
|
+
await store.mkdir(scope, shard, folderPath);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
return c.json({ error: String(err?.message ?? err) }, 409);
|
|
175
|
+
}
|
|
176
|
+
return c.json({ ok: true });
|
|
177
|
+
}
|
|
178
|
+
if (rawPath.endsWith('/rmdir')) {
|
|
179
|
+
const folderPath = rawPath.replace(/\/rmdir$/, '');
|
|
180
|
+
if (!folderPath)
|
|
181
|
+
return c.json({ error: 'Missing folder path' }, 400);
|
|
182
|
+
const body = await c.req.json().catch(() => ({}));
|
|
183
|
+
const recursive = body && body.recursive === true;
|
|
184
|
+
try {
|
|
185
|
+
await store.rmdir(scope, shard, folderPath, { recursive });
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return c.json({ error: String(err?.message ?? err) }, 409);
|
|
189
|
+
}
|
|
190
|
+
return c.json({ ok: true });
|
|
191
|
+
}
|
|
192
|
+
if (rawPath.endsWith('/rename-folder')) {
|
|
193
|
+
const oldPath = rawPath.replace(/\/rename-folder$/, '');
|
|
194
|
+
if (!oldPath)
|
|
195
|
+
return c.json({ error: 'Missing folder path' }, 400);
|
|
196
|
+
const body = await c.req.json().catch(() => null);
|
|
197
|
+
if (!body || typeof body.to !== 'string' || body.to.length === 0) {
|
|
198
|
+
return c.json({ error: 'Body must include { to: string }' }, 400);
|
|
199
|
+
}
|
|
200
|
+
if (body.to === oldPath) {
|
|
201
|
+
return c.json({ error: 'Rename target must differ from source' }, 400);
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await store.renameFolder(scope, shard, oldPath, body.to);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const msg = String(err?.message ?? err);
|
|
208
|
+
if (/does not exist/i.test(msg))
|
|
209
|
+
return c.json({ error: msg }, 404);
|
|
210
|
+
if (/already exists/i.test(msg))
|
|
211
|
+
return c.json({ error: msg }, 409);
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
return c.json({ ok: true });
|
|
215
|
+
}
|
|
216
|
+
if (rawPath.endsWith('/transfer')) {
|
|
217
|
+
const filePath = rawPath.replace(/\/transfer$/, '');
|
|
218
|
+
if (!filePath)
|
|
219
|
+
return c.json({ error: 'Missing file path' }, 400);
|
|
220
|
+
const body = await c.req.json().catch(() => null);
|
|
221
|
+
if (!body || typeof body.targetScope !== 'string' || body.targetScope.length === 0) {
|
|
222
|
+
return c.json({ error: 'Body must include { targetScope: string }' }, 400);
|
|
223
|
+
}
|
|
224
|
+
if (body.targetScope === scope) {
|
|
225
|
+
return c.json({ error: 'targetScope must differ from source scope' }, 400);
|
|
226
|
+
}
|
|
227
|
+
const caller = c.get('caller');
|
|
228
|
+
if (caller && !caller.accessibleScopes.includes(body.targetScope)) {
|
|
229
|
+
return c.json({ error: `Caller is not a member of scope "${body.targetScope}"` }, 403);
|
|
230
|
+
}
|
|
231
|
+
const targetShard = body.targetShardId ?? shard;
|
|
232
|
+
const content = await store.read(scope, shard, filePath);
|
|
233
|
+
if (content === null)
|
|
234
|
+
return c.json({ error: 'Source document not found' }, 404);
|
|
235
|
+
await store.write(body.targetScope, targetShard, filePath, content);
|
|
236
|
+
if (body.delete) {
|
|
237
|
+
await store.delete(scope, shard, filePath);
|
|
238
|
+
}
|
|
239
|
+
return c.json({ ok: true, [body.delete ? 'deleted' : 'copied']: true });
|
|
240
|
+
}
|
|
160
241
|
return c.json({ error: 'Unknown docs POST endpoint' }, 404);
|
|
161
242
|
});
|
|
162
243
|
// 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
|
package/dist/scope.d.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* unless their accessibleScopes list includes the requested scope.
|
|
11
11
|
*/
|
|
12
12
|
import type { MiddlewareHandler } from 'hono';
|
|
13
|
-
import type { SettingsStore } from './settings.js';
|
|
14
13
|
export declare function scopeRequired(scope: string): MiddlewareHandler;
|
|
15
14
|
export declare const requireCallerScope: MiddlewareHandler;
|
|
16
|
-
export declare function scopeAccessMatch(paramName: string
|
|
15
|
+
export declare function scopeAccessMatch(paramName: string): MiddlewareHandler;
|
package/dist/scope.js
CHANGED
|
@@ -28,12 +28,8 @@ export const requireCallerScope = async (c, next) => {
|
|
|
28
28
|
return c.json({ error: 'Scope-bound credentials required' }, 401);
|
|
29
29
|
return next();
|
|
30
30
|
};
|
|
31
|
-
export function scopeAccessMatch(paramName
|
|
31
|
+
export function scopeAccessMatch(paramName) {
|
|
32
32
|
return async (c, next) => {
|
|
33
|
-
// Open / no-auth mode: admin has explicitly disabled scope isolation.
|
|
34
|
-
// Mirrors sessionAuth's open-mode bypass; required for Tauri sidecar mode.
|
|
35
|
-
if (settings && !settings.get().auth.required)
|
|
36
|
-
return next();
|
|
37
33
|
const caller = c.get('caller');
|
|
38
34
|
if (!caller)
|
|
39
35
|
return c.json({ error: 'Caller not resolved' }, 500);
|
package/dist/settings.d.ts
CHANGED
package/dist/settings.js
CHANGED
|
@@ -7,7 +7,6 @@ import { dirname } from 'node:path';
|
|
|
7
7
|
const MAX_PACKAGE_CACHE_AGE = 31536000; // 1 year
|
|
8
8
|
const DEFAULTS = {
|
|
9
9
|
auth: {
|
|
10
|
-
required: true,
|
|
11
10
|
guestAllowed: false,
|
|
12
11
|
sessionTTL: 24,
|
|
13
12
|
selfRegistration: false,
|
|
@@ -44,7 +43,6 @@ export class SettingsStore {
|
|
|
44
43
|
const raw = JSON.parse(readFileSync(this.#path, 'utf-8'));
|
|
45
44
|
return {
|
|
46
45
|
auth: {
|
|
47
|
-
required: raw.auth?.required ?? DEFAULTS.auth.required,
|
|
48
46
|
guestAllowed: raw.auth?.guestAllowed ?? DEFAULTS.auth.guestAllowed,
|
|
49
47
|
sessionTTL: raw.auth?.sessionTTL ?? DEFAULTS.auth.sessionTTL,
|
|
50
48
|
selfRegistration: raw.auth?.selfRegistration ?? DEFAULTS.auth.selfRegistration,
|
|
@@ -72,8 +70,6 @@ export class SettingsStore {
|
|
|
72
70
|
/** Patch settings. Only provided fields are updated. */
|
|
73
71
|
update(patch) {
|
|
74
72
|
if (patch.auth) {
|
|
75
|
-
if (patch.auth.required !== undefined)
|
|
76
|
-
this.#settings.auth.required = patch.auth.required;
|
|
77
73
|
if (patch.auth.guestAllowed !== undefined)
|
|
78
74
|
this.#settings.auth.guestAllowed = patch.auth.guestAllowed;
|
|
79
75
|
if (patch.auth.sessionTTL !== undefined)
|
package/dist/shard-router.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface MountContext {
|
|
|
48
48
|
wsRegister(onConnect: (ws: any, c: any) => void): any;
|
|
49
49
|
}
|
|
50
50
|
/** Middleware requiring the caller's scope set to include admin:*. */
|
|
51
|
-
export declare function adminOnly(
|
|
51
|
+
export declare function adminOnly(): MiddlewareHandler;
|
|
52
52
|
/**
|
|
53
53
|
* Dynamic shard route manager. Holds a Map of shard Hono sub-apps
|
|
54
54
|
* and delegates requests from a single wildcard route.
|
package/dist/shard-router.js
CHANGED
|
@@ -5,10 +5,8 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { scopeRequired, requireCallerScope } from './scope.js';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
/** Middleware requiring the caller's scope set to include admin:*. */
|
|
8
|
-
export function adminOnly(
|
|
8
|
+
export function adminOnly() {
|
|
9
9
|
return async (c, next) => {
|
|
10
|
-
if (!settings.get().auth.required)
|
|
11
|
-
return next();
|
|
12
10
|
const caller = c.get('caller');
|
|
13
11
|
if (caller?.scopes.includes('admin:*'))
|
|
14
12
|
return next();
|
|
@@ -154,6 +152,26 @@ export class ShardRouter {
|
|
|
154
152
|
// Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
|
|
155
153
|
}
|
|
156
154
|
const docStore = ctx.docStore;
|
|
155
|
+
// Symmetric path translator: same (shardId, path) pair that documents()
|
|
156
|
+
// accepts. `mounts/*` walks the MountedPathResolver wired on the docStore;
|
|
157
|
+
// any other shardId resolves to the canonical native-doc location under
|
|
158
|
+
// the host's docs dir. Throws on unresolvable mounts so callers can just
|
|
159
|
+
// hand the result to spawn/streams without branching.
|
|
160
|
+
const resolveFsPath = (tenant, targetShardId, path) => {
|
|
161
|
+
if (targetShardId === 'mounts') {
|
|
162
|
+
const resolver = docStore.mountResolver;
|
|
163
|
+
if (!resolver)
|
|
164
|
+
throw new Error('Mount resolver not configured on docStore');
|
|
165
|
+
const docPath = path ? `mounts/${path}` : 'mounts';
|
|
166
|
+
const resolved = resolver.resolve(tenant, docPath);
|
|
167
|
+
if (resolved.kind === 'mount')
|
|
168
|
+
return resolved.realPath;
|
|
169
|
+
if (resolved.kind === 'mount-unresolved')
|
|
170
|
+
throw new Error(resolved.error);
|
|
171
|
+
throw new Error(`Invalid mount path: ${docPath}`);
|
|
172
|
+
}
|
|
173
|
+
return join(docStore.dataDir, 'docs', tenant, targetShardId, path);
|
|
174
|
+
};
|
|
157
175
|
// Hono's MiddlewareHandler uses a concrete Context generic that isn't
|
|
158
176
|
// assignable to sh3-core's framework-agnostic stand-in (c: unknown).
|
|
159
177
|
// Cast once at assembly — shards only call the handlers, never introspect.
|
|
@@ -161,7 +179,7 @@ export class ShardRouter {
|
|
|
161
179
|
shardId,
|
|
162
180
|
dataDir: shardDataDir,
|
|
163
181
|
permissions,
|
|
164
|
-
adminOnly: adminOnly(
|
|
182
|
+
adminOnly: adminOnly(),
|
|
165
183
|
scopeRequired,
|
|
166
184
|
tenantRequired: requireCallerScope,
|
|
167
185
|
wsRegister: ctx.wsRegister,
|
|
@@ -172,6 +190,7 @@ export class ShardRouter {
|
|
|
172
190
|
return; // silent no-op
|
|
173
191
|
docStore.roles.set(tenant, role);
|
|
174
192
|
},
|
|
193
|
+
resolveFsPath,
|
|
175
194
|
};
|
|
176
195
|
return ctxOut;
|
|
177
196
|
}
|
package/dist/tenant-fs/http.d.ts
CHANGED
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* Read-only. Writes are out of scope for this iteration.
|
|
6
6
|
*/
|
|
7
7
|
import type { Hono } from 'hono';
|
|
8
|
-
import type { SettingsStore } from '../settings.js';
|
|
9
8
|
export interface TenantFsRouteContext {
|
|
10
9
|
dataDir: string;
|
|
11
10
|
rootBase: string;
|
|
12
|
-
settings: SettingsStore;
|
|
13
11
|
maxReadBytes: number;
|
|
14
12
|
}
|
|
15
13
|
export declare function registerTenantFsRoutes(app: Hono, ctx: TenantFsRouteContext): void;
|
package/dist/tenant-fs/http.js
CHANGED
|
@@ -16,7 +16,7 @@ function userIdFromContext(c) {
|
|
|
16
16
|
return session.userId;
|
|
17
17
|
}
|
|
18
18
|
export function registerTenantFsRoutes(app, ctx) {
|
|
19
|
-
const sessionRequired = makeSessionRequired(
|
|
19
|
+
const sessionRequired = makeSessionRequired();
|
|
20
20
|
app.get('/api/fs/list', sessionRequired, async (c) => {
|
|
21
21
|
const rel = c.req.query('path') ?? '';
|
|
22
22
|
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from 'hono';
|
|
2
|
-
import type { SettingsStore } from '../settings.js';
|
|
3
2
|
/**
|
|
4
3
|
* Requires an authenticated session on the request. Any role passes.
|
|
5
|
-
* When `auth.required` is false (dev/--no-auth), passes through.
|
|
6
4
|
*
|
|
7
5
|
* Contrast with adminOnly: this gate is for tenant-scoped APIs where any
|
|
8
6
|
* logged-in user can operate — scope is enforced by the handler jailing to
|
|
9
7
|
* the caller's own tenant root, not by role.
|
|
10
8
|
*/
|
|
11
|
-
export declare function makeSessionRequired(
|
|
9
|
+
export declare function makeSessionRequired(): MiddlewareHandler;
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Requires an authenticated session on the request. Any role passes.
|
|
3
|
-
* When `auth.required` is false (dev/--no-auth), passes through.
|
|
4
3
|
*
|
|
5
4
|
* Contrast with adminOnly: this gate is for tenant-scoped APIs where any
|
|
6
5
|
* logged-in user can operate — scope is enforced by the handler jailing to
|
|
7
6
|
* the caller's own tenant root, not by role.
|
|
8
7
|
*/
|
|
9
|
-
export function makeSessionRequired(
|
|
8
|
+
export function makeSessionRequired() {
|
|
10
9
|
return async (c, next) => {
|
|
11
|
-
if (!settings.get().auth.required)
|
|
12
|
-
return next();
|
|
13
10
|
const session = c.get('session') ?? c.env?.session;
|
|
14
11
|
if (!session?.userId) {
|
|
15
12
|
return c.json({ error: 'authentication required' }, 401);
|
package/dist/users.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface StoredUser {
|
|
|
6
6
|
id: string;
|
|
7
7
|
username: string;
|
|
8
8
|
displayName: string;
|
|
9
|
-
passwordHash: string;
|
|
9
|
+
passwordHash: string | null;
|
|
10
10
|
role: 'admin' | 'user';
|
|
11
11
|
createdAt: string;
|
|
12
12
|
updatedAt: string;
|
|
@@ -39,6 +39,19 @@ export declare class UserStore {
|
|
|
39
39
|
}): Promise<PublicUser | null>;
|
|
40
40
|
/** Delete a user by ID. Returns true if found and removed. */
|
|
41
41
|
delete(id: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Upsert a synthetic user (no password). Used by `--local` desktop mode to
|
|
44
|
+
* persist the `local` owner without invoking bcrypt (which the SEA sidecar
|
|
45
|
+
* cannot load). Users created via this path have `passwordHash=null` and
|
|
46
|
+
* can never authenticate through the password flow — `authenticate()` filters
|
|
47
|
+
* them out.
|
|
48
|
+
*/
|
|
49
|
+
upsertSynthetic(opts: {
|
|
50
|
+
id: string;
|
|
51
|
+
username: string;
|
|
52
|
+
displayName: string;
|
|
53
|
+
role: 'admin' | 'user';
|
|
54
|
+
}): PublicUser;
|
|
42
55
|
/** Generate a random temporary password. */
|
|
43
56
|
static generatePassword(): string;
|
|
44
57
|
}
|
package/dist/users.js
CHANGED
|
@@ -64,6 +64,8 @@ export class UserStore {
|
|
|
64
64
|
const user = this.#users.find(u => u.username === username.toLowerCase());
|
|
65
65
|
if (!user)
|
|
66
66
|
return null;
|
|
67
|
+
if (!user.passwordHash)
|
|
68
|
+
return null;
|
|
67
69
|
if (!(await (await getBcrypt()).compare(password, user.passwordHash)))
|
|
68
70
|
return null;
|
|
69
71
|
return this.#toPublic(user);
|
|
@@ -102,6 +104,38 @@ export class UserStore {
|
|
|
102
104
|
}
|
|
103
105
|
return false;
|
|
104
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Upsert a synthetic user (no password). Used by `--local` desktop mode to
|
|
109
|
+
* persist the `local` owner without invoking bcrypt (which the SEA sidecar
|
|
110
|
+
* cannot load). Users created via this path have `passwordHash=null` and
|
|
111
|
+
* can never authenticate through the password flow — `authenticate()` filters
|
|
112
|
+
* them out.
|
|
113
|
+
*/
|
|
114
|
+
upsertSynthetic(opts) {
|
|
115
|
+
const lower = opts.username.toLowerCase();
|
|
116
|
+
const existing = this.#users.find((u) => u.id === opts.id);
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
if (existing) {
|
|
119
|
+
existing.username = lower;
|
|
120
|
+
existing.displayName = opts.displayName;
|
|
121
|
+
existing.role = opts.role;
|
|
122
|
+
existing.updatedAt = now;
|
|
123
|
+
this.#save();
|
|
124
|
+
return this.#toPublic(existing);
|
|
125
|
+
}
|
|
126
|
+
const user = {
|
|
127
|
+
id: opts.id,
|
|
128
|
+
username: lower,
|
|
129
|
+
displayName: opts.displayName,
|
|
130
|
+
passwordHash: null,
|
|
131
|
+
role: opts.role,
|
|
132
|
+
createdAt: now,
|
|
133
|
+
updatedAt: now,
|
|
134
|
+
};
|
|
135
|
+
this.#users.push(user);
|
|
136
|
+
this.#save();
|
|
137
|
+
return this.#toPublic(user);
|
|
138
|
+
}
|
|
105
139
|
/** Generate a random temporary password. */
|
|
106
140
|
static generatePassword() {
|
|
107
141
|
return randomBytes(6).toString('base64url');
|