sh3-server 0.6.0 → 0.8.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.
@@ -11,21 +11,37 @@ export interface MountContext {
11
11
  * route prefix. The returned value is a Hono middleware handler that
12
12
  * can be passed directly as the handler argument to `router.get(path, ...)`.
13
13
  *
14
- * The `ws` argument passed to `onConnect` is the server-side WebSocket
15
- * from `@hono/node-ws` (which wraps the `ws` package). `@hono/node-ws`
16
- * does not re-export a type name for it and `@types/ws` is not a
17
- * dependency, so we use `any` per the plan's explicit fallback rule.
18
- * The returned handler is likewise typed as `any` — its concrete type
19
- * is Hono's internal `MiddlewareHandler<..., { outputFormat: "ws" }>`,
20
- * which would leak Hono ws internals into every shard that touches it.
14
+ * The `ws` argument passed to `onConnect` is the `WSContext` from
15
+ * `@hono/node-ws` enriched with two per-connection setter methods:
16
+ *
17
+ * - `ws.send(data)` inherited from WSContext, send a frame.
18
+ * - `ws.close()` inherited from WSContext, close the connection.
19
+ * - `ws.onMessage(handler)` register a callback that receives each
20
+ * incoming frame as a string. Only one handler; last call wins.
21
+ * - `ws.onClose(handler)` — register a callback fired when the
22
+ * connection closes. Only one handler; last call wins.
23
+ *
24
+ * These setters exist because `@hono/node-ws` exposes the lifecycle
25
+ * via the `WSEvents` interface returned from `upgradeWebSocket`, not
26
+ * via `addEventListener` on WSContext. `wsRegister` in sh3-server
27
+ * bridges the two so shards can keep all per-connection state in a
28
+ * single closure. Do NOT try `ws.addEventListener('message', ...)` —
29
+ * WSContext has no such method and the call silently no-ops.
30
+ *
31
+ * `@hono/node-ws` does not re-export a type name for WSContext and
32
+ * `@types/ws` is not a dependency, so we use `any` per the plan's
33
+ * explicit fallback rule. The returned handler is likewise typed as
34
+ * `any` — its concrete type is Hono's internal
35
+ * `MiddlewareHandler<..., { outputFormat: "ws" }>`, which would leak
36
+ * Hono ws internals into every shard that touches it.
21
37
  *
22
38
  * The `c` argument is the Hono `Context` for the upgrade request.
23
39
  * Shards can read `c.get('session')` to route the connection to the
24
40
  * correct per-user state (e.g. `manager.getOrCreate(session.userId)`).
25
41
  *
26
- * @param onConnect Called with the raw WebSocket and request Context
27
- * when a client connects. Attach listeners in the body;
28
- * return nothing.
42
+ * @param onConnect Called with the enriched WebSocket and request
43
+ * Context when a client connects. Attach listeners
44
+ * via `ws.onMessage` / `ws.onClose`; return nothing.
29
45
  */
30
46
  wsRegister(onConnect: (ws: any, c: any) => void): any;
31
47
  }
@@ -4,6 +4,8 @@ import type { WsLike } from './session-manager.js';
4
4
  export interface ShellServerContext {
5
5
  shardId: string;
6
6
  dataDir: string;
7
+ /** Base for per-user document roots; empty string → <dataDir>/users. */
8
+ tenantRootBase?: string;
7
9
  adminOnly: any;
8
10
  wsRegister: (onConnect: (ws: WsLike, c: Context) => void) => any;
9
11
  }
@@ -10,6 +10,7 @@
10
10
  import { LocalRunner } from './runner.js';
11
11
  import { SessionManager } from './session-manager.js';
12
12
  import { handleClientMessage } from './ws.js';
13
+ import { shardDocumentsPath } from '../tenant-fs/paths.js';
13
14
  function sessionUser(c) {
14
15
  const session = c.get('session') ?? c.env?.session;
15
16
  return session?.userId ?? 'admin';
@@ -20,7 +21,8 @@ export default {
20
21
  // Default config — overridable via shard env state in a future pass.
21
22
  const cfg = { ringSize: 500, historyMaxLines: 10_000, defaultCwd: '' };
22
23
  const runner = new LocalRunner();
23
- const manager = new SessionManager(ctx.dataDir, runner, cfg);
24
+ const userCwd = (userId) => shardDocumentsPath(ctx.dataDir, userId, 'shell', ctx.tenantRootBase ?? '');
25
+ const manager = new SessionManager(ctx.dataDir, runner, cfg, userCwd);
24
26
  // NOTE: the JSON /history endpoint is convenience — the authoritative
25
27
  // delivery of history is via the `history` server message sent on WS
26
28
  // attach. Clients can skip the REST endpoint entirely. Kept for
@@ -39,11 +41,14 @@ export default {
39
41
  const userId = sessionUser(c);
40
42
  const session = manager.getOrCreate(userId);
41
43
  session.attach(ws);
42
- // Wire incoming frames through the dispatch layer.
43
- ws.addEventListener?.('message', (evt) => {
44
- handleClientMessage(session, ws, String(evt.data));
44
+ // Wire incoming frames through the dispatch layer. `wsRegister`
45
+ // enriches the ws object with per-connection `onMessage` /
46
+ // `onClose` setters that forward the hono/node-ws WSEvents
47
+ // callbacks for this upgrade.
48
+ ws.onMessage((data) => {
49
+ handleClientMessage(session, ws, data);
45
50
  });
46
- ws.addEventListener?.('close', () => {
51
+ ws.onClose(() => {
47
52
  session.detach(ws);
48
53
  });
49
54
  }));
@@ -3,6 +3,28 @@ export interface RunnerStartOpts {
3
3
  cmd: string;
4
4
  cwd: string;
5
5
  env: Record<string, string>;
6
+ /**
7
+ * Optional event callbacks wired up synchronously at the top of start()
8
+ * BEFORE the emitter can fire. Pass these when the caller cannot attach
9
+ * listeners on `handle.events` atomically with receiving the handle —
10
+ * e.g., ShellSession.submit, which uses `await runner.start(...)` and
11
+ * therefore only gets a chance to attach listeners in the next microtask.
12
+ *
13
+ * Why this matters: sync-failure paths (tokenize throw, empty command,
14
+ * Node 24's CVE-2024-27980 EINVAL for .cmd/.bat on Windows) emit their
15
+ * 'stderr'/'exit' events immediately via setImmediate. Without pre-
16
+ * registration, there's a subtle race between the emitter firing and
17
+ * the caller's post-await listener attachment — when `this.running = handle`
18
+ * is assigned only after await, an exit callback that fires on the wrong
19
+ * side of the boundary leaves `running` pointing at a dead handle and
20
+ * every subsequent submit gets silently dropped by the running-guard.
21
+ */
22
+ onStdout?: (data: string) => void;
23
+ onStderr?: (data: string) => void;
24
+ onExit?: (info: {
25
+ code: number | null;
26
+ signal: string | null;
27
+ }) => void;
6
28
  }
7
29
  export interface RunnerHandle {
8
30
  /** Send bytes to the process's stdin. No-op for LocalRunner in v1. */
@@ -14,12 +14,35 @@ import { tokenize } from './tokenize.js';
14
14
  export class LocalRunner {
15
15
  async start(opts) {
16
16
  const events = new EventEmitter();
17
+ // Pre-attach caller-supplied listeners BEFORE any code path that
18
+ // could emit. See note on RunnerStartOpts — this closes the race
19
+ // for every sync-failure path (tokenize throw, empty cmd, EINVAL).
20
+ if (opts.onStdout)
21
+ events.on('stdout', opts.onStdout);
22
+ if (opts.onStderr)
23
+ events.on('stderr', opts.onStderr);
24
+ if (opts.onExit)
25
+ events.on('exit', opts.onExit);
17
26
  let child;
18
27
  try {
19
- const [bin, ...args] = tokenize(opts.cmd);
28
+ // tokenize is used for VALIDATION only — detect unterminated quotes
29
+ // and empty commands before handing off to the shell. We don't pass
30
+ // the tokenized args to spawn: with `shell: true` (below) the shell
31
+ // re-parses the raw command line, and tokenizing would strip quotes
32
+ // the shell needs for its own escaping.
33
+ const [bin] = tokenize(opts.cmd);
20
34
  if (!bin) {
21
- // Empty command — emit an error exit without spawning
22
- queueMicrotask(() => {
35
+ // Empty command — emit an error exit without spawning.
36
+ // Must use setImmediate, not queueMicrotask: the caller
37
+ // (ShellSession.submit) attaches its 'stdout'/'stderr'/'exit'
38
+ // listeners in the continuation after `await runner.start()`,
39
+ // which is itself a microtask. queueMicrotask would drain FIFO
40
+ // before that continuation, emitting to an emitter with zero
41
+ // listeners — the exit event silently vanishes and the session
42
+ // gets stuck with `running` pointing at a dead handle. setImmediate
43
+ // defers to the event loop's check phase, strictly after microtask
44
+ // drain, so listeners are always in place when we fire.
45
+ setImmediate(() => {
23
46
  events.emit('stderr', 'shell: empty command\n');
24
47
  events.emit('exit', { code: null, signal: 'spawn-error' });
25
48
  });
@@ -30,15 +53,24 @@ export class LocalRunner {
30
53
  events,
31
54
  };
32
55
  }
33
- child = spawn(bin, args, {
56
+ // Use shell: true so the platform shell resolves PATH, handles
57
+ // Windows .cmd/.bat files (npm, yarn, tsc, …), and gives the user
58
+ // the metacharacter semantics they expect from a terminal. Node
59
+ // 24's CVE-2024-27980 patch throws EINVAL synchronously for
60
+ // .cmd/.bat with shell: false — which is what the terminal IS
61
+ // supposed to run, so shell: true is the only correct mode here.
62
+ // We pass opts.cmd as the sole command string and [] as args so
63
+ // spawn hands the whole line to the shell untouched.
64
+ child = spawn(opts.cmd, [], {
34
65
  cwd: opts.cwd,
35
66
  env: opts.env,
36
- shell: false,
67
+ shell: true,
37
68
  });
38
69
  }
39
70
  catch (err) {
40
71
  const message = err.message ?? String(err);
41
- queueMicrotask(() => {
72
+ // See note above on setImmediate vs queueMicrotask same race.
73
+ setImmediate(() => {
42
74
  events.emit('stderr', `shell: ${message}\n`);
43
75
  events.emit('exit', { code: null, signal: 'spawn-error' });
44
76
  });
@@ -7,13 +7,20 @@ export interface SessionConfig {
7
7
  defaultCwd: string;
8
8
  }
9
9
  /**
10
- * Minimal WebSocket interface — any object with .send(string) satisfies it.
11
- * The server's real WS implementation (from @hono/node-ws) provides a richer
12
- * surface; we intentionally narrow it here so tests can use plain mocks.
10
+ * Minimal WebSocket interface — what ShellSession needs to talk to a
11
+ * connected client. Real connections come from sh3-server's wsRegister
12
+ * (see MountContext.wsRegister in shard-router.ts), which enriches the
13
+ * `@hono/node-ws` WSContext with per-connection `onMessage` / `onClose`
14
+ * setter methods. ShellSession itself only ever calls `send` / `close`;
15
+ * the setter methods are only used by the connection-wiring code in
16
+ * shell-shard/index.ts, but they stay on this interface so the same
17
+ * type flows through `wsRegister(onConnect: (ws: WsLike, ...))`.
13
18
  */
14
19
  export interface WsLike {
15
20
  send(data: string): void;
16
21
  close(): void;
22
+ onMessage(handler: (data: string) => void): void;
23
+ onClose(handler: () => void): void;
17
24
  }
18
25
  export declare class ShellSession {
19
26
  readonly userId: string;
@@ -51,6 +58,7 @@ export declare class SessionManager {
51
58
  private readonly dataDir;
52
59
  private readonly runner;
53
60
  private readonly cfg;
54
- constructor(dataDir: string, runner: Runner, cfg: SessionConfig);
61
+ private readonly userCwd;
62
+ constructor(dataDir: string, runner: Runner, cfg: SessionConfig, userCwd: (userId: string) => string);
55
63
  getOrCreate(userId: string): ShellSession;
56
64
  }
@@ -80,6 +80,13 @@ export class ShellSession {
80
80
  // Claim the start slot synchronously so a concurrent submit during
81
81
  // the `await runner.start()` below cannot slip past the lock check.
82
82
  this.starting = true;
83
+ // Track whether the runner already exited before we latched `running`.
84
+ // Sync-failure paths (empty cmd, tokenize throw, Node 24's EINVAL for
85
+ // .cmd/.bat on Windows) can fire onExit via setImmediate BEFORE the
86
+ // await continuation runs — so we must not assign `this.running = handle`
87
+ // for a process that's already dead, or subsequent submits get wedged
88
+ // by the running-guard above. See runner.ts for the full race writeup.
89
+ let exited = false;
83
90
  try {
84
91
  this.history.append(line);
85
92
  // Emit a prompt event so every attached client echoes the line
@@ -94,24 +101,33 @@ export class ShellSession {
94
101
  cmd: line,
95
102
  cwd: this.cwd,
96
103
  env: this.env,
104
+ // Callback listeners — attached synchronously inside start() BEFORE
105
+ // any emit can fire. Do NOT switch back to `handle.events.on(...)`
106
+ // after await: that re-opens the race the callbacks exist to close.
107
+ onStdout: (data) => {
108
+ this.broadcast({ kind: 'stdout', data, ts: Date.now(), seq: 0 });
109
+ },
110
+ onStderr: (data) => {
111
+ this.broadcast({ kind: 'stderr', data, ts: Date.now(), seq: 0 });
112
+ },
113
+ onExit: (info) => {
114
+ this.broadcast({
115
+ kind: 'exit',
116
+ code: info.code,
117
+ signal: info.signal,
118
+ ts: Date.now(),
119
+ seq: 0,
120
+ });
121
+ exited = true;
122
+ this.running = null;
123
+ },
97
124
  });
98
- this.running = handle;
99
- handle.events.on('stdout', (data) => {
100
- this.broadcast({ kind: 'stdout', data, ts: Date.now(), seq: 0 });
101
- });
102
- handle.events.on('stderr', (data) => {
103
- this.broadcast({ kind: 'stderr', data, ts: Date.now(), seq: 0 });
104
- });
105
- handle.events.on('exit', (info) => {
106
- this.broadcast({
107
- kind: 'exit',
108
- code: info.code,
109
- signal: info.signal,
110
- ts: Date.now(),
111
- seq: 0,
112
- });
113
- this.running = null;
114
- });
125
+ // Only latch `running` if the process hasn't already died during the
126
+ // await boundary. Otherwise we'd resurrect a dead handle and wedge
127
+ // every subsequent submit.
128
+ if (!exited) {
129
+ this.running = handle;
130
+ }
115
131
  }
116
132
  finally {
117
133
  this.starting = false;
@@ -144,16 +160,29 @@ export class SessionManager {
144
160
  dataDir;
145
161
  runner;
146
162
  cfg;
147
- constructor(dataDir, runner, cfg) {
163
+ userCwd;
164
+ constructor(dataDir, runner, cfg, userCwd) {
148
165
  this.dataDir = dataDir;
149
166
  this.runner = runner;
150
167
  this.cfg = cfg;
168
+ this.userCwd = userCwd;
151
169
  }
152
170
  getOrCreate(userId) {
153
171
  let session = this.sessions.get(userId);
154
172
  if (!session) {
155
173
  const history = new HistoryStore(this.dataDir, userId, this.cfg.historyMaxLines);
156
- session = new ShellSession(userId, this.runner, history, this.cfg);
174
+ // Resolve user cwd; on disk failure fall back to cfg.defaultCwd so
175
+ // a sick data dir doesn't block login. The warning surfaces in the
176
+ // server log so ops can notice.
177
+ let cwd = this.cfg.defaultCwd;
178
+ try {
179
+ cwd = this.userCwd(userId);
180
+ }
181
+ catch (err) {
182
+ console.warn(`[shell-shard] userCwd failed for ${userId}: ${err.message} — falling back to ${cwd || 'process.cwd()'}`);
183
+ }
184
+ const cfg = { ...this.cfg, defaultCwd: cwd };
185
+ session = new ShellSession(userId, this.runner, history, cfg);
157
186
  this.sessions.set(userId, session);
158
187
  }
159
188
  return session;
@@ -15,21 +15,16 @@ export function handleClientMessage(session, ws, raw) {
15
15
  msg = JSON.parse(raw);
16
16
  }
17
17
  catch {
18
- // Malformed frame — ignore, do not crash the session.
19
18
  return;
20
19
  }
21
20
  switch (msg.t) {
22
21
  case 'hello':
23
- // attach() already sent welcome+replay+history at connection time.
24
- // The only meaningful thing here is a late hello with replayFrom,
25
- // which v1 ignores — the client can resync by reconnecting.
26
22
  return;
27
23
  case 'submit': {
28
24
  const trimmed = msg.line.trim();
29
25
  if (trimmed.startsWith('cd ') || trimmed === 'cd') {
30
- // Server-managed cd — don't spawn, update session cwd directly.
31
26
  const target = trimmed === 'cd' ? '' : trimmed.slice(3).trim();
32
- handleCd(session, target);
27
+ applyCwdChange(session, target, 'cd');
33
28
  return;
34
29
  }
35
30
  void session.submit(msg.line, ws);
@@ -42,12 +37,12 @@ export function handleClientMessage(session, ws, raw) {
42
37
  session.historyLog(msg.line);
43
38
  return;
44
39
  case 'cwd-query':
45
- // Re-emit a cwd update message (reuses setCwd() broadcast path).
46
40
  session.setCwd(session.cwd);
47
41
  return;
42
+ case 'setCwd':
43
+ applyCwdChange(session, msg.path, 'setCwd');
44
+ return;
48
45
  default: {
49
- // Exhaustiveness check. If a new ClientMessage variant is added to
50
- // the protocol without a handler here, TypeScript flags this line.
51
46
  const _exhaustive = msg;
52
47
  void _exhaustive;
53
48
  return;
@@ -55,11 +50,16 @@ export function handleClientMessage(session, ws, raw) {
55
50
  }
56
51
  }
57
52
  /**
58
- * Handle a `cd` command server-side: resolve the target path, validate it
59
- * exists and is a directory, then update session.cwd via setCwd() which
60
- * broadcasts a 'cwd' message to every attached client.
53
+ * Resolve a cwd-change request against the session's current cwd, validate
54
+ * it exists and is a directory, then update session.cwd via setCwd(). Used
55
+ * for both interactive `cd` (from submit) and programmatic `setCwd` (from
56
+ * the docs tree / file explorer).
57
+ *
58
+ * Stderr wording keeps the familiar `cd: no such directory` for shell users
59
+ * and `setCwd: no such directory` for programmatic callers, so the source
60
+ * of a bad path is obvious in the terminal log.
61
61
  */
62
- function handleCd(session, target) {
62
+ function applyCwdChange(session, target, source) {
63
63
  const dest = target === '' || target === '~'
64
64
  ? homedir()
65
65
  : isAbsolute(target)
@@ -69,7 +69,7 @@ function handleCd(session, target) {
69
69
  session.broadcast({
70
70
  seq: 0,
71
71
  kind: 'stderr',
72
- data: `cd: no such directory: ${target}\n`,
72
+ data: `${source}: no such directory: ${target}\n`,
73
73
  ts: Date.now(),
74
74
  });
75
75
  return;
@@ -0,0 +1,15 @@
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 type { Hono } from 'hono';
8
+ import type { SettingsStore } from '../settings.js';
9
+ export interface TenantFsRouteContext {
10
+ dataDir: string;
11
+ rootBase: string;
12
+ settings: SettingsStore;
13
+ maxReadBytes: number;
14
+ }
15
+ export declare function registerTenantFsRoutes(app: Hono, ctx: TenantFsRouteContext): void;
@@ -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,4 @@
1
+ export * from './paths.js';
2
+ export * from './resolve.js';
3
+ export * from './session-required.js';
4
+ export * from './http.js';
@@ -0,0 +1,4 @@
1
+ export * from './paths.js';
2
+ export * from './resolve.js';
3
+ export * from './session-required.js';
4
+ export * from './http.js';
@@ -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>;