sh3-server 0.7.5 → 0.8.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/index-Cb-zoqb1.js +17 -0
- package/app/assets/index-Cb-zoqb1.js.map +1 -0
- package/app/assets/index-DPcN5Lor.css +1 -0
- package/app/index.html +2 -2
- package/dist/auth.d.ts +10 -3
- package/dist/auth.js +14 -22
- package/dist/caller.d.ts +16 -0
- package/dist/caller.js +54 -0
- package/dist/cli.js +9 -7
- package/dist/fs-backend.d.ts +10 -0
- package/dist/fs-backend.js +105 -0
- package/dist/index.js +30 -12
- package/dist/keys.d.ts +33 -19
- package/dist/keys.js +172 -49
- package/dist/packages.d.ts +23 -3
- package/dist/packages.js +67 -6
- package/dist/routes/admin.js +7 -3
- package/dist/routes/docs.d.ts +2 -0
- package/dist/routes/docs.js +30 -0
- package/dist/routes/keys.d.ts +21 -0
- package/dist/routes/keys.js +164 -0
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +25 -0
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +33 -0
- package/dist/shard-router.d.ts +9 -2
- package/dist/shard-router.js +58 -29
- package/dist/shell-shard/index.d.ts +6 -1
- package/dist/shell-shard/index.js +3 -1
- package/dist/shell-shard/session-manager.d.ts +2 -1
- package/dist/shell-shard/session-manager.js +15 -2
- package/dist/shell-shard/ws.js +14 -14
- package/dist/tenant-fs/http.d.ts +15 -0
- package/dist/tenant-fs/http.js +109 -0
- package/dist/tenant-fs/index.d.ts +4 -0
- package/dist/tenant-fs/index.js +4 -0
- package/dist/tenant-fs/paths.d.ts +23 -0
- package/dist/tenant-fs/paths.js +51 -0
- package/dist/tenant-fs/resolve.d.ts +16 -0
- package/dist/tenant-fs/resolve.js +48 -0
- package/dist/tenant-fs/session-required.d.ts +11 -0
- package/dist/tenant-fs/session-required.js +19 -0
- package/package.json +2 -2
- package/app/assets/index-25fXNyG3.js +0 -12
- package/app/assets/index-25fXNyG3.js.map +0 -1
- package/app/assets/index-BcQ1cruS.css +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant FS HTTP API — mounts /api/fs/list, /api/fs/stat, /api/fs/read.
|
|
3
|
+
*
|
|
4
|
+
* Gated by sessionRequired; scope is the caller's own documentsRoot.
|
|
5
|
+
* Read-only. Writes are out of scope for this iteration.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import { documentsRoot } from './paths.js';
|
|
10
|
+
import { resolveTenantPath, TenantPathEscapeError } from './resolve.js';
|
|
11
|
+
import { makeSessionRequired } from './session-required.js';
|
|
12
|
+
function userIdFromContext(c) {
|
|
13
|
+
const session = c.get('session') ?? c.env?.session;
|
|
14
|
+
if (!session?.userId)
|
|
15
|
+
throw new Error('sessionRequired missing before handler');
|
|
16
|
+
return session.userId;
|
|
17
|
+
}
|
|
18
|
+
export function registerTenantFsRoutes(app, ctx) {
|
|
19
|
+
const sessionRequired = makeSessionRequired(ctx.settings);
|
|
20
|
+
app.get('/api/fs/list', sessionRequired, async (c) => {
|
|
21
|
+
const rel = c.req.query('path') ?? '';
|
|
22
|
+
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
23
|
+
let abs;
|
|
24
|
+
try {
|
|
25
|
+
abs = await resolveTenantPath(root, rel);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err instanceof TenantPathEscapeError)
|
|
29
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
let names;
|
|
33
|
+
try {
|
|
34
|
+
names = await readdir(abs);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err?.code === 'ENOENT')
|
|
38
|
+
return c.json({ error: 'not found' }, 404);
|
|
39
|
+
if (err?.code === 'ENOTDIR')
|
|
40
|
+
return c.json({ error: 'not a directory' }, 400);
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
44
|
+
const s = await stat(`${abs}/${name}`);
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
kind: s.isDirectory() ? 'dir' : 'file',
|
|
48
|
+
size: s.size,
|
|
49
|
+
mtime: s.mtimeMs,
|
|
50
|
+
};
|
|
51
|
+
}));
|
|
52
|
+
return c.json({ entries });
|
|
53
|
+
});
|
|
54
|
+
app.get('/api/fs/stat', sessionRequired, async (c) => {
|
|
55
|
+
const rel = c.req.query('path') ?? '';
|
|
56
|
+
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
57
|
+
let abs;
|
|
58
|
+
try {
|
|
59
|
+
abs = await resolveTenantPath(root, rel);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof TenantPathEscapeError)
|
|
63
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const s = await stat(abs);
|
|
68
|
+
return c.json({
|
|
69
|
+
name: basename(abs),
|
|
70
|
+
kind: s.isDirectory() ? 'dir' : 'file',
|
|
71
|
+
size: s.size,
|
|
72
|
+
mtime: s.mtimeMs,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err?.code === 'ENOENT')
|
|
77
|
+
return c.json({ error: 'not found' }, 404);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
app.get('/api/fs/read', sessionRequired, async (c) => {
|
|
82
|
+
const rel = c.req.query('path') ?? '';
|
|
83
|
+
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
84
|
+
let abs;
|
|
85
|
+
try {
|
|
86
|
+
abs = await resolveTenantPath(root, rel);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err instanceof TenantPathEscapeError)
|
|
90
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
let s;
|
|
94
|
+
try {
|
|
95
|
+
s = await stat(abs);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err?.code === 'ENOENT')
|
|
99
|
+
return c.json({ error: 'not found' }, 404);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
if (s.isDirectory())
|
|
103
|
+
return c.json({ error: 'is a directory' }, 400);
|
|
104
|
+
if (s.size > ctx.maxReadBytes)
|
|
105
|
+
return c.json({ error: 'file too large' }, 413);
|
|
106
|
+
const buf = await readFile(abs);
|
|
107
|
+
return c.body(buf, 200, { 'Content-Type': 'application/octet-stream' });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant path helpers — the framework owns the `data/users/<id>/...` layout.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers:
|
|
5
|
+
* users/<id>/<shardId>/ — shard's private per-user state (internal)
|
|
6
|
+
* users/<id>/documents/<shardId>/ — user-facing files (exposed via /api/fs)
|
|
7
|
+
*
|
|
8
|
+
* Shards call these helpers server-side. Clients use /api/fs/* to reach
|
|
9
|
+
* documents/ only — shard state is never served over HTTP.
|
|
10
|
+
*/
|
|
11
|
+
export type TenantRootResolver = (userId: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* Back-compat resolver: <base>/<userId>/documents. Equivalent to
|
|
14
|
+
* documentsRoot() but parameterized by rootBase. Retained for sites that
|
|
15
|
+
* historically accepted a resolver function.
|
|
16
|
+
*/
|
|
17
|
+
export declare function makeTenantRootResolver(dataDir: string, rootBase: string): TenantRootResolver;
|
|
18
|
+
/** <dataDir>/users/<userId>/documents — the document library root. */
|
|
19
|
+
export declare function documentsRoot(dataDir: string, userId: string, rootBase?: string): string;
|
|
20
|
+
/** <dataDir>/users/<userId>/documents/<shardId> — user docs produced by shard. */
|
|
21
|
+
export declare function shardDocumentsPath(dataDir: string, userId: string, shardId: string, rootBase?: string): string;
|
|
22
|
+
/** <dataDir>/users/<userId>/<shardId> — shard-internal per-user state. */
|
|
23
|
+
export declare function shardStatePath(dataDir: string, userId: string, shardId: string, rootBase?: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant path helpers — the framework owns the `data/users/<id>/...` layout.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers:
|
|
5
|
+
* users/<id>/<shardId>/ — shard's private per-user state (internal)
|
|
6
|
+
* users/<id>/documents/<shardId>/ — user-facing files (exposed via /api/fs)
|
|
7
|
+
*
|
|
8
|
+
* Shards call these helpers server-side. Clients use /api/fs/* to reach
|
|
9
|
+
* documents/ only — shard state is never served over HTTP.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
13
|
+
function resolveBase(dataDir, rootBase) {
|
|
14
|
+
if (rootBase === '')
|
|
15
|
+
return join(dataDir, 'users');
|
|
16
|
+
return isAbsolute(rootBase) ? normalize(rootBase) : resolve(dataDir, rootBase);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Back-compat resolver: <base>/<userId>/documents. Equivalent to
|
|
20
|
+
* documentsRoot() but parameterized by rootBase. Retained for sites that
|
|
21
|
+
* historically accepted a resolver function.
|
|
22
|
+
*/
|
|
23
|
+
export function makeTenantRootResolver(dataDir, rootBase) {
|
|
24
|
+
const base = resolveBase(dataDir, rootBase);
|
|
25
|
+
return (userId) => {
|
|
26
|
+
const root = join(base, userId, 'documents');
|
|
27
|
+
mkdirSync(root, { recursive: true });
|
|
28
|
+
return root;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** <dataDir>/users/<userId>/documents — the document library root. */
|
|
32
|
+
export function documentsRoot(dataDir, userId, rootBase = '') {
|
|
33
|
+
const base = resolveBase(dataDir, rootBase);
|
|
34
|
+
const p = join(base, userId, 'documents');
|
|
35
|
+
mkdirSync(p, { recursive: true });
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
/** <dataDir>/users/<userId>/documents/<shardId> — user docs produced by shard. */
|
|
39
|
+
export function shardDocumentsPath(dataDir, userId, shardId, rootBase = '') {
|
|
40
|
+
const base = resolveBase(dataDir, rootBase);
|
|
41
|
+
const p = join(base, userId, 'documents', shardId);
|
|
42
|
+
mkdirSync(p, { recursive: true });
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
/** <dataDir>/users/<userId>/<shardId> — shard-internal per-user state. */
|
|
46
|
+
export function shardStatePath(dataDir, userId, shardId, rootBase = '') {
|
|
47
|
+
const base = resolveBase(dataDir, rootBase);
|
|
48
|
+
const p = join(base, userId, shardId);
|
|
49
|
+
mkdirSync(p, { recursive: true });
|
|
50
|
+
return p;
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment helper — resolves a relative path under a tenant root and
|
|
3
|
+
* asserts the real resolved path stays inside the root. Use this for ANY
|
|
4
|
+
* file I/O that accepts a path from the client.
|
|
5
|
+
*
|
|
6
|
+
* - Resolves `rel` against `root`.
|
|
7
|
+
* - Calls fs.realpath to follow symlinks.
|
|
8
|
+
* - If the target does not exist yet (e.g. future write ops), realpath
|
|
9
|
+
* walks up to the deepest existing ancestor and appends the remainder.
|
|
10
|
+
* - Throws TenantPathEscapeError if the real path is not root or a descendant.
|
|
11
|
+
*/
|
|
12
|
+
export declare class TenantPathEscapeError extends Error {
|
|
13
|
+
readonly rel: string;
|
|
14
|
+
constructor(rel: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function resolveTenantPath(root: string, rel: string): Promise<string>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment helper — resolves a relative path under a tenant root and
|
|
3
|
+
* asserts the real resolved path stays inside the root. Use this for ANY
|
|
4
|
+
* file I/O that accepts a path from the client.
|
|
5
|
+
*
|
|
6
|
+
* - Resolves `rel` against `root`.
|
|
7
|
+
* - Calls fs.realpath to follow symlinks.
|
|
8
|
+
* - If the target does not exist yet (e.g. future write ops), realpath
|
|
9
|
+
* walks up to the deepest existing ancestor and appends the remainder.
|
|
10
|
+
* - Throws TenantPathEscapeError if the real path is not root or a descendant.
|
|
11
|
+
*/
|
|
12
|
+
import { realpath } from 'node:fs/promises';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { resolve, sep, dirname, basename, join } from 'node:path';
|
|
15
|
+
export class TenantPathEscapeError extends Error {
|
|
16
|
+
rel;
|
|
17
|
+
constructor(rel) {
|
|
18
|
+
super(`path escape: ${rel} resolves outside tenant root`);
|
|
19
|
+
this.rel = rel;
|
|
20
|
+
this.name = 'TenantPathEscapeError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function realpathDeepest(p) {
|
|
24
|
+
// Walk up until we find an existing ancestor, realpath it, then
|
|
25
|
+
// rejoin the non-existing tail. This lets us validate paths that
|
|
26
|
+
// do not exist yet (future write ops) without allowing escape via
|
|
27
|
+
// a non-existent path component.
|
|
28
|
+
let cursor = p;
|
|
29
|
+
const tail = [];
|
|
30
|
+
while (!existsSync(cursor)) {
|
|
31
|
+
const parent = dirname(cursor);
|
|
32
|
+
if (parent === cursor)
|
|
33
|
+
break; // reached filesystem root
|
|
34
|
+
tail.unshift(basename(cursor));
|
|
35
|
+
cursor = parent;
|
|
36
|
+
}
|
|
37
|
+
const real = await realpath(cursor);
|
|
38
|
+
return tail.length === 0 ? real : join(real, ...tail);
|
|
39
|
+
}
|
|
40
|
+
export async function resolveTenantPath(root, rel) {
|
|
41
|
+
const realRoot = await realpath(root);
|
|
42
|
+
const abs = resolve(realRoot, rel);
|
|
43
|
+
const real = await realpathDeepest(abs);
|
|
44
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
|
|
45
|
+
throw new TenantPathEscapeError(rel);
|
|
46
|
+
}
|
|
47
|
+
return real;
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
import type { SettingsStore } from '../settings.js';
|
|
3
|
+
/**
|
|
4
|
+
* Requires an authenticated session on the request. Any role passes.
|
|
5
|
+
* When `auth.required` is false (dev/--no-auth), passes through.
|
|
6
|
+
*
|
|
7
|
+
* Contrast with adminOnly: this gate is for tenant-scoped APIs where any
|
|
8
|
+
* logged-in user can operate — scope is enforced by the handler jailing to
|
|
9
|
+
* the caller's own tenant root, not by role.
|
|
10
|
+
*/
|
|
11
|
+
export declare function makeSessionRequired(settings: SettingsStore): MiddlewareHandler;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requires an authenticated session on the request. Any role passes.
|
|
3
|
+
* When `auth.required` is false (dev/--no-auth), passes through.
|
|
4
|
+
*
|
|
5
|
+
* Contrast with adminOnly: this gate is for tenant-scoped APIs where any
|
|
6
|
+
* logged-in user can operate — scope is enforced by the handler jailing to
|
|
7
|
+
* the caller's own tenant root, not by role.
|
|
8
|
+
*/
|
|
9
|
+
export function makeSessionRequired(settings) {
|
|
10
|
+
return async (c, next) => {
|
|
11
|
+
if (!settings.get().auth.required)
|
|
12
|
+
return next();
|
|
13
|
+
const session = c.get('session') ?? c.env?.session;
|
|
14
|
+
if (!session?.userId) {
|
|
15
|
+
return c.json({ error: 'authentication required' }, 401);
|
|
16
|
+
}
|
|
17
|
+
return next();
|
|
18
|
+
};
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sh3-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sh3-server": "dist/cli.js"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@types/node": "^20.0.0",
|
|
40
40
|
"esbuild": "^0.21.5",
|
|
41
41
|
"postject": "^1.0.0-alpha.6",
|
|
42
|
-
"sh3-core": "
|
|
42
|
+
"sh3-core": "*",
|
|
43
43
|
"svelte": "^5.0.0",
|
|
44
44
|
"tsx": "^4.0.0",
|
|
45
45
|
"typescript": "^5.6.0",
|