mulmoclaude 1.2.0 → 1.4.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 (96) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
  3. package/client/assets/index-7QnbsZZr.css +2 -0
  4. package/client/assets/{index-yQkLdtMk.js → index-C3SRVT2p.js} +84 -85
  5. package/client/assets/{marp-Bs_qwoKZ.js → marp-DxctLEy1.js} +12 -3
  6. package/client/index.html +3 -3
  7. package/package.json +7 -6
  8. package/server/agent/attachmentConverter.ts +0 -25
  9. package/server/agent/mcp-server.ts +27 -8
  10. package/server/api/auth/viewToken.ts +16 -8
  11. package/server/api/routes/agent.ts +49 -11
  12. package/server/api/routes/collections.ts +274 -102
  13. package/server/api/routes/files.ts +57 -54
  14. package/server/api/routes/mulmo-script.ts +257 -895
  15. package/server/api/routes/wiki/history.ts +17 -19
  16. package/server/build/dispatcher.mjs +2 -2
  17. package/server/events/collection-change.ts +1 -5
  18. package/server/events/file-change.ts +1 -6
  19. package/server/events/pub-sub/index.ts +15 -2
  20. package/server/events/relay-client.ts +6 -1
  21. package/server/events/session-store/index.ts +11 -2
  22. package/server/index.ts +51 -78
  23. package/server/plugins/diagnostics.ts +0 -6
  24. package/server/plugins/mulmoscript-server.ts +58 -0
  25. package/server/prompts/system/system.md +2 -3
  26. package/server/remoteHost/handlers/getCollection.ts +5 -4
  27. package/server/remoteHost/handlers/getFeed.ts +10 -5
  28. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  29. package/server/remoteHost/handlers/index.ts +3 -1
  30. package/server/services/translation/index.ts +10 -3
  31. package/server/utils/errors.ts +7 -21
  32. package/server/utils/files/html-store.ts +2 -16
  33. package/server/utils/files/journal-io.ts +0 -12
  34. package/server/utils/files/plugins-io.ts +2 -11
  35. package/server/utils/files/safe.ts +39 -0
  36. package/server/utils/httpError.ts +6 -0
  37. package/server/utils/text.ts +7 -23
  38. package/server/utils/time.ts +0 -3
  39. package/server/workspace/collections/index.ts +6 -4
  40. package/server/workspace/collections/remoteView.ts +31 -22
  41. package/server/workspace/collections/watcher.ts +1 -0
  42. package/server/workspace/custom-dirs.ts +16 -6
  43. package/server/workspace/journal/state.ts +1 -9
  44. package/server/workspace/memory/topic-index-hook.ts +0 -9
  45. package/server/workspace/memory/topic-io.ts +0 -4
  46. package/server/workspace/reference-dirs.ts +8 -3
  47. package/server/workspace/skills/external/catalog.ts +24 -13
  48. package/server/workspace/skills/writer.ts +20 -16
  49. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  50. package/src/components/SettingsGoogleTab.vue +4 -1
  51. package/src/components/StackView.vue +12 -1
  52. package/src/composables/collections/uiHost.ts +2 -1
  53. package/src/composables/useChatScroll.ts +18 -6
  54. package/src/composables/useFileDropZone.ts +3 -15
  55. package/src/composables/useFileSelection.ts +4 -1
  56. package/src/composables/useStickToBottom.ts +51 -0
  57. package/src/config/apiRoutes.ts +20 -0
  58. package/src/config/roles.ts +0 -4
  59. package/src/lang/de.ts +0 -30
  60. package/src/lang/en.ts +0 -30
  61. package/src/lang/es.ts +0 -30
  62. package/src/lang/fr.ts +0 -30
  63. package/src/lang/ja.ts +0 -30
  64. package/src/lang/ko.ts +0 -30
  65. package/src/lang/pt-BR.ts +0 -30
  66. package/src/lang/zh.ts +0 -30
  67. package/src/plugins/accounting/index.ts +0 -2
  68. package/src/plugins/api.ts +0 -6
  69. package/src/plugins/canvas/index.ts +0 -2
  70. package/src/plugins/chart/index.ts +0 -2
  71. package/src/plugins/editImages/index.ts +0 -2
  72. package/src/plugins/generateImage/index.ts +0 -2
  73. package/src/plugins/manageSkills/index.ts +0 -2
  74. package/src/plugins/markdown/index.ts +0 -2
  75. package/src/plugins/photoLocations/index.ts +0 -2
  76. package/src/plugins/presentCollection/index.ts +0 -2
  77. package/src/plugins/presentForm/index.ts +0 -2
  78. package/src/plugins/presentHtml/index.ts +0 -2
  79. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  80. package/src/plugins/presentMulmoScript/index.ts +62 -12
  81. package/src/plugins/presentSVG/index.ts +0 -2
  82. package/src/plugins/skill/index.ts +0 -3
  83. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  84. package/src/plugins/spreadsheet/index.ts +0 -2
  85. package/src/plugins/textResponse/index.ts +0 -3
  86. package/src/plugins/wiki/index.ts +0 -2
  87. package/src/types/notification.ts +0 -10
  88. package/src/utils/api.ts +0 -4
  89. package/src/utils/dom/scrollable.ts +15 -0
  90. package/src/utils/errors.ts +7 -38
  91. package/client/assets/index-D5nJbMO1.css +0 -2
  92. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  93. package/server/utils/mulmoErrorCapture.ts +0 -93
  94. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  95. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  96. package/src/plugins/presentMulmoScript/helpers.ts +0 -206
@@ -12,34 +12,35 @@ 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,
18
19
  deleteCollection,
19
20
  deleteCollectionRefusalMessage,
20
21
  deleteCustomView,
21
- deleteItem,
22
- listItems,
23
22
  loadCollection,
24
- readItem,
25
23
  readSkillTemplate,
26
24
  readCustomViewHtml,
27
25
  readCustomViewI18n,
26
+ readOnlyRefusal,
28
27
  buildActionSeedPrompt,
29
28
  buildCollectionActionSeedPrompt,
29
+ buildWorkspaceOntology,
30
30
  promptPathsFor,
31
31
  resolveCreateItemId,
32
+ storeFor,
32
33
  toDetail,
33
34
  toSummary,
34
35
  applyMutateAction,
35
36
  validateCollectionRecords,
36
- writeItem,
37
37
  } from "../../workspace/collections/index.js";
38
38
  import type {
39
39
  CollectionMutateAction,
40
40
  CollectionSeededAction,
41
41
  CollectionDetail,
42
42
  CollectionItem,
43
+ CollectionOntologyEntry,
43
44
  CollectionSummary,
44
45
  DeleteViewResult,
45
46
  LoadedCollection,
@@ -56,8 +57,9 @@ import {
56
57
  type RemoteViewBuildResult,
57
58
  type RemoteViewItemsResult,
58
59
  } from "../../workspace/collections/remoteView.js";
59
- import { clampLimit, clampOffset, normalizeFields, normalizeMutate } from "@mulmoclaude/core/remote-view";
60
- import { badRequest, notFound, conflict, forbidden, serverError, serviceUnavailable } from "../../utils/httpError.js";
60
+ import { clampImageMaxEdge, clampLimit, clampOffset, normalizeFields, normalizeMutate } from "@mulmoclaude/core/remote-view";
61
+ import { resolveThumbnail } from "../../utils/files/thumbnail-store.js";
62
+ import { badRequest, notFound, conflict, forbidden, methodNotAllowed, serverError, serviceUnavailable } from "../../utils/httpError.js";
61
63
  import { ONE_MINUTE_MS } from "../../utils/time.js";
62
64
  import { errorMessage } from "../../utils/errors.js";
63
65
  import { log } from "../../system/logger/index.js";
@@ -69,10 +71,42 @@ import { clampCapabilities, mintViewToken, requireViewToken, type ViewCapability
69
71
 
70
72
  const router = Router();
71
73
 
74
+ // Load a collection by slug or send a 404 and return null. Callers do
75
+ // `const collection = await loadCollectionOr404(slug, res); if (!collection) return;`.
76
+ // The load-or-404 preamble was repeated across ~16 route handlers.
77
+ async function loadCollectionOr404(slug: string, res: Response): Promise<LoadedCollection | null> {
78
+ const collection = await loadCollection(slug);
79
+ if (!collection) {
80
+ notFound(res, `collection '${slug}' not found`);
81
+ return null;
82
+ }
83
+ return collection;
84
+ }
85
+
86
+ type CustomView = NonNullable<LoadedCollection["schema"]["views"]>[number];
87
+
88
+ // Resolve a collection + one of its custom views by id, or send a 404
89
+ // (missing collection or missing view) and return null. Shared by the
90
+ // view-file / view-i18n / view-token routes.
91
+ async function resolveCustomViewOr404(slug: string, viewId: string, res: Response): Promise<{ collection: LoadedCollection; view: CustomView } | null> {
92
+ const collection = await loadCollectionOr404(slug, res);
93
+ if (!collection) return null;
94
+ const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
95
+ if (!view) {
96
+ notFound(res, `custom view '${viewId}' not found on collection '${slug}'`);
97
+ return null;
98
+ }
99
+ return { collection, view };
100
+ }
101
+
72
102
  interface CollectionsListResponse {
73
103
  collections: CollectionSummary[];
74
104
  }
75
105
 
106
+ interface CollectionOntologyResponse {
107
+ entries: CollectionOntologyEntry[];
108
+ }
109
+
76
110
  interface CollectionDetailResponse {
77
111
  collection: CollectionDetail;
78
112
  items: CollectionItem[];
@@ -155,14 +189,24 @@ router.get(API_ROUTES.collections.list, async (_req: Request, res: Response<Coll
155
189
  }
156
190
  });
157
191
 
158
- 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;
192
+ // Raw workspace-ontology entries (buildWorkspaceOntology derived on
193
+ // demand, never stored); the /collections Map tab builds its graph from
194
+ // these with the shared `buildOntologyGraph`. Registered before the
195
+ // `:slug` routes so "ontology" is never swallowed as a slug.
196
+ router.get(API_ROUTES.collections.ontology, async (_req: Request, res: Response<CollectionOntologyResponse>) => {
197
+ try {
198
+ res.json({ entries: await buildWorkspaceOntology() });
199
+ } catch (err) {
200
+ log.warn("collections", "ontology failed", { error: errorMessage(err) });
201
+ serverError(res, errorMessage(err));
163
202
  }
203
+ });
204
+
205
+ router.get(API_ROUTES.collections.detail, async (req: Request<{ slug: string }>, res: Response<CollectionDetailResponse>) => {
206
+ const collection = await loadCollectionOr404(req.params.slug, res);
207
+ if (!collection) return;
164
208
  try {
165
- const items = await listItems(collection.dataDir);
209
+ const items = await storeFor(collection).list();
166
210
  // Best-effort validation: a malformed record is silently skipped at
167
211
  // read time, so surface the problems here too (the same pass
168
212
  // presentCollection runs) and let the view offer a Repair button.
@@ -193,11 +237,8 @@ router.get(API_ROUTES.collections.detail, async (req: Request<{ slug: string }>,
193
237
  // non-preset collections are deletable; see deleteCollection for the
194
238
  // scope rules and the archive layout.
195
239
  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
- }
240
+ const collection = await loadCollectionOr404(req.params.slug, res);
241
+ if (!collection) return;
201
242
  try {
202
243
  const result = await deleteCollection(collection);
203
244
  if (result.kind !== "ok") {
@@ -218,9 +259,11 @@ function extractRecord(body: unknown): CollectionItem | null {
218
259
  }
219
260
 
220
261
  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`);
262
+ const collection = await loadCollectionOr404(req.params.slug, res);
263
+ if (!collection) return;
264
+ const createStore = storeFor(collection).write;
265
+ if (!createStore) {
266
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
224
267
  return;
225
268
  }
226
269
  const record = extractRecord(req.body);
@@ -238,7 +281,7 @@ router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>,
238
281
  const itemId = resolveCreateItemId(collection.schema, record) ?? generateItemId();
239
282
  const recordWithId: CollectionItem = { ...record, [collection.schema.primaryKey]: itemId };
240
283
  try {
241
- const result = await writeItem(collection.dataDir, itemId, recordWithId, { refuseOverwrite: true, slug: collection.slug });
284
+ const result = await createStore(itemId, recordWithId, { refuseOverwrite: true });
242
285
  if (result.kind === "invalid-id") {
243
286
  badRequest(res, `invalid item id: ${result.itemId}`);
244
287
  return;
@@ -260,9 +303,11 @@ router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>,
260
303
  });
261
304
 
262
305
  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`);
306
+ const collection = await loadCollectionOr404(req.params.slug, res);
307
+ if (!collection) return;
308
+ const updateStore = storeFor(collection).write;
309
+ if (!updateStore) {
310
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
266
311
  return;
267
312
  }
268
313
  const record = extractRecord(req.body);
@@ -282,7 +327,7 @@ router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; item
282
327
  // record id never drift.
283
328
  const recordWithId: CollectionItem = { ...record, [primaryKey]: req.params.itemId };
284
329
  try {
285
- const result = await writeItem(collection.dataDir, req.params.itemId, recordWithId, { slug: collection.slug });
330
+ const result = await updateStore(req.params.itemId, recordWithId);
286
331
  if (result.kind === "invalid-id") {
287
332
  badRequest(res, `invalid item id: ${result.itemId}`);
288
333
  return;
@@ -306,13 +351,15 @@ router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; item
306
351
  });
307
352
 
308
353
  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`);
354
+ const collection = await loadCollectionOr404(req.params.slug, res);
355
+ if (!collection) return;
356
+ const deleteStore = storeFor(collection).delete;
357
+ if (!deleteStore) {
358
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
312
359
  return;
313
360
  }
314
361
  try {
315
- const result = await deleteItem(collection.dataDir, req.params.itemId, { slug: collection.slug });
362
+ const result = await deleteStore(req.params.itemId);
316
363
  if (result.kind === "invalid-id") {
317
364
  badRequest(res, `invalid item id: ${result.itemId}`);
318
365
  return;
@@ -351,11 +398,8 @@ interface RefreshResponse {
351
398
  // carries no `ingest` block (it's an ordinary skill collection, not a
352
399
  // feed). Backs the CollectionView "Refresh feed" button.
353
400
  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
- }
401
+ const collection = await loadCollectionOr404(req.params.slug, res);
402
+ if (!collection) return;
359
403
  if (!collection.schema.ingest) {
360
404
  badRequest(res, `collection '${collection.slug}' is not a feed (no ingest config)`);
361
405
  return;
@@ -441,18 +485,15 @@ async function respondForMutateAction(
441
485
  // the hidden worker itself; for `kind: "mutate"`, it applies the
442
486
  // declarative write). No domain (invoice / PDF / role) literals.
443
487
  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
- }
488
+ const collection = await loadCollectionOr404(req.params.slug, res);
489
+ if (!collection) return;
449
490
  const action = collection.schema.actions?.find((entry) => entry.id === req.params.actionId);
450
491
  if (!action) {
451
492
  notFound(res, `action '${req.params.actionId}' not found on collection '${collection.slug}'`);
452
493
  return;
453
494
  }
454
495
  try {
455
- const record = await readItem(collection.dataDir, req.params.itemId);
496
+ const record = await storeFor(collection).read(req.params.itemId);
456
497
  if (!record) {
457
498
  notFound(res, `item '${req.params.itemId}' not found`);
458
499
  return;
@@ -469,6 +510,12 @@ router.post(API_ROUTES.collections.itemAction, async (req: Request<{ slug: strin
469
510
  // the declarative write itself (require was just enforced above,
470
511
  // same visibility-is-authorization rule as the seeded kinds).
471
512
  if (action.kind === "mutate") {
513
+ // Schema validation already rejects mutate actions on a dataSource
514
+ // collection; this is the defensive server-side twin.
515
+ if (!collectionWritable(collection)) {
516
+ methodNotAllowed(res, readOnlyRefusal(collection.slug));
517
+ return;
518
+ }
472
519
  await respondForMutateAction(res, collection, action, req.params.itemId, req.body as { params?: unknown } | undefined);
473
520
  return;
474
521
  }
@@ -498,7 +545,7 @@ router.post(API_ROUTES.collections.itemAction, async (req: Request<{ slug: strin
498
545
  async function buildCollectionActionSeed(collection: LoadedCollection, action: CollectionSeededAction): Promise<ActionSeedResponse | null> {
499
546
  const template = await readSkillTemplate(collection.skillDir, action.template);
500
547
  if (template === null) return null;
501
- const items = await listItems(collection.dataDir);
548
+ const items = await storeFor(collection).list();
502
549
  log.info("collections", "collection action seed built", { slug: collection.slug, actionId: action.id, items: items.length });
503
550
  return { prompt: buildCollectionActionSeedPrompt(items, collection.schema, template, promptPathsFor(collection, workspacePath)), role: action.role };
504
551
  }
@@ -506,11 +553,8 @@ async function buildCollectionActionSeed(collection: LoadedCollection, action: C
506
553
  // Like the per-record route but with no `itemId`: there is no record to read or
507
554
  // gate on, so the seed injects a progress summary instead. No domain literals.
508
555
  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
- }
556
+ const collection = await loadCollectionOr404(req.params.slug, res);
557
+ if (!collection) return;
514
558
  const action = collection.schema.collectionActions?.find((entry) => entry.id === req.params.actionId);
515
559
  if (!action) {
516
560
  notFound(res, `collection action '${req.params.actionId}' not found on collection '${collection.slug}'`);
@@ -566,6 +610,20 @@ function sendToolResult(res: Response, raw: string): void {
566
610
  }
567
611
  }
568
612
 
613
+ // ── View-data routes: a FROZEN public contract ──────────────────────────────
614
+ // Everything under `viewData*` below is called by LLM-authored custom-view
615
+ // HTML files persisted in users' workspaces (`data/skills/*/views/*.html`,
616
+ // `feeds/*/views/*.html`), written against the contract in
617
+ // `packages/core/assets/helps/custom-view.md`. Those files cannot be
618
+ // re-generated or migrated centrally, so this surface must stay
619
+ // backward-compatible indefinitely: keep `?fields=` / `?ids=` semantics, the
620
+ // `{ collection, count, items }` / `{ written, rejected }` / `{ rows }`
621
+ // response shapes, and the status-code semantics (400 with `{ error }`,
622
+ // 403 mutate-kind, 409 require-gate) stable. Evolve by ADDITION only —
623
+ // new optional params, new routes — never by renaming or reshaping.
624
+ // Storage virtualization (new `CollectionStore` backends — see
625
+ // `@mulmoclaude/core` collection/server/store.ts) must be invisible here.
626
+ //
569
627
  // The view-data fetch comes from a sandboxed (opaque-origin) iframe, so it is
570
628
  // a cross-origin request that the browser gates with CORS. `*` is safe here:
571
629
  // auth is the unguessable scoped token in the Authorization header (not a
@@ -614,6 +672,12 @@ export function makeViewActionRateLimiter(max: number, windowMs: number, now: ()
614
672
 
615
673
  const VIEW_ACTION_RATE_LIMIT_PER_MINUTE = 60;
616
674
  const viewActionRateLimit = makeViewActionRateLimiter(VIEW_ACTION_RATE_LIMIT_PER_MINUTE, ONE_MINUTE_MS);
675
+ // Image thumbnails get their own, roomier bucket: a gallery legitimately
676
+ // fetches dozens of images on first paint, so the action budget (60/min)
677
+ // would starve it — while the endpoint still needs a ceiling (each request
678
+ // is a record scan + a thumbnail decode).
679
+ const VIEW_IMAGE_RATE_LIMIT_PER_MINUTE = 300;
680
+ const viewImageRateLimit = makeViewActionRateLimiter(VIEW_IMAGE_RATE_LIMIT_PER_MINUTE, ONE_MINUTE_MS);
617
681
 
618
682
  router.options(API_ROUTES.collections.viewData, viewDataCors, (_req: Request, res: Response) => {
619
683
  res.status(204).end();
@@ -628,16 +692,9 @@ router.get(API_ROUTES.collections.viewFile, async (req: Request<{ slug: string }
628
692
  try {
629
693
  const { slug } = req.params;
630
694
  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
- }
695
+ const resolved = await resolveCustomViewOr404(slug, viewId, res);
696
+ if (!resolved) return;
697
+ const { collection, view } = resolved;
641
698
  // Path-safe, source-aware read through the collections domain layer (no raw
642
699
  // fs / hardcoded subpaths in the route).
643
700
  const html = await readCustomViewHtml(collection, view.file);
@@ -670,11 +727,8 @@ router.get(API_ROUTES.collections.remoteView, async (req: Request<{ slug: string
670
727
  const { slug } = req.params;
671
728
  const viewId = typeof req.query.id === "string" ? req.query.id : "";
672
729
  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
- }
730
+ const collection = await loadCollectionOr404(slug, res);
731
+ if (!collection) return;
678
732
  const result = await buildRemoteView(collection, viewId, locale);
679
733
  if (result.kind !== "ok") {
680
734
  sendRemoteViewFailure(res, result, slug);
@@ -692,6 +746,7 @@ router.get(API_ROUTES.collections.remoteView, async (req: Request<{ slug: string
692
746
  function sendMutateRemoteViewFailure(res: Response, result: Exclude<MutateRemoteViewResult, { kind: "ok" }>, slug: string): void {
693
747
  const message = mutateRemoteViewFailureMessage(result, slug);
694
748
  if (result.kind === "view-not-found" || result.kind === "item-not-found") notFound(res, message);
749
+ else if (result.kind === "read-only-collection") methodNotAllowed(res, message);
695
750
  else if (result.kind === "not-writable" || result.kind === "delete-not-allowed" || result.kind === "field-not-editable" || result.kind === "path-escape")
696
751
  forbidden(res, message);
697
752
  else badRequest(res, message);
@@ -710,11 +765,8 @@ router.post(API_ROUTES.collections.remoteViewMutate, async (req: Request<{ slug:
710
765
  badRequest(res, "invalid mutate request — expected { op: 'update'|'delete', id, patch? }");
711
766
  return;
712
767
  }
713
- const collection = await loadCollection(slug);
714
- if (!collection) {
715
- notFound(res, `collection '${slug}' not found`);
716
- return;
717
- }
768
+ const collection = await loadCollectionOr404(slug, res);
769
+ if (!collection) return;
718
770
  const result = await mutateRemoteView(collection, viewId, request);
719
771
  if (result.kind !== "ok") {
720
772
  sendMutateRemoteViewFailure(res, result, slug);
@@ -753,11 +805,8 @@ router.get(API_ROUTES.collections.remoteViewItems, async (req: Request<{ slug: s
753
805
  try {
754
806
  const { slug, viewId } = req.params;
755
807
  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
- }
808
+ const collection = await loadCollectionOr404(slug, res);
809
+ if (!collection) return;
761
810
  const result = await remoteViewItems(collection, viewId, request);
762
811
  if (result.kind !== "ok") {
763
812
  sendRemoteViewItemsFailure(res, result, slug);
@@ -788,16 +837,9 @@ router.get(API_ROUTES.collections.viewI18n, async (req: Request<{ slug: string }
788
837
  const { slug } = req.params;
789
838
  const viewId = typeof req.query.id === "string" ? req.query.id : "";
790
839
  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
- }
840
+ const resolved = await resolveCustomViewOr404(slug, viewId, res);
841
+ if (!resolved) return;
842
+ const { collection, view } = resolved;
801
843
  if (!view.i18n) {
802
844
  // The view declared no translation file — return the empty contract so
803
845
  // the client doesn't have to special-case "no i18n" with a different
@@ -829,16 +871,9 @@ router.post(API_ROUTES.collections.viewToken, async (req: Request<{ slug: string
829
871
  badRequest(res, "`viewId` is required");
830
872
  return;
831
873
  }
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
- }
874
+ const resolved = await resolveCustomViewOr404(slug, viewId, res);
875
+ if (!resolved) return;
876
+ const { view } = resolved;
842
877
  const granted = clampCapabilities(view.capabilities, parseCapabilities(body.capabilities));
843
878
  const minted = mintViewToken(slug, granted);
844
879
  if (!minted) {
@@ -871,6 +906,149 @@ router.get(API_ROUTES.collections.viewData, viewDataCors, requireViewToken("read
871
906
  }
872
907
  });
873
908
 
909
+ // Preflight for the token-scoped query endpoint (POST + JSON from a
910
+ // sandboxed opaque-origin iframe — same CORS story as view-data itself).
911
+ router.options(API_ROUTES.collections.viewDataQuery, viewDataCors, (_req: Request, res: Response) => {
912
+ res.sendStatus(204);
913
+ });
914
+
915
+ /** Per-slug in-flight cap for view-issued aggregation queries. The
916
+ * per-minute limiter alone still lets a burst arrive CONCURRENTLY —
917
+ * each query is a full-file DuckDB scan, so a runaway dashboard loop
918
+ * could otherwise stack dozens of scans at once. Exported factory so
919
+ * the unit test can drive the counter without HTTP. */
920
+ export function makeViewQueryConcurrencyGuard(max: number) {
921
+ const inflight = new Map<string, number>();
922
+ return (req: Request<{ slug?: string }>, res: Response, next: NextFunction): void => {
923
+ const slug = req.params.slug ?? "";
924
+ const current = inflight.get(slug) ?? 0;
925
+ if (current >= max) {
926
+ res.status(429).json({ error: "too many concurrent queries for this collection — retry shortly" });
927
+ return;
928
+ }
929
+ inflight.set(slug, current + 1);
930
+ let released = false;
931
+ // `close` fires after the response finishes OR the client disconnects
932
+ // mid-request — either way the scan slot must come back exactly once.
933
+ res.once("close", () => {
934
+ if (released) return;
935
+ released = true;
936
+ const now = inflight.get(slug) ?? 1;
937
+ if (now <= 1) inflight.delete(slug);
938
+ else inflight.set(slug, now - 1);
939
+ });
940
+ next();
941
+ };
942
+ }
943
+
944
+ const VIEW_QUERY_MAX_CONCURRENT = 4;
945
+ const viewQueryConcurrency = makeViewQueryConcurrencyGuard(VIEW_QUERY_MAX_CONCURRENT);
946
+
947
+ // Scoped aggregation: run a structured query (the DSL — never raw SQL)
948
+ // over a dataSource collection's whole data file. Read capability only:
949
+ // the DSL is read-only by construction. Reuses the manageCollection
950
+ // handler so a view can never do more than the agent's own queryItems
951
+ // (same validation, same file-backed refusal). Guarded twice: the
952
+ // per-minute limiter (request volume) + the in-flight cap (concurrent
953
+ // full-file scans).
954
+ router.post(
955
+ API_ROUTES.collections.viewDataQuery,
956
+ viewDataCors,
957
+ viewActionRateLimit,
958
+ viewQueryConcurrency,
959
+ requireViewToken("read"),
960
+ async (req: Request<{ slug: string }>, res: Response) => {
961
+ try {
962
+ const body = (req.body ?? {}) as { query?: unknown };
963
+ const raw = await manageCollection.handler({ action: "queryItems", slug: req.params.slug, query: body.query });
964
+ sendToolResult(res, raw);
965
+ } catch (err) {
966
+ // Log the detail server-side; the token holder gets a FIXED message —
967
+ // a raw DuckDB error can carry absolute paths / host internals, and a
968
+ // scoped view is not a trusted audience for those.
969
+ log.warn("collections", "view-data query failed", { slug: req.params.slug.replace(/[\r\n]/g, " "), error: errorMessage(err) });
970
+ serverError(res, "collection query failed");
971
+ }
972
+ },
973
+ );
974
+
975
+ /** True when `relPath` is a CURRENT value of one of the schema's
976
+ * `image`-type fields (top-level fields only, matching the remote view's
977
+ * `inlineFields` rule) across `items`. This is the authorization rule for
978
+ * the view-data image route: a scoped view token may resolve exactly these
979
+ * paths and nothing else — never an arbitrary workspace file. Early-exit
980
+ * scan (no per-request Set allocation). Exported for the unit test. */
981
+ export function isAuthorizedImagePath(schema: LoadedCollection["schema"], items: CollectionItem[], relPath: string): boolean {
982
+ const imageFields = Object.entries(schema.fields)
983
+ .filter(([, spec]) => spec.type === "image")
984
+ .map(([name]) => name);
985
+ if (imageFields.length === 0 || relPath.length === 0) return false;
986
+ return items.some((item) => imageFields.some((field) => item[field] === relPath));
987
+ }
988
+
989
+ export interface ViewDataImageDeps {
990
+ loadCollection: (slug: string) => Promise<LoadedCollection | null>;
991
+ listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
992
+ resolveThumbnail: typeof resolveThumbnail;
993
+ }
994
+
995
+ /** The image-route handler behind a deps seam so its contract (400 missing
996
+ * path / 404 unknown collection / 404 unauthorized path / 404 unresolvable
997
+ * / clamped `maxEdge` plumbing / 500 on a thrown resolver) is
998
+ * unit-testable without mounting the express app — same factory pattern as
999
+ * the remote-view handlers. */
1000
+ export const createViewDataImageHandler =
1001
+ (deps: ViewDataImageDeps) =>
1002
+ async (req: Request<{ slug: string }>, res: Response): Promise<void> => {
1003
+ const collection = await deps.loadCollection(req.params.slug);
1004
+ if (!collection) {
1005
+ notFound(res, `collection '${req.params.slug}' not found`);
1006
+ return;
1007
+ }
1008
+ const relPath = typeof req.query.path === "string" ? req.query.path : "";
1009
+ if (relPath.length === 0) {
1010
+ badRequest(res, "pass `path` — an image field's workspace-relative value");
1011
+ return;
1012
+ }
1013
+ try {
1014
+ const items = await deps.listRecords(collection);
1015
+ if (!isAuthorizedImagePath(collection.schema, items, relPath)) {
1016
+ notFound(res, "path is not a current value of this collection's image fields");
1017
+ return;
1018
+ }
1019
+ const dataUrl = await deps.resolveThumbnail(relPath, clampImageMaxEdge(req.query.maxEdge));
1020
+ if (dataUrl === null) {
1021
+ notFound(res, "image could not be resolved");
1022
+ return;
1023
+ }
1024
+ res.json({ path: relPath, dataUrl });
1025
+ } catch (err) {
1026
+ log.warn("collections", "view-data image failed", { slug: collection.slug, error: errorMessage(err) });
1027
+ serverError(res, "image resolve failed");
1028
+ }
1029
+ };
1030
+
1031
+ router.options(API_ROUTES.collections.viewDataImage, viewDataCors, (_req: Request, res: Response) => {
1032
+ res.status(204).end();
1033
+ });
1034
+
1035
+ // Scoped image read: resolve one record-referenced image path into a
1036
+ // downscaled `data:` thumbnail (same resolver + clamps as the remote view's
1037
+ // `imageFields` inlining). The record scan doubles as the authorization
1038
+ // check — the requested path must be a CURRENT image-field value — and runs
1039
+ // under the same in-flight cap as /query (both are per-request full scans).
1040
+ // Sandboxed views can't attach the bearer to an <img>, so the JSON
1041
+ // { dataUrl } shape (fetch → img.src) is the contract; see
1042
+ // packages/core/assets/helps/custom-view.md "Displaying images".
1043
+ router.get(
1044
+ API_ROUTES.collections.viewDataImage,
1045
+ viewDataCors,
1046
+ viewImageRateLimit,
1047
+ viewQueryConcurrency,
1048
+ requireViewToken("read"),
1049
+ createViewDataImageHandler({ loadCollection, listRecords: (collection) => storeFor(collection).list(), resolveThumbnail }),
1050
+ );
1051
+
874
1052
  // Scoped write: validated putItems. Requires the `write` capability.
875
1053
  router.put(API_ROUTES.collections.viewData, viewDataCors, requireViewToken("write"), async (req: Request<{ slug: string }>, res: Response) => {
876
1054
  try {
@@ -903,11 +1081,8 @@ router.post(
903
1081
  requireViewToken("write"),
904
1082
  async (req: Request<{ slug: string; actionId: string }>, res: Response<ActionRunResponse>) => {
905
1083
  try {
906
- const collection = await loadCollection(req.params.slug);
907
- if (!collection) {
908
- notFound(res, `collection '${req.params.slug}' not found`);
909
- return;
910
- }
1084
+ const collection = await loadCollectionOr404(req.params.slug, res);
1085
+ if (!collection) return;
911
1086
  const action = collection.schema.actions?.find((entry) => entry.id === req.params.actionId);
912
1087
  if (!action) {
913
1088
  notFound(res, `action '${req.params.actionId}' not found on collection '${collection.slug}'`);
@@ -923,7 +1098,7 @@ router.post(
923
1098
  badRequest(res, "`itemId` is required (the record's primary-key value)");
924
1099
  return;
925
1100
  }
926
- const record = await readItem(collection.dataDir, itemId);
1101
+ const record = await storeFor(collection).read(itemId);
927
1102
  if (!record) {
928
1103
  notFound(res, `item '${itemId}' not found`);
929
1104
  return;
@@ -971,11 +1146,8 @@ function sendDeleteViewRefusal(res: Response, result: Exclude<DeleteViewResult,
971
1146
  // refuses user-scope + preset collections, consistent with collection delete.
972
1147
  router.delete(API_ROUTES.collections.viewDelete, async (req: Request<{ slug: string; viewId: string }>, res: Response<DeleteViewResponse>) => {
973
1148
  try {
974
- const collection = await loadCollection(req.params.slug);
975
- if (!collection) {
976
- notFound(res, `collection '${req.params.slug}' not found`);
977
- return;
978
- }
1149
+ const collection = await loadCollectionOr404(req.params.slug, res);
1150
+ if (!collection) return;
979
1151
  const result = await deleteCustomView(collection, req.params.viewId);
980
1152
  if (result.kind !== "ok") {
981
1153
  sendDeleteViewRefusal(res, result);