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.
Files changed (46) hide show
  1. package/app/assets/index-Cb-zoqb1.js +17 -0
  2. package/app/assets/index-Cb-zoqb1.js.map +1 -0
  3. package/app/assets/index-DPcN5Lor.css +1 -0
  4. package/app/index.html +2 -2
  5. package/dist/auth.d.ts +10 -3
  6. package/dist/auth.js +14 -22
  7. package/dist/caller.d.ts +16 -0
  8. package/dist/caller.js +54 -0
  9. package/dist/cli.js +9 -7
  10. package/dist/fs-backend.d.ts +10 -0
  11. package/dist/fs-backend.js +105 -0
  12. package/dist/index.js +30 -12
  13. package/dist/keys.d.ts +33 -19
  14. package/dist/keys.js +172 -49
  15. package/dist/packages.d.ts +23 -3
  16. package/dist/packages.js +67 -6
  17. package/dist/routes/admin.js +7 -3
  18. package/dist/routes/docs.d.ts +2 -0
  19. package/dist/routes/docs.js +30 -0
  20. package/dist/routes/keys.d.ts +21 -0
  21. package/dist/routes/keys.js +164 -0
  22. package/dist/scope.d.ts +9 -0
  23. package/dist/scope.js +25 -0
  24. package/dist/settings.d.ts +13 -0
  25. package/dist/settings.js +33 -0
  26. package/dist/shard-router.d.ts +9 -2
  27. package/dist/shard-router.js +58 -29
  28. package/dist/shell-shard/index.d.ts +6 -1
  29. package/dist/shell-shard/index.js +3 -1
  30. package/dist/shell-shard/session-manager.d.ts +2 -1
  31. package/dist/shell-shard/session-manager.js +15 -2
  32. package/dist/shell-shard/ws.js +14 -14
  33. package/dist/tenant-fs/http.d.ts +15 -0
  34. package/dist/tenant-fs/http.js +109 -0
  35. package/dist/tenant-fs/index.d.ts +4 -0
  36. package/dist/tenant-fs/index.js +4 -0
  37. package/dist/tenant-fs/paths.d.ts +23 -0
  38. package/dist/tenant-fs/paths.js +51 -0
  39. package/dist/tenant-fs/resolve.d.ts +16 -0
  40. package/dist/tenant-fs/resolve.js +48 -0
  41. package/dist/tenant-fs/session-required.d.ts +11 -0
  42. package/dist/tenant-fs/session-required.js +19 -0
  43. package/package.json +2 -2
  44. package/app/assets/index-25fXNyG3.js +0 -12
  45. package/app/assets/index-25fXNyG3.js.map +0 -1
  46. package/app/assets/index-BcQ1cruS.css +0 -1
@@ -0,0 +1,164 @@
1
+ /**
2
+ * User-tenant key management endpoints.
3
+ *
4
+ * Auth: uses `caller.tenantId` set by resolveCaller. Each caller can only
5
+ * see and revoke keys in their own tenant.
6
+ *
7
+ * Mint flow:
8
+ * 1. Shell calls POST /consent (session-only) → receives a short-lived ticket.
9
+ * 2. Shard calls POST / with the ticket → key is generated and returned once.
10
+ *
11
+ * Tickets are single-use and expire after TICKET_TTL_MS (60 s).
12
+ */
13
+ import { Hono } from 'hono';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { tenantRequired } from '../scope.js';
16
+ const TICKET_TTL_MS = 60_000;
17
+ function sweepExpired(tickets) {
18
+ const now = Date.now();
19
+ for (const [token, entry] of tickets) {
20
+ if (now - entry.issuedAt > TICKET_TTL_MS)
21
+ tickets.delete(token);
22
+ }
23
+ }
24
+ export function createKeysRouter(keys, onRevoke) {
25
+ const router = new Hono();
26
+ const tickets = new Map();
27
+ // SSE subscribers — one entry per connected browser tab.
28
+ const sseSubscribers = new Set();
29
+ /** Push a revocation event to all connected SSE listeners. */
30
+ function pushToBus(ev) {
31
+ for (const fn of sseSubscribers)
32
+ fn(ev);
33
+ }
34
+ router.use('*', tenantRequired);
35
+ router.get('/', (c) => {
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const caller = c.get('caller');
38
+ return c.json(keys.listForTenant(caller.tenantId));
39
+ });
40
+ /**
41
+ * POST /consent — shell-only endpoint that issues a single-use consent ticket.
42
+ *
43
+ * Only session-authenticated callers may call this — bearer-token clients
44
+ * (background shards) must not be able to self-issue consent proofs.
45
+ */
46
+ router.post('/consent', async (c) => {
47
+ sweepExpired(tickets);
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ const caller = c.get('caller');
50
+ if (caller.source !== 'session' || !caller.userId) {
51
+ return c.json({ error: 'Consent requires a logged-in session.' }, 403);
52
+ }
53
+ const body = (await c.req.json());
54
+ if (!body.shardId || !body.label || !Array.isArray(body.scopes)) {
55
+ return c.json({ error: 'Missing shardId/label/scopes' }, 400);
56
+ }
57
+ if (!body.scopes.every((s) => typeof s === 'string')) {
58
+ return c.json({ error: 'scopes must be an array of strings' }, 400);
59
+ }
60
+ const ticket = `tk_${randomBytes(16).toString('hex')}`;
61
+ tickets.set(ticket, {
62
+ shardId: body.shardId,
63
+ label: body.label,
64
+ scopes: body.scopes,
65
+ connectorId: body.connectorId,
66
+ expiresIn: body.expiresIn,
67
+ tenantId: caller.tenantId,
68
+ userId: caller.userId,
69
+ issuedAt: Date.now(),
70
+ });
71
+ return c.json({ ticket });
72
+ });
73
+ /**
74
+ * POST / — mint a key using a valid consent ticket.
75
+ *
76
+ * Ticket is consumed on first use (single-use). Expired or unknown tickets
77
+ * return 400. The full key value is returned exactly once.
78
+ */
79
+ router.post('/', async (c) => {
80
+ sweepExpired(tickets);
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ const caller = c.get('caller');
83
+ const body = (await c.req.json());
84
+ if (!body.ticket)
85
+ return c.json({ error: 'Missing ticket' }, 400);
86
+ const entry = tickets.get(body.ticket);
87
+ tickets.delete(body.ticket); // consume immediately (single-use)
88
+ if (!entry)
89
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
90
+ if (Date.now() - entry.issuedAt > TICKET_TTL_MS) {
91
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
92
+ }
93
+ if (entry.tenantId !== caller.tenantId) {
94
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
95
+ }
96
+ const expiresAt = entry.expiresIn
97
+ ? new Date(Date.now() + entry.expiresIn).toISOString()
98
+ : undefined;
99
+ const row = keys.generate({
100
+ label: entry.label,
101
+ tenantId: entry.tenantId,
102
+ ownerUserId: entry.userId,
103
+ mintedByShardId: entry.shardId,
104
+ scopes: entry.scopes,
105
+ connectorId: entry.connectorId,
106
+ expiresAt,
107
+ });
108
+ return c.json({ id: row.id, key: row.key });
109
+ });
110
+ /**
111
+ * GET /events — server-sent events stream.
112
+ *
113
+ * Delivers `{ id, shardId }` messages whenever a key belonging to this
114
+ * tenant is revoked from any source. The client-side revocation bus
115
+ * consumes this stream and dispatches `onKeyRevoked` on the owning shard.
116
+ *
117
+ * Auth: tenantRequired (already applied via the `*` middleware above).
118
+ * Each connected tab gets its own stream. Connections are cleaned up
119
+ * automatically when the client disconnects (abort signal).
120
+ */
121
+ router.get('/events', (c) => {
122
+ return new Response(new ReadableStream({
123
+ start(controller) {
124
+ const enc = new TextEncoder();
125
+ const send = (data) => {
126
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`));
127
+ };
128
+ const fn = (ev) => send(ev);
129
+ sseSubscribers.add(fn);
130
+ // Send a keepalive comment every 20 s so proxies don't kill idle connections.
131
+ const heartbeat = setInterval(() => {
132
+ try {
133
+ controller.enqueue(enc.encode(': ping\n\n'));
134
+ }
135
+ catch { /* stream closed */ }
136
+ }, 20_000);
137
+ c.req.raw.signal?.addEventListener('abort', () => {
138
+ clearInterval(heartbeat);
139
+ sseSubscribers.delete(fn);
140
+ controller.close();
141
+ });
142
+ },
143
+ }), {
144
+ headers: {
145
+ 'content-type': 'text/event-stream',
146
+ 'cache-control': 'no-cache',
147
+ connection: 'keep-alive',
148
+ },
149
+ });
150
+ });
151
+ router.delete('/:id', async (c) => {
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ const caller = c.get('caller');
154
+ const id = c.req.param('id');
155
+ const removed = keys.revoke(caller.tenantId, id);
156
+ if (!removed)
157
+ return c.json({ error: 'Key not found' }, 404);
158
+ await onRevoke({ tenantId: caller.tenantId, id, row: removed });
159
+ // Broadcast to SSE subscribers so other browser tabs learn of the revocation.
160
+ pushToBus({ id, shardId: removed.mintedByShardId ?? null });
161
+ return c.json({ ok: true });
162
+ });
163
+ return router;
164
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Scope-based route guards. Operate on the `caller` set by resolveCaller.
3
+ *
4
+ * scopeRequired(scope) — 403 unless caller has that scope or admin:*.
5
+ * tenantRequired — 401 unless caller.tenantId is non-null.
6
+ */
7
+ import type { MiddlewareHandler } from 'hono';
8
+ export declare function scopeRequired(scope: string): MiddlewareHandler;
9
+ export declare const tenantRequired: MiddlewareHandler;
package/dist/scope.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Scope-based route guards. Operate on the `caller` set by resolveCaller.
3
+ *
4
+ * scopeRequired(scope) — 403 unless caller has that scope or admin:*.
5
+ * tenantRequired — 401 unless caller.tenantId is non-null.
6
+ */
7
+ export function scopeRequired(scope) {
8
+ return async (c, next) => {
9
+ const caller = c.get('caller');
10
+ if (!caller)
11
+ return c.json({ error: 'Caller not resolved' }, 500);
12
+ if (caller.scopes.includes('admin:*') || caller.scopes.includes(scope)) {
13
+ return next();
14
+ }
15
+ return c.json({ error: `Missing required scope: ${scope}` }, 403);
16
+ };
17
+ }
18
+ export const tenantRequired = async (c, next) => {
19
+ const caller = c.get('caller');
20
+ if (!caller)
21
+ return c.json({ error: 'Caller not resolved' }, 500);
22
+ if (!caller.tenantId)
23
+ return c.json({ error: 'Tenant-scoped credentials required' }, 401);
24
+ return next();
25
+ };
@@ -9,6 +9,17 @@ export interface GlobalSettings {
9
9
  sessionTTL: number;
10
10
  selfRegistration: boolean;
11
11
  };
12
+ tenants: {
13
+ /** Absolute or dataDir-relative base; each user gets `<base>/<userId>/documents/`.
14
+ * Empty string means "<dataDir>/users" at resolve time. */
15
+ rootBase: string;
16
+ };
17
+ packages: {
18
+ /** Max-age in seconds for `GET /packages/:id/client.js` responses.
19
+ * Clamped to [0, 31536000]. 0 emits `Cache-Control: no-store`.
20
+ * Any other value emits `public, max-age=N` (never `immutable`). */
21
+ cacheMaxAge: number;
22
+ };
12
23
  }
13
24
  export declare class SettingsStore {
14
25
  #private;
@@ -18,5 +29,7 @@ export declare class SettingsStore {
18
29
  /** Patch settings. Only provided fields are updated. */
19
30
  update(patch: {
20
31
  auth?: Partial<GlobalSettings['auth']>;
32
+ tenants?: Partial<GlobalSettings['tenants']>;
33
+ packages?: Partial<GlobalSettings['packages']>;
21
34
  }): GlobalSettings;
22
35
  }
package/dist/settings.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
6
6
  import { dirname } from 'node:path';
7
+ const MAX_PACKAGE_CACHE_AGE = 31536000; // 1 year
7
8
  const DEFAULTS = {
8
9
  auth: {
9
10
  required: true,
@@ -11,7 +12,24 @@ const DEFAULTS = {
11
12
  sessionTTL: 24,
12
13
  selfRegistration: false,
13
14
  },
15
+ tenants: {
16
+ rootBase: '',
17
+ },
18
+ packages: {
19
+ cacheMaxAge: MAX_PACKAGE_CACHE_AGE,
20
+ },
14
21
  };
22
+ function clampCacheMaxAge(value) {
23
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
24
+ return DEFAULTS.packages.cacheMaxAge;
25
+ }
26
+ const floored = Math.floor(value);
27
+ if (floored < 0)
28
+ return 0;
29
+ if (floored > MAX_PACKAGE_CACHE_AGE)
30
+ return MAX_PACKAGE_CACHE_AGE;
31
+ return floored;
32
+ }
15
33
  export class SettingsStore {
16
34
  #path;
17
35
  #settings;
@@ -31,6 +49,12 @@ export class SettingsStore {
31
49
  sessionTTL: raw.auth?.sessionTTL ?? DEFAULTS.auth.sessionTTL,
32
50
  selfRegistration: raw.auth?.selfRegistration ?? DEFAULTS.auth.selfRegistration,
33
51
  },
52
+ tenants: {
53
+ rootBase: raw.tenants?.rootBase ?? DEFAULTS.tenants.rootBase,
54
+ },
55
+ packages: {
56
+ cacheMaxAge: clampCacheMaxAge(raw.packages?.cacheMaxAge),
57
+ },
34
58
  };
35
59
  }
36
60
  catch {
@@ -57,6 +81,15 @@ export class SettingsStore {
57
81
  if (patch.auth.selfRegistration !== undefined)
58
82
  this.#settings.auth.selfRegistration = patch.auth.selfRegistration;
59
83
  }
84
+ if (patch.tenants) {
85
+ if (patch.tenants.rootBase !== undefined)
86
+ this.#settings.tenants.rootBase = patch.tenants.rootBase;
87
+ }
88
+ if (patch.packages) {
89
+ if (patch.packages.cacheMaxAge !== undefined) {
90
+ this.#settings.packages.cacheMaxAge = clampCacheMaxAge(patch.packages.cacheMaxAge);
91
+ }
92
+ }
60
93
  this.#save();
61
94
  return this.get();
62
95
  }
@@ -6,6 +6,8 @@ export interface MountContext {
6
6
  pkgDir: string;
7
7
  keys: KeyStore;
8
8
  settings: SettingsStore;
9
+ /** The server's document backend, for sync handle construction. */
10
+ documentBackend: import('sh3-core/server-sync').DocumentBackend;
9
11
  /**
10
12
  * Register a WebSocket upgrade handler on a path under this shard's
11
13
  * route prefix. The returned value is a Hono middleware handler that
@@ -45,8 +47,13 @@ export interface MountContext {
45
47
  */
46
48
  wsRegister(onConnect: (ws: any, c: any) => void): any;
47
49
  }
48
- /** Middleware that requires admin session OR valid API key. */
49
- export declare function adminOnly(keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
50
+ /** Middleware requiring the caller's scope set to include admin:*. */
51
+ export declare function adminOnly(_keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
52
+ /**
53
+ * Build a ServerShardContext object for the given shard, wiring
54
+ * sync/syncRegistry based on the declared permissions.
55
+ */
56
+ export declare function buildShardCtx(shardId: string, dataDir: string, permissions: string[], keys: KeyStore, settings: SettingsStore, wsRegister: MountContext['wsRegister'], documentBackend: MountContext['documentBackend']): Record<string, unknown>;
50
57
  /**
51
58
  * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
52
59
  * and delegates requests from a single wildcard route.
@@ -1,30 +1,51 @@
1
1
  // packages/sh3-server/src/shard-router.ts
2
2
  import { Hono } from 'hono';
3
- import { mkdirSync } from 'node:fs';
3
+ import { mkdirSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ import { scopeRequired, tenantRequired } from './scope.js';
5
6
  import { pathToFileURL } from 'node:url';
6
- /** Middleware that requires admin session OR valid API key. */
7
- export function adminOnly(keys, settings) {
7
+ import { getSyncBundle, createSyncHandle, createSyncRegistry } from 'sh3-core/server-sync';
8
+ /** Middleware requiring the caller's scope set to include admin:*. */
9
+ export function adminOnly(_keys, settings) {
8
10
  return async (c, next) => {
9
- // When auth is disabled (--no-auth), skip admin checks entirely
10
- if (!settings.get().auth.required) {
11
+ if (!settings.get().auth.required)
11
12
  return next();
12
- }
13
- // Session-based admin — forwarded from upstream sessionAuth via env
14
- const session = c.get('session') ?? c.env?.session;
15
- if (session?.role === 'admin') {
13
+ const caller = c.get('caller');
14
+ if (caller?.scopes.includes('admin:*'))
16
15
  return next();
16
+ return c.json({ error: 'Admin privileges required' }, 403);
17
+ };
18
+ }
19
+ const PERMISSION_DOCUMENTS_SYNC = 'documents:sync';
20
+ /**
21
+ * Build a ServerShardContext object for the given shard, wiring
22
+ * sync/syncRegistry based on the declared permissions.
23
+ */
24
+ export function buildShardCtx(shardId, dataDir, permissions, keys, settings, wsRegister, documentBackend) {
25
+ const hasSync = permissions.includes(PERMISSION_DOCUMENTS_SYNC);
26
+ const ctx = {
27
+ shardId,
28
+ dataDir,
29
+ permissions,
30
+ adminOnly: adminOnly(keys, settings),
31
+ scopeRequired,
32
+ tenantRequired,
33
+ wsRegister,
34
+ };
35
+ ctx.sync = async (tenantId, connectorId) => {
36
+ if (!hasSync) {
37
+ throw new Error(`Shard "${shardId}" cannot call ctx.sync — missing '${PERMISSION_DOCUMENTS_SYNC}' permission in manifest.`);
17
38
  }
18
- // Fallback: API key (for external tools / CLI)
19
- const authHeader = c.req.header('Authorization');
20
- if (authHeader?.startsWith('Bearer sh3_')) {
21
- const token = authHeader.slice(7);
22
- if (keys.validate(token)) {
23
- return next();
24
- }
39
+ const { engine, registry } = await getSyncBundle(documentBackend, tenantId);
40
+ return createSyncHandle({ tenantId, connectorId, engine, registry });
41
+ };
42
+ ctx.syncRegistry = (tenantId) => {
43
+ if (!hasSync) {
44
+ throw new Error(`Shard "${shardId}" cannot call ctx.syncRegistry — missing '${PERMISSION_DOCUMENTS_SYNC}' permission in manifest.`);
25
45
  }
26
- return c.json({ error: 'Admin privileges required' }, 403);
46
+ return createSyncRegistry(documentBackend, tenantId);
27
47
  };
48
+ return ctx;
28
49
  }
29
50
  /**
30
51
  * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
@@ -44,13 +65,17 @@ export class ShardRouter {
44
65
  throw new Error(`${shardId}/server.js invalid export shape — expected { id, routes }`);
45
66
  }
46
67
  const shardDataDir = join(ctx.pkgDir, 'data');
68
+ const manifestPath = join(ctx.pkgDir, 'manifest.json');
69
+ let permissions = [];
70
+ try {
71
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
72
+ permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
73
+ }
74
+ catch {
75
+ // Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
76
+ }
47
77
  const router = new Hono();
48
- const shardCtx = {
49
- shardId: shard.id,
50
- dataDir: shardDataDir,
51
- adminOnly: adminOnly(ctx.keys, ctx.settings),
52
- wsRegister: ctx.wsRegister,
53
- };
78
+ const shardCtx = buildShardCtx(shard.id, shardDataDir, permissions, ctx.keys, ctx.settings, ctx.wsRegister, ctx.documentBackend);
54
79
  await shard.routes(router, shardCtx);
55
80
  // Create data dir only after routes() succeeds — so a failure
56
81
  // doesn't leave behind a dir that prevents install rollback cleanup.
@@ -68,13 +93,17 @@ export class ShardRouter {
68
93
  throw new Error(`${shardId} static mount — expected { id, routes }, got ${typeof mod}`);
69
94
  }
70
95
  const shardDataDir = join(ctx.pkgDir, 'data');
96
+ const manifestPath = join(ctx.pkgDir, 'manifest.json');
97
+ let permissions = [];
98
+ try {
99
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
100
+ permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
101
+ }
102
+ catch {
103
+ // Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
104
+ }
71
105
  const router = new Hono();
72
- const shardCtx = {
73
- shardId: mod.id,
74
- dataDir: shardDataDir,
75
- adminOnly: adminOnly(ctx.keys, ctx.settings),
76
- wsRegister: ctx.wsRegister,
77
- };
106
+ const shardCtx = buildShardCtx(mod.id, shardDataDir, permissions, ctx.keys, ctx.settings, ctx.wsRegister, ctx.documentBackend);
78
107
  await mod.routes(router, shardCtx);
79
108
  mkdirSync(shardDataDir, { recursive: true });
80
109
  this.shards.set(shardId, router);
@@ -1,11 +1,16 @@
1
1
  import { Hono } from 'hono';
2
- import type { Context } from 'hono';
2
+ import type { Context, MiddlewareHandler } from 'hono';
3
3
  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;
11
+ permissions: string[];
12
+ scopeRequired: (scope: string) => MiddlewareHandler;
13
+ tenantRequired: MiddlewareHandler;
9
14
  }
10
15
  declare const _default: {
11
16
  id: string;
@@ -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
@@ -58,6 +58,7 @@ export declare class SessionManager {
58
58
  private readonly dataDir;
59
59
  private readonly runner;
60
60
  private readonly cfg;
61
- constructor(dataDir: string, runner: Runner, cfg: SessionConfig);
61
+ private readonly userCwd;
62
+ constructor(dataDir: string, runner: Runner, cfg: SessionConfig, userCwd: (userId: string) => string);
62
63
  getOrCreate(userId: string): ShellSession;
63
64
  }
@@ -160,16 +160,29 @@ export class SessionManager {
160
160
  dataDir;
161
161
  runner;
162
162
  cfg;
163
- constructor(dataDir, runner, cfg) {
163
+ userCwd;
164
+ constructor(dataDir, runner, cfg, userCwd) {
164
165
  this.dataDir = dataDir;
165
166
  this.runner = runner;
166
167
  this.cfg = cfg;
168
+ this.userCwd = userCwd;
167
169
  }
168
170
  getOrCreate(userId) {
169
171
  let session = this.sessions.get(userId);
170
172
  if (!session) {
171
173
  const history = new HistoryStore(this.dataDir, userId, this.cfg.historyMaxLines);
172
- 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);
173
186
  this.sessions.set(userId, session);
174
187
  }
175
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;