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.
Files changed (43) hide show
  1. package/app/assets/{icons-nOyIoORC.svg → icons-OMmH0JiM.svg} +5 -0
  2. package/app/assets/index-C6sN7l5X.js +26 -0
  3. package/app/assets/index-C6sN7l5X.js.map +1 -0
  4. package/app/assets/index-DbnwAWBS.css +1 -0
  5. package/app/index.html +2 -2
  6. package/dist/auth.d.ts +7 -11
  7. package/dist/auth.js +7 -19
  8. package/dist/cli.js +2 -2
  9. package/dist/doc-store/store.d.ts +12 -0
  10. package/dist/doc-store/store.js +172 -3
  11. package/dist/index.d.ts +5 -2
  12. package/dist/index.js +21 -12
  13. package/dist/middleware/project-allowlist.d.ts +4 -0
  14. package/dist/middleware/project-allowlist.js +26 -12
  15. package/dist/mounts/resolver.d.ts +21 -0
  16. package/dist/mounts/resolver.js +41 -0
  17. package/dist/mounts/routes.d.ts +4 -0
  18. package/dist/mounts/routes.js +136 -0
  19. package/dist/mounts/store.d.ts +30 -0
  20. package/dist/mounts/store.js +115 -0
  21. package/dist/routes/admin.d.ts +3 -1
  22. package/dist/routes/admin.js +6 -1
  23. package/dist/routes/boot.d.ts +7 -1
  24. package/dist/routes/boot.js +13 -4
  25. package/dist/routes/docs.js +83 -2
  26. package/dist/routes/projects.d.ts +1 -3
  27. package/dist/routes/projects.js +1 -3
  28. package/dist/scope.d.ts +1 -2
  29. package/dist/scope.js +1 -5
  30. package/dist/settings.d.ts +0 -1
  31. package/dist/settings.js +0 -4
  32. package/dist/shard-router.d.ts +1 -1
  33. package/dist/shard-router.js +23 -4
  34. package/dist/tenant-fs/http.d.ts +0 -2
  35. package/dist/tenant-fs/http.js +1 -1
  36. package/dist/tenant-fs/session-required.d.ts +1 -3
  37. package/dist/tenant-fs/session-required.js +1 -4
  38. package/dist/users.d.ts +14 -1
  39. package/dist/users.js +34 -0
  40. package/package.json +1 -1
  41. package/app/assets/index--m0u3gjJ.js +0 -21
  42. package/app/assets/index--m0u3gjJ.js.map +0 -1
  43. 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
+ }
@@ -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
- export declare function createAdminRouter(users: UserStore, settings: SettingsStore, sessions: SessionStore, keys: KeyStore, dataDir: string): Hono;
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;
@@ -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
- export function createAdminRouter(users, settings, sessions, keys, dataDir) {
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
  }
@@ -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;
@@ -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 (!config.auth.required || config.auth.guestAllowed) {
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
  },
@@ -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', opts.settings));
28
- router.use('/:scope', scopeAccessMatch('scope', opts.settings));
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 HTTP API.
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
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Projects HTTP API.
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, settings?: SettingsStore): MiddlewareHandler;
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, settings) {
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);
@@ -4,7 +4,6 @@
4
4
  */
5
5
  export interface GlobalSettings {
6
6
  auth: {
7
- required: boolean;
8
7
  guestAllowed: boolean;
9
8
  sessionTTL: number;
10
9
  selfRegistration: boolean;
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)
@@ -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(_keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
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.
@@ -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(_keys, settings) {
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(ctx.keys, ctx.settings),
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
  }
@@ -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;
@@ -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(ctx.settings);
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(settings: SettingsStore): MiddlewareHandler;
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(settings) {
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-server",
3
- "version": "0.19.6",
3
+ "version": "0.20.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sh3-server": "dist/cli.js"