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.
- package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
- package/client/assets/index-D3czxq1I.css +2 -0
- package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
- package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
- package/client/index.html +3 -3
- package/package.json +6 -5
- package/server/agent/attachmentConverter.ts +0 -25
- package/server/api/auth/viewToken.ts +10 -8
- package/server/api/routes/agent.ts +35 -10
- package/server/api/routes/collections.ts +150 -96
- package/server/api/routes/files.ts +57 -54
- package/server/api/routes/mulmo-script.ts +257 -895
- package/server/api/routes/wiki/history.ts +17 -19
- package/server/events/collection-change.ts +0 -5
- package/server/events/file-change.ts +1 -6
- package/server/index.ts +25 -76
- package/server/plugins/diagnostics.ts +0 -6
- package/server/plugins/mulmoscript-server.ts +58 -0
- package/server/prompts/system/system.md +2 -2
- package/server/remoteHost/handlers/getCollection.ts +5 -4
- package/server/remoteHost/handlers/googleCalendar.ts +50 -3
- package/server/remoteHost/handlers/index.ts +3 -1
- package/server/utils/files/html-store.ts +2 -16
- package/server/utils/files/journal-io.ts +0 -12
- package/server/utils/files/plugins-io.ts +2 -11
- package/server/utils/files/safe.ts +39 -0
- package/server/utils/httpError.ts +6 -0
- package/server/utils/time.ts +0 -3
- package/server/workspace/collections/index.ts +1 -0
- package/server/workspace/collections/remoteView.ts +13 -4
- package/server/workspace/journal/state.ts +1 -9
- package/server/workspace/memory/topic-index-hook.ts +0 -9
- package/server/workspace/memory/topic-io.ts +0 -4
- package/server/workspace/skills/external/catalog.ts +24 -13
- package/server/workspace/skills/writer.ts +20 -16
- package/server/workspace/wiki-pages/snapshot.ts +13 -12
- package/src/composables/useFileDropZone.ts +3 -15
- package/src/config/apiRoutes.ts +6 -0
- package/src/config/roles.ts +0 -4
- package/src/lang/de.ts +0 -30
- package/src/lang/en.ts +0 -30
- package/src/lang/es.ts +0 -30
- package/src/lang/fr.ts +0 -30
- package/src/lang/ja.ts +0 -30
- package/src/lang/ko.ts +0 -30
- package/src/lang/pt-BR.ts +0 -30
- package/src/lang/zh.ts +0 -30
- package/src/plugins/accounting/index.ts +0 -2
- package/src/plugins/api.ts +0 -6
- package/src/plugins/canvas/index.ts +0 -2
- package/src/plugins/chart/index.ts +0 -2
- package/src/plugins/editImages/index.ts +0 -2
- package/src/plugins/generateImage/index.ts +0 -2
- package/src/plugins/manageSkills/index.ts +0 -2
- package/src/plugins/markdown/index.ts +0 -2
- package/src/plugins/photoLocations/index.ts +0 -2
- package/src/plugins/presentCollection/index.ts +0 -2
- package/src/plugins/presentForm/index.ts +0 -2
- package/src/plugins/presentHtml/index.ts +0 -2
- package/src/plugins/presentMulmoScript/definition.ts +11 -116
- package/src/plugins/presentMulmoScript/index.ts +62 -12
- package/src/plugins/presentSVG/index.ts +0 -2
- package/src/plugins/skill/index.ts +0 -3
- package/src/plugins/spreadsheet/engine/parser.ts +0 -103
- package/src/plugins/spreadsheet/index.ts +0 -2
- package/src/plugins/textResponse/index.ts +0 -3
- package/src/plugins/wiki/index.ts +0 -2
- package/src/types/notification.ts +0 -10
- package/src/utils/api.ts +0 -4
- package/client/assets/index-D5nJbMO1.css +0 -2
- package/server/api/routes/mulmoScriptValidate.ts +0 -101
- package/server/utils/mulmoErrorCapture.ts +0 -93
- package/src/plugins/presentMulmoScript/Preview.vue +0 -23
- package/src/plugins/presentMulmoScript/View.vue +0 -1949
- 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
|
|
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
|
|
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
|
|
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
|
|
222
|
-
if (!collection)
|
|
223
|
-
|
|
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
|
|
264
|
-
if (!collection)
|
|
265
|
-
|
|
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
|
|
310
|
-
if (!collection)
|
|
311
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
632
|
-
if (!
|
|
633
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
792
|
-
if (!
|
|
793
|
-
|
|
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
|
|
833
|
-
if (!
|
|
834
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
493
|
-
//
|
|
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
|
-
|
|
497
|
-
if (!
|
|
498
|
-
|
|
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
|
-
|
|
531
|
-
if (!
|
|
532
|
-
|
|
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
|
-
|
|
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>) => {
|