mulmoclaude 1.2.0 → 1.3.0

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 (75) hide show
  1. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
  2. package/client/assets/index-D3czxq1I.css +2 -0
  3. package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
  4. package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
  5. package/client/index.html +3 -3
  6. package/package.json +6 -5
  7. package/server/agent/attachmentConverter.ts +0 -25
  8. package/server/api/auth/viewToken.ts +10 -8
  9. package/server/api/routes/agent.ts +35 -10
  10. package/server/api/routes/collections.ts +150 -96
  11. package/server/api/routes/files.ts +57 -54
  12. package/server/api/routes/mulmo-script.ts +257 -895
  13. package/server/api/routes/wiki/history.ts +17 -19
  14. package/server/events/collection-change.ts +0 -5
  15. package/server/events/file-change.ts +1 -6
  16. package/server/index.ts +25 -76
  17. package/server/plugins/diagnostics.ts +0 -6
  18. package/server/plugins/mulmoscript-server.ts +58 -0
  19. package/server/prompts/system/system.md +2 -2
  20. package/server/remoteHost/handlers/getCollection.ts +5 -4
  21. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  22. package/server/remoteHost/handlers/index.ts +3 -1
  23. package/server/utils/files/html-store.ts +2 -16
  24. package/server/utils/files/journal-io.ts +0 -12
  25. package/server/utils/files/plugins-io.ts +2 -11
  26. package/server/utils/files/safe.ts +39 -0
  27. package/server/utils/httpError.ts +6 -0
  28. package/server/utils/time.ts +0 -3
  29. package/server/workspace/collections/index.ts +1 -0
  30. package/server/workspace/collections/remoteView.ts +13 -4
  31. package/server/workspace/journal/state.ts +1 -9
  32. package/server/workspace/memory/topic-index-hook.ts +0 -9
  33. package/server/workspace/memory/topic-io.ts +0 -4
  34. package/server/workspace/skills/external/catalog.ts +24 -13
  35. package/server/workspace/skills/writer.ts +20 -16
  36. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  37. package/src/composables/useFileDropZone.ts +3 -15
  38. package/src/config/apiRoutes.ts +6 -0
  39. package/src/config/roles.ts +0 -4
  40. package/src/lang/de.ts +0 -30
  41. package/src/lang/en.ts +0 -30
  42. package/src/lang/es.ts +0 -30
  43. package/src/lang/fr.ts +0 -30
  44. package/src/lang/ja.ts +0 -30
  45. package/src/lang/ko.ts +0 -30
  46. package/src/lang/pt-BR.ts +0 -30
  47. package/src/lang/zh.ts +0 -30
  48. package/src/plugins/accounting/index.ts +0 -2
  49. package/src/plugins/api.ts +0 -6
  50. package/src/plugins/canvas/index.ts +0 -2
  51. package/src/plugins/chart/index.ts +0 -2
  52. package/src/plugins/editImages/index.ts +0 -2
  53. package/src/plugins/generateImage/index.ts +0 -2
  54. package/src/plugins/manageSkills/index.ts +0 -2
  55. package/src/plugins/markdown/index.ts +0 -2
  56. package/src/plugins/photoLocations/index.ts +0 -2
  57. package/src/plugins/presentCollection/index.ts +0 -2
  58. package/src/plugins/presentForm/index.ts +0 -2
  59. package/src/plugins/presentHtml/index.ts +0 -2
  60. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  61. package/src/plugins/presentMulmoScript/index.ts +62 -12
  62. package/src/plugins/presentSVG/index.ts +0 -2
  63. package/src/plugins/skill/index.ts +0 -3
  64. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  65. package/src/plugins/spreadsheet/index.ts +0 -2
  66. package/src/plugins/textResponse/index.ts +0 -3
  67. package/src/plugins/wiki/index.ts +0 -2
  68. package/src/types/notification.ts +0 -10
  69. package/src/utils/api.ts +0 -4
  70. package/client/assets/index-D5nJbMO1.css +0 -2
  71. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  72. package/server/utils/mulmoErrorCapture.ts +0 -93
  73. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  74. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  75. package/src/plugins/presentMulmoScript/helpers.ts +0 -206
@@ -12,6 +12,7 @@ import { Router, Request, Response, NextFunction } from "express";
12
12
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
13
13
  import { actionVisible } from "@mulmoclaude/core/collection";
14
14
  import {
15
+ collectionWritable,
15
16
  computeCollectionIcon,
16
17
  discoverCollections,
17
18
  generateItemId,
@@ -19,16 +20,16 @@ import {
19
20
  deleteCollectionRefusalMessage,
20
21
  deleteCustomView,
21
22
  deleteItem,
22
- listItems,
23
23
  loadCollection,
24
- readItem,
25
24
  readSkillTemplate,
26
25
  readCustomViewHtml,
27
26
  readCustomViewI18n,
27
+ readOnlyRefusal,
28
28
  buildActionSeedPrompt,
29
29
  buildCollectionActionSeedPrompt,
30
30
  promptPathsFor,
31
31
  resolveCreateItemId,
32
+ storeFor,
32
33
  toDetail,
33
34
  toSummary,
34
35
  applyMutateAction,
@@ -57,7 +58,7 @@ import {
57
58
  type RemoteViewItemsResult,
58
59
  } from "../../workspace/collections/remoteView.js";
59
60
  import { clampLimit, clampOffset, normalizeFields, normalizeMutate } from "@mulmoclaude/core/remote-view";
60
- import { badRequest, notFound, conflict, forbidden, serverError, serviceUnavailable } from "../../utils/httpError.js";
61
+ import { badRequest, notFound, conflict, forbidden, methodNotAllowed, serverError, serviceUnavailable } from "../../utils/httpError.js";
61
62
  import { ONE_MINUTE_MS } from "../../utils/time.js";
62
63
  import { errorMessage } from "../../utils/errors.js";
63
64
  import { log } from "../../system/logger/index.js";
@@ -69,6 +70,34 @@ import { clampCapabilities, mintViewToken, requireViewToken, type ViewCapability
69
70
 
70
71
  const router = Router();
71
72
 
73
+ // Load a collection by slug or send a 404 and return null. Callers do
74
+ // `const collection = await loadCollectionOr404(slug, res); if (!collection) return;`.
75
+ // The load-or-404 preamble was repeated across ~16 route handlers.
76
+ async function loadCollectionOr404(slug: string, res: Response): Promise<LoadedCollection | null> {
77
+ const collection = await loadCollection(slug);
78
+ if (!collection) {
79
+ notFound(res, `collection '${slug}' not found`);
80
+ return null;
81
+ }
82
+ return collection;
83
+ }
84
+
85
+ type CustomView = NonNullable<LoadedCollection["schema"]["views"]>[number];
86
+
87
+ // Resolve a collection + one of its custom views by id, or send a 404
88
+ // (missing collection or missing view) and return null. Shared by the
89
+ // view-file / view-i18n / view-token routes.
90
+ async function resolveCustomViewOr404(slug: string, viewId: string, res: Response): Promise<{ collection: LoadedCollection; view: CustomView } | null> {
91
+ const collection = await loadCollectionOr404(slug, res);
92
+ if (!collection) return null;
93
+ const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
94
+ if (!view) {
95
+ notFound(res, `custom view '${viewId}' not found on collection '${slug}'`);
96
+ return null;
97
+ }
98
+ return { collection, view };
99
+ }
100
+
72
101
  interface CollectionsListResponse {
73
102
  collections: CollectionSummary[];
74
103
  }
@@ -156,13 +185,10 @@ router.get(API_ROUTES.collections.list, async (_req: Request, res: Response<Coll
156
185
  });
157
186
 
158
187
  router.get(API_ROUTES.collections.detail, async (req: Request<{ slug: string }>, res: Response<CollectionDetailResponse>) => {
159
- const collection = await loadCollection(req.params.slug);
160
- if (!collection) {
161
- notFound(res, `collection '${req.params.slug}' not found`);
162
- return;
163
- }
188
+ const collection = await loadCollectionOr404(req.params.slug, res);
189
+ if (!collection) return;
164
190
  try {
165
- const items = await listItems(collection.dataDir);
191
+ const items = await storeFor(collection).list();
166
192
  // Best-effort validation: a malformed record is silently skipped at
167
193
  // read time, so surface the problems here too (the same pass
168
194
  // presentCollection runs) and let the view offer a Repair button.
@@ -193,11 +219,8 @@ router.get(API_ROUTES.collections.detail, async (req: Request<{ slug: string }>,
193
219
  // non-preset collections are deletable; see deleteCollection for the
194
220
  // scope rules and the archive layout.
195
221
  router.delete(API_ROUTES.collections.detail, async (req: Request<{ slug: string }>, res: Response<DeleteCollectionResponse>) => {
196
- const collection = await loadCollection(req.params.slug);
197
- if (!collection) {
198
- notFound(res, `collection '${req.params.slug}' not found`);
199
- return;
200
- }
222
+ const collection = await loadCollectionOr404(req.params.slug, res);
223
+ if (!collection) return;
201
224
  try {
202
225
  const result = await deleteCollection(collection);
203
226
  if (result.kind !== "ok") {
@@ -218,9 +241,10 @@ function extractRecord(body: unknown): CollectionItem | null {
218
241
  }
219
242
 
220
243
  router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>, res: Response<ItemMutationResponse>) => {
221
- const collection = await loadCollection(req.params.slug);
222
- if (!collection) {
223
- notFound(res, `collection '${req.params.slug}' not found`);
244
+ const collection = await loadCollectionOr404(req.params.slug, res);
245
+ if (!collection) return;
246
+ if (!collectionWritable(collection)) {
247
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
224
248
  return;
225
249
  }
226
250
  const record = extractRecord(req.body);
@@ -260,9 +284,10 @@ router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>,
260
284
  });
261
285
 
262
286
  router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; itemId: string }>, res: Response<ItemMutationResponse>) => {
263
- const collection = await loadCollection(req.params.slug);
264
- if (!collection) {
265
- notFound(res, `collection '${req.params.slug}' not found`);
287
+ const collection = await loadCollectionOr404(req.params.slug, res);
288
+ if (!collection) return;
289
+ if (!collectionWritable(collection)) {
290
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
266
291
  return;
267
292
  }
268
293
  const record = extractRecord(req.body);
@@ -306,9 +331,10 @@ router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; item
306
331
  });
307
332
 
308
333
  router.delete(API_ROUTES.collections.item, async (req: Request<{ slug: string; itemId: string }>, res: Response<DeleteResponse>) => {
309
- const collection = await loadCollection(req.params.slug);
310
- if (!collection) {
311
- notFound(res, `collection '${req.params.slug}' not found`);
334
+ const collection = await loadCollectionOr404(req.params.slug, res);
335
+ if (!collection) return;
336
+ if (!collectionWritable(collection)) {
337
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
312
338
  return;
313
339
  }
314
340
  try {
@@ -351,11 +377,8 @@ interface RefreshResponse {
351
377
  // carries no `ingest` block (it's an ordinary skill collection, not a
352
378
  // feed). Backs the CollectionView "Refresh feed" button.
353
379
  router.post(API_ROUTES.collections.refresh, async (req: Request<{ slug: string }>, res: Response<RefreshResponse>) => {
354
- const collection = await loadCollection(req.params.slug);
355
- if (!collection) {
356
- notFound(res, `collection '${req.params.slug}' not found`);
357
- return;
358
- }
380
+ const collection = await loadCollectionOr404(req.params.slug, res);
381
+ if (!collection) return;
359
382
  if (!collection.schema.ingest) {
360
383
  badRequest(res, `collection '${collection.slug}' is not a feed (no ingest config)`);
361
384
  return;
@@ -441,18 +464,15 @@ async function respondForMutateAction(
441
464
  // the hidden worker itself; for `kind: "mutate"`, it applies the
442
465
  // declarative write). No domain (invoice / PDF / role) literals.
443
466
  router.post(API_ROUTES.collections.itemAction, async (req: Request<{ slug: string; itemId: string; actionId: string }>, res: Response<ActionRunResponse>) => {
444
- const collection = await loadCollection(req.params.slug);
445
- if (!collection) {
446
- notFound(res, `collection '${req.params.slug}' not found`);
447
- return;
448
- }
467
+ const collection = await loadCollectionOr404(req.params.slug, res);
468
+ if (!collection) return;
449
469
  const action = collection.schema.actions?.find((entry) => entry.id === req.params.actionId);
450
470
  if (!action) {
451
471
  notFound(res, `action '${req.params.actionId}' not found on collection '${collection.slug}'`);
452
472
  return;
453
473
  }
454
474
  try {
455
- const record = await readItem(collection.dataDir, req.params.itemId);
475
+ const record = await storeFor(collection).read(req.params.itemId);
456
476
  if (!record) {
457
477
  notFound(res, `item '${req.params.itemId}' not found`);
458
478
  return;
@@ -469,6 +489,12 @@ router.post(API_ROUTES.collections.itemAction, async (req: Request<{ slug: strin
469
489
  // the declarative write itself (require was just enforced above,
470
490
  // same visibility-is-authorization rule as the seeded kinds).
471
491
  if (action.kind === "mutate") {
492
+ // Schema validation already rejects mutate actions on a dataSource
493
+ // collection; this is the defensive server-side twin.
494
+ if (!collectionWritable(collection)) {
495
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
496
+ return;
497
+ }
472
498
  await respondForMutateAction(res, collection, action, req.params.itemId, req.body as { params?: unknown } | undefined);
473
499
  return;
474
500
  }
@@ -498,7 +524,7 @@ router.post(API_ROUTES.collections.itemAction, async (req: Request<{ slug: strin
498
524
  async function buildCollectionActionSeed(collection: LoadedCollection, action: CollectionSeededAction): Promise<ActionSeedResponse | null> {
499
525
  const template = await readSkillTemplate(collection.skillDir, action.template);
500
526
  if (template === null) return null;
501
- const items = await listItems(collection.dataDir);
527
+ const items = await storeFor(collection).list();
502
528
  log.info("collections", "collection action seed built", { slug: collection.slug, actionId: action.id, items: items.length });
503
529
  return { prompt: buildCollectionActionSeedPrompt(items, collection.schema, template, promptPathsFor(collection, workspacePath)), role: action.role };
504
530
  }
@@ -506,11 +532,8 @@ async function buildCollectionActionSeed(collection: LoadedCollection, action: C
506
532
  // Like the per-record route but with no `itemId`: there is no record to read or
507
533
  // gate on, so the seed injects a progress summary instead. No domain literals.
508
534
  router.post(API_ROUTES.collections.collectionAction, async (req: Request<{ slug: string; actionId: string }>, res: Response<ActionRunResponse>) => {
509
- const collection = await loadCollection(req.params.slug);
510
- if (!collection) {
511
- notFound(res, `collection '${req.params.slug}' not found`);
512
- return;
513
- }
535
+ const collection = await loadCollectionOr404(req.params.slug, res);
536
+ if (!collection) return;
514
537
  const action = collection.schema.collectionActions?.find((entry) => entry.id === req.params.actionId);
515
538
  if (!action) {
516
539
  notFound(res, `collection action '${req.params.actionId}' not found on collection '${collection.slug}'`);
@@ -628,16 +651,9 @@ router.get(API_ROUTES.collections.viewFile, async (req: Request<{ slug: string }
628
651
  try {
629
652
  const { slug } = req.params;
630
653
  const viewId = typeof req.query.id === "string" ? req.query.id : "";
631
- const collection = await loadCollection(slug);
632
- if (!collection) {
633
- notFound(res, `collection '${slug}' not found`);
634
- return;
635
- }
636
- const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
637
- if (!view) {
638
- notFound(res, `custom view '${viewId}' not found on collection '${slug}'`);
639
- return;
640
- }
654
+ const resolved = await resolveCustomViewOr404(slug, viewId, res);
655
+ if (!resolved) return;
656
+ const { collection, view } = resolved;
641
657
  // Path-safe, source-aware read through the collections domain layer (no raw
642
658
  // fs / hardcoded subpaths in the route).
643
659
  const html = await readCustomViewHtml(collection, view.file);
@@ -670,11 +686,8 @@ router.get(API_ROUTES.collections.remoteView, async (req: Request<{ slug: string
670
686
  const { slug } = req.params;
671
687
  const viewId = typeof req.query.id === "string" ? req.query.id : "";
672
688
  const locale = typeof req.query.locale === "string" ? req.query.locale : "";
673
- const collection = await loadCollection(slug);
674
- if (!collection) {
675
- notFound(res, `collection '${slug}' not found`);
676
- return;
677
- }
689
+ const collection = await loadCollectionOr404(slug, res);
690
+ if (!collection) return;
678
691
  const result = await buildRemoteView(collection, viewId, locale);
679
692
  if (result.kind !== "ok") {
680
693
  sendRemoteViewFailure(res, result, slug);
@@ -692,6 +705,7 @@ router.get(API_ROUTES.collections.remoteView, async (req: Request<{ slug: string
692
705
  function sendMutateRemoteViewFailure(res: Response, result: Exclude<MutateRemoteViewResult, { kind: "ok" }>, slug: string): void {
693
706
  const message = mutateRemoteViewFailureMessage(result, slug);
694
707
  if (result.kind === "view-not-found" || result.kind === "item-not-found") notFound(res, message);
708
+ else if (result.kind === "read-only-collection") methodNotAllowed(res, message);
695
709
  else if (result.kind === "not-writable" || result.kind === "delete-not-allowed" || result.kind === "field-not-editable" || result.kind === "path-escape")
696
710
  forbidden(res, message);
697
711
  else badRequest(res, message);
@@ -710,11 +724,8 @@ router.post(API_ROUTES.collections.remoteViewMutate, async (req: Request<{ slug:
710
724
  badRequest(res, "invalid mutate request — expected { op: 'update'|'delete', id, patch? }");
711
725
  return;
712
726
  }
713
- const collection = await loadCollection(slug);
714
- if (!collection) {
715
- notFound(res, `collection '${slug}' not found`);
716
- return;
717
- }
727
+ const collection = await loadCollectionOr404(slug, res);
728
+ if (!collection) return;
718
729
  const result = await mutateRemoteView(collection, viewId, request);
719
730
  if (result.kind !== "ok") {
720
731
  sendMutateRemoteViewFailure(res, result, slug);
@@ -753,11 +764,8 @@ router.get(API_ROUTES.collections.remoteViewItems, async (req: Request<{ slug: s
753
764
  try {
754
765
  const { slug, viewId } = req.params;
755
766
  const request = { offset: clampOffset(req.query.offset), limit: clampLimit(req.query.limit), fields: normalizeFields(csvParam(req.query.fields)) };
756
- const collection = await loadCollection(slug);
757
- if (!collection) {
758
- notFound(res, `collection '${slug}' not found`);
759
- return;
760
- }
767
+ const collection = await loadCollectionOr404(slug, res);
768
+ if (!collection) return;
761
769
  const result = await remoteViewItems(collection, viewId, request);
762
770
  if (result.kind !== "ok") {
763
771
  sendRemoteViewItemsFailure(res, result, slug);
@@ -788,16 +796,9 @@ router.get(API_ROUTES.collections.viewI18n, async (req: Request<{ slug: string }
788
796
  const { slug } = req.params;
789
797
  const viewId = typeof req.query.id === "string" ? req.query.id : "";
790
798
  const locale = typeof req.query.locale === "string" ? req.query.locale : "";
791
- const collection = await loadCollection(slug);
792
- if (!collection) {
793
- notFound(res, `collection '${slug}' not found`);
794
- return;
795
- }
796
- const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
797
- if (!view) {
798
- notFound(res, `custom view '${viewId}' not found on collection '${slug}'`);
799
- return;
800
- }
799
+ const resolved = await resolveCustomViewOr404(slug, viewId, res);
800
+ if (!resolved) return;
801
+ const { collection, view } = resolved;
801
802
  if (!view.i18n) {
802
803
  // The view declared no translation file — return the empty contract so
803
804
  // the client doesn't have to special-case "no i18n" with a different
@@ -829,16 +830,9 @@ router.post(API_ROUTES.collections.viewToken, async (req: Request<{ slug: string
829
830
  badRequest(res, "`viewId` is required");
830
831
  return;
831
832
  }
832
- const collection = await loadCollection(slug);
833
- if (!collection) {
834
- notFound(res, `collection '${slug}' not found`);
835
- return;
836
- }
837
- const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
838
- if (!view) {
839
- notFound(res, `custom view '${viewId}' not found on collection '${slug}'`);
840
- return;
841
- }
833
+ const resolved = await resolveCustomViewOr404(slug, viewId, res);
834
+ if (!resolved) return;
835
+ const { view } = resolved;
842
836
  const granted = clampCapabilities(view.capabilities, parseCapabilities(body.capabilities));
843
837
  const minted = mintViewToken(slug, granted);
844
838
  if (!minted) {
@@ -871,6 +865,72 @@ router.get(API_ROUTES.collections.viewData, viewDataCors, requireViewToken("read
871
865
  }
872
866
  });
873
867
 
868
+ // Preflight for the token-scoped query endpoint (POST + JSON from a
869
+ // sandboxed opaque-origin iframe — same CORS story as view-data itself).
870
+ router.options(API_ROUTES.collections.viewDataQuery, viewDataCors, (_req: Request, res: Response) => {
871
+ res.sendStatus(204);
872
+ });
873
+
874
+ /** Per-slug in-flight cap for view-issued aggregation queries. The
875
+ * per-minute limiter alone still lets a burst arrive CONCURRENTLY —
876
+ * each query is a full-file DuckDB scan, so a runaway dashboard loop
877
+ * could otherwise stack dozens of scans at once. Exported factory so
878
+ * the unit test can drive the counter without HTTP. */
879
+ export function makeViewQueryConcurrencyGuard(max: number) {
880
+ const inflight = new Map<string, number>();
881
+ return (req: Request<{ slug?: string }>, res: Response, next: NextFunction): void => {
882
+ const slug = req.params.slug ?? "";
883
+ const current = inflight.get(slug) ?? 0;
884
+ if (current >= max) {
885
+ res.status(429).json({ error: "too many concurrent queries for this collection — retry shortly" });
886
+ return;
887
+ }
888
+ inflight.set(slug, current + 1);
889
+ let released = false;
890
+ // `close` fires after the response finishes OR the client disconnects
891
+ // mid-request — either way the scan slot must come back exactly once.
892
+ res.once("close", () => {
893
+ if (released) return;
894
+ released = true;
895
+ const now = inflight.get(slug) ?? 1;
896
+ if (now <= 1) inflight.delete(slug);
897
+ else inflight.set(slug, now - 1);
898
+ });
899
+ next();
900
+ };
901
+ }
902
+
903
+ const VIEW_QUERY_MAX_CONCURRENT = 4;
904
+ const viewQueryConcurrency = makeViewQueryConcurrencyGuard(VIEW_QUERY_MAX_CONCURRENT);
905
+
906
+ // Scoped aggregation: run a structured query (the DSL — never raw SQL)
907
+ // over a dataSource collection's whole data file. Read capability only:
908
+ // the DSL is read-only by construction. Reuses the manageCollection
909
+ // handler so a view can never do more than the agent's own queryItems
910
+ // (same validation, same file-backed refusal). Guarded twice: the
911
+ // per-minute limiter (request volume) + the in-flight cap (concurrent
912
+ // full-file scans).
913
+ router.post(
914
+ API_ROUTES.collections.viewDataQuery,
915
+ viewDataCors,
916
+ viewActionRateLimit,
917
+ viewQueryConcurrency,
918
+ requireViewToken("read"),
919
+ async (req: Request<{ slug: string }>, res: Response) => {
920
+ try {
921
+ const body = (req.body ?? {}) as { query?: unknown };
922
+ const raw = await manageCollection.handler({ action: "queryItems", slug: req.params.slug, query: body.query });
923
+ sendToolResult(res, raw);
924
+ } catch (err) {
925
+ // Log the detail server-side; the token holder gets a FIXED message —
926
+ // a raw DuckDB error can carry absolute paths / host internals, and a
927
+ // scoped view is not a trusted audience for those.
928
+ log.warn("collections", "view-data query failed", { slug: req.params.slug.replace(/[\r\n]/g, " "), error: errorMessage(err) });
929
+ serverError(res, "collection query failed");
930
+ }
931
+ },
932
+ );
933
+
874
934
  // Scoped write: validated putItems. Requires the `write` capability.
875
935
  router.put(API_ROUTES.collections.viewData, viewDataCors, requireViewToken("write"), async (req: Request<{ slug: string }>, res: Response) => {
876
936
  try {
@@ -903,11 +963,8 @@ router.post(
903
963
  requireViewToken("write"),
904
964
  async (req: Request<{ slug: string; actionId: string }>, res: Response<ActionRunResponse>) => {
905
965
  try {
906
- const collection = await loadCollection(req.params.slug);
907
- if (!collection) {
908
- notFound(res, `collection '${req.params.slug}' not found`);
909
- return;
910
- }
966
+ const collection = await loadCollectionOr404(req.params.slug, res);
967
+ if (!collection) return;
911
968
  const action = collection.schema.actions?.find((entry) => entry.id === req.params.actionId);
912
969
  if (!action) {
913
970
  notFound(res, `action '${req.params.actionId}' not found on collection '${collection.slug}'`);
@@ -923,7 +980,7 @@ router.post(
923
980
  badRequest(res, "`itemId` is required (the record's primary-key value)");
924
981
  return;
925
982
  }
926
- const record = await readItem(collection.dataDir, itemId);
983
+ const record = await storeFor(collection).read(itemId);
927
984
  if (!record) {
928
985
  notFound(res, `item '${itemId}' not found`);
929
986
  return;
@@ -971,11 +1028,8 @@ function sendDeleteViewRefusal(res: Response, result: Exclude<DeleteViewResult,
971
1028
  // refuses user-scope + preset collections, consistent with collection delete.
972
1029
  router.delete(API_ROUTES.collections.viewDelete, async (req: Request<{ slug: string; viewId: string }>, res: Response<DeleteViewResponse>) => {
973
1030
  try {
974
- const collection = await loadCollection(req.params.slug);
975
- if (!collection) {
976
- notFound(res, `collection '${req.params.slug}' not found`);
977
- return;
978
- }
1031
+ const collection = await loadCollectionOr404(req.params.slug, res);
1032
+ if (!collection) return;
979
1033
  const result = await deleteCustomView(collection, req.params.viewId);
980
1034
  if (result.kind !== "ok") {
981
1035
  sendDeleteViewRefusal(res, result);
@@ -452,6 +452,53 @@ function pipeWithErrorHandling(stream: ReadStream, res: Response<ErrorResponse>)
452
452
  stream.pipe(res);
453
453
  }
454
454
 
455
+ // Apply the UI-visibility filters (hidden dirs, sensitive files,
456
+ // symlinks, .gitignore matches) to a single directory entry and stat
457
+ // it. Returns the resolved child paths + stat, or null when the entry
458
+ // should not surface. The recursive walk and the shallow lazy-expand
459
+ // path share this so the filter policy lives in exactly one place.
460
+ async function resolveVisibleChild(
461
+ entry: Dirent,
462
+ absPath: string,
463
+ relPath: string,
464
+ localFilter: GitignoreFilter | undefined,
465
+ ): Promise<{ childRel: string; childAbs: string; childStat: Stats } | null> {
466
+ if (HIDDEN_DIRS.has(entry.name)) return null;
467
+ if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
468
+ if (entry.isSymbolicLink()) return null;
469
+ const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
470
+ // .gitignore check: for directories, append trailing / so
471
+ // directory-only patterns (e.g. "node_modules/") match.
472
+ if (localFilter) {
473
+ const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
474
+ if (localFilter.ignores(testPath)) return null;
475
+ }
476
+ const childAbs = path.join(absPath, entry.name);
477
+ const childStat = await statSafeAsync(childAbs);
478
+ if (!childStat) return null;
479
+ return { childRel, childAbs, childStat };
480
+ }
481
+
482
+ // Await a directory's child-node promises, drop the filtered-out nulls,
483
+ // sort (dirs before files, alphabetical within type), and wrap them in
484
+ // the parent's `dir` TreeNode. Shared by the recursive and shallow
485
+ // builders so the assembly + ordering is defined once.
486
+ async function assembleDirNode(childPromises: Promise<TreeNode | null>[], relPath: string, modifiedMs: number): Promise<TreeNode> {
487
+ const resolved = await Promise.all(childPromises);
488
+ const children = resolved.filter((childNode): childNode is TreeNode => childNode !== null);
489
+ children.sort((leftChild, rightChild) => {
490
+ if (leftChild.type !== rightChild.type) return leftChild.type === "dir" ? -1 : 1;
491
+ return leftChild.name.localeCompare(rightChild.name);
492
+ });
493
+ return {
494
+ name: relPath ? path.basename(relPath) : "",
495
+ path: relPath,
496
+ type: "dir",
497
+ modifiedMs,
498
+ children,
499
+ };
500
+ }
501
+
455
502
  // Async workspace tree walker — recurses through the workspace with
456
503
  // the same security filters as the original sync implementation
457
504
  // (hidden dirs, sensitive files, symlinks all rejected) and the same
@@ -489,55 +536,23 @@ export async function buildTreeAsync(absPath: string, relPath: string, gitFilter
489
536
  // root .gitignore (it's for git, not the UI). Pass a fresh empty
490
537
  // filter so children pick up THEIR .gitignore files.
491
538
  const localFilter = gitFilter ? gitFilter.childForDir(absPath) : new GitignoreFilter();
492
- // Build every surviving child concurrently. Filter:
493
- // skip hidden dirs, sensitive files, symlinks, .gitignore matches,
494
- // and entries that fail to stat.
539
+ // Build every surviving child concurrently, recursing into the ones
540
+ // that pass the visibility filter.
495
541
  const childPromises: Promise<TreeNode | null>[] = entries.map(async (entry): Promise<TreeNode | null> => {
496
- if (HIDDEN_DIRS.has(entry.name)) return null;
497
- if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
498
- if (entry.isSymbolicLink()) return null;
499
- const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
500
- // .gitignore check: for directories, append trailing / so
501
- // directory-only patterns (e.g. "node_modules/") match.
502
- if (localFilter) {
503
- const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
504
- if (localFilter.ignores(testPath)) return null;
505
- }
506
- const childAbs = path.join(absPath, entry.name);
507
- const childStat = await statSafeAsync(childAbs);
508
- if (!childStat) return null;
509
- return buildTreeAsync(childAbs, childRel, localFilter);
510
- });
511
- const resolved = await Promise.all(childPromises);
512
- const children = resolved.filter((childNode): childNode is TreeNode => childNode !== null);
513
- children.sort((leftChild, rightChild) => {
514
- if (leftChild.type !== rightChild.type) return leftChild.type === "dir" ? -1 : 1;
515
- return leftChild.name.localeCompare(rightChild.name);
542
+ const child = await resolveVisibleChild(entry, absPath, relPath, localFilter);
543
+ if (!child) return null;
544
+ return buildTreeAsync(child.childAbs, child.childRel, localFilter);
516
545
  });
517
- return {
518
- name: relPath ? path.basename(relPath) : "",
519
- path: relPath,
520
- type: "dir",
521
- modifiedMs: stat.mtimeMs,
522
- children,
523
- };
546
+ return assembleDirNode(childPromises, relPath, stat.mtimeMs);
524
547
  }
525
548
 
526
549
  // Map a single directory entry to a shallow TreeNode, applying the
527
550
  // same hidden/sensitive/symlink/gitignore filters as the recursive
528
551
  // walk. Returns null for entries that should not surface in the UI.
529
552
  async function dirEntryToNode(entry: Dirent, absPath: string, relPath: string, localFilter: GitignoreFilter | undefined): Promise<TreeNode | null> {
530
- if (HIDDEN_DIRS.has(entry.name)) return null;
531
- if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
532
- if (entry.isSymbolicLink()) return null;
533
- const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
534
- if (localFilter) {
535
- const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
536
- if (localFilter.ignores(testPath)) return null;
537
- }
538
- const childAbs = path.join(absPath, entry.name);
539
- const childStat = await statSafeAsync(childAbs);
540
- if (!childStat) return null;
553
+ const child = await resolveVisibleChild(entry, absPath, relPath, localFilter);
554
+ if (!child) return null;
555
+ const { childRel, childStat } = child;
541
556
  if (childStat.isDirectory()) {
542
557
  return {
543
558
  name: entry.name,
@@ -580,19 +595,7 @@ export async function listDirShallow(absPath: string, relPath: string, gitFilter
580
595
  // filter so children pick up THEIR .gitignore files.
581
596
  const localFilter = gitFilter ? gitFilter.childForDir(absPath) : new GitignoreFilter();
582
597
  const childPromises = entries.map((entry) => dirEntryToNode(entry, absPath, relPath, localFilter));
583
- const resolved = await Promise.all(childPromises);
584
- const children = resolved.filter((childNode): childNode is TreeNode => childNode !== null);
585
- children.sort((leftChild, rightChild) => {
586
- if (leftChild.type !== rightChild.type) return leftChild.type === "dir" ? -1 : 1;
587
- return leftChild.name.localeCompare(rightChild.name);
588
- });
589
- return {
590
- name: relPath ? path.basename(relPath) : "",
591
- path: relPath,
592
- type: "dir",
593
- modifiedMs: stat.mtimeMs,
594
- children,
595
- };
598
+ return assembleDirNode(childPromises, relPath, stat.mtimeMs);
596
599
  }
597
600
 
598
601
  router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown, object>, res: Response<TreeNode | ErrorResponse>) => {