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.
- package/README.md +1 -1
- package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
- package/client/assets/index-7QnbsZZr.css +2 -0
- package/client/assets/{index-yQkLdtMk.js → index-C3SRVT2p.js} +84 -85
- package/client/assets/{marp-Bs_qwoKZ.js → marp-DxctLEy1.js} +12 -3
- package/client/index.html +3 -3
- package/package.json +7 -6
- package/server/agent/attachmentConverter.ts +0 -25
- package/server/agent/mcp-server.ts +27 -8
- package/server/api/auth/viewToken.ts +16 -8
- package/server/api/routes/agent.ts +49 -11
- package/server/api/routes/collections.ts +274 -102
- 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/build/dispatcher.mjs +2 -2
- package/server/events/collection-change.ts +1 -5
- package/server/events/file-change.ts +1 -6
- package/server/events/pub-sub/index.ts +15 -2
- package/server/events/relay-client.ts +6 -1
- package/server/events/session-store/index.ts +11 -2
- package/server/index.ts +51 -78
- package/server/plugins/diagnostics.ts +0 -6
- package/server/plugins/mulmoscript-server.ts +58 -0
- package/server/prompts/system/system.md +2 -3
- package/server/remoteHost/handlers/getCollection.ts +5 -4
- package/server/remoteHost/handlers/getFeed.ts +10 -5
- package/server/remoteHost/handlers/googleCalendar.ts +50 -3
- package/server/remoteHost/handlers/index.ts +3 -1
- package/server/services/translation/index.ts +10 -3
- package/server/utils/errors.ts +7 -21
- 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/text.ts +7 -23
- package/server/utils/time.ts +0 -3
- package/server/workspace/collections/index.ts +6 -4
- package/server/workspace/collections/remoteView.ts +31 -22
- package/server/workspace/collections/watcher.ts +1 -0
- package/server/workspace/custom-dirs.ts +16 -6
- 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/reference-dirs.ts +8 -3
- 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/components/SettingsGoogleTab.vue +4 -1
- package/src/components/StackView.vue +12 -1
- package/src/composables/collections/uiHost.ts +2 -1
- package/src/composables/useChatScroll.ts +18 -6
- package/src/composables/useFileDropZone.ts +3 -15
- package/src/composables/useFileSelection.ts +4 -1
- package/src/composables/useStickToBottom.ts +51 -0
- package/src/config/apiRoutes.ts +20 -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/src/utils/dom/scrollable.ts +15 -0
- package/src/utils/errors.ts +7 -38
- 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,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 {
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
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
|
|
222
|
-
if (!collection)
|
|
223
|
-
|
|
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
|
|
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
|
|
264
|
-
if (!collection)
|
|
265
|
-
|
|
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
|
|
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
|
|
310
|
-
if (!collection)
|
|
311
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
|
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);
|