@3sln/trove 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// What an agent can actually do with the drive.
|
|
2
|
+
//
|
|
3
|
+
// Every tool here goes through the SAME permission checks the HTTP API uses. That is the
|
|
4
|
+
// whole security posture: an agent holding Alice's token is Alice, with Alice's
|
|
5
|
+
// collections and Alice's write access, and there is no MCP-shaped bypass around the
|
|
6
|
+
// collection ACL. The alternative — a service account the agent shares — would mean one
|
|
7
|
+
// compromised agent reads everyone's drive.
|
|
8
|
+
//
|
|
9
|
+
// Descriptions are written for a MODEL to read. That means saying what a tool is FOR and
|
|
10
|
+
// when to reach for it, not restating the parameter names it can already see in the
|
|
11
|
+
// schema. The single most valuable sentence in this file is the one telling the model
|
|
12
|
+
// that this drive has no folders, because every model assumes otherwise and will
|
|
13
|
+
// otherwise spend its turns looking for a path that does not exist.
|
|
14
|
+
|
|
15
|
+
import { TroveError } from '@3sln/trove/core';
|
|
16
|
+
import { troveUri } from '@3sln/trove/core/links.js';
|
|
17
|
+
import { toolText } from './protocol.js';
|
|
18
|
+
|
|
19
|
+
// A file read has to fit in a context window and in memory. Past this the tool returns
|
|
20
|
+
// the head and says so, which is far more useful than refusing or than silently
|
|
21
|
+
// truncating and letting the model reason about half a document as if it were whole.
|
|
22
|
+
const MAX_READ_BYTES = 256 * 1024;
|
|
23
|
+
const MAX_RESULTS = 50;
|
|
24
|
+
|
|
25
|
+
const INSTRUCTIONS = `Trove is a personal file drive with semantic search.
|
|
26
|
+
|
|
27
|
+
There are NO FOLDERS and no paths. Files live in flat "collections" (the default one is
|
|
28
|
+
called "default"), and are found by searching rather than by browsing a tree. If you are
|
|
29
|
+
looking for something, search for it — do not try to construct a path.
|
|
30
|
+
|
|
31
|
+
Files reference each other with trove: URIs (trove:default?name=notes.md). Search matches
|
|
32
|
+
meaning as well as words, so a description of the content works as a query.`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether this deployment has an ACL layer at all — from configuration, never from
|
|
36
|
+
* whether `ctx.collections` happens to be there. The two agree while everything is
|
|
37
|
+
* wired correctly and diverge exactly when it is not, and here the failure mode is an
|
|
38
|
+
* agent quietly reading every collection in the drive.
|
|
39
|
+
*/
|
|
40
|
+
const enforcing = (ctx) => ctx.config?.collections !== false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collections this principal can read, or undefined when there is no ACL layer.
|
|
44
|
+
*
|
|
45
|
+
* `undefined` means "do not scope the query", which is only correct when there is
|
|
46
|
+
* nothing to scope BY. Search and backlinks reach across collections by design, so an
|
|
47
|
+
* unscoped query hands back names, ids and `trove:` URIs from collections the caller
|
|
48
|
+
* cannot read.
|
|
49
|
+
*/
|
|
50
|
+
async function readable(ctx, narrowTo) {
|
|
51
|
+
if (!enforcing(ctx)) return undefined;
|
|
52
|
+
const ids = (await ctx.collections.list(ctx.principal)).map((c) => c.id);
|
|
53
|
+
return narrowTo ? ids.filter((id) => id === narrowTo) : ids;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A file, and the operations this agent may perform on it.
|
|
58
|
+
*
|
|
59
|
+
* `access.node` resolves by id, by name within a collection, or by `trove:` URI, and
|
|
60
|
+
* throws when the capability is not held — so a tool that gets a handle back is a tool
|
|
61
|
+
* that may act. Missing becomes the sentence a MODEL should read: "Item" tells it
|
|
62
|
+
* nothing, and it will retry the same call.
|
|
63
|
+
*/
|
|
64
|
+
async function fileHandle(ctx, file, collectionId, capability) {
|
|
65
|
+
try {
|
|
66
|
+
return await ctx.access.node(file, capability, { collectionId });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err?.code === 'not_found') throw TroveError.notFound(`No file called "${file}"`);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Everything about a node worth telling a model, and nothing internal. */
|
|
74
|
+
function describeNode(node) {
|
|
75
|
+
return {
|
|
76
|
+
id: node.id,
|
|
77
|
+
name: node.name,
|
|
78
|
+
uri: troveUri(node),
|
|
79
|
+
collection: node.collectionId,
|
|
80
|
+
contentType: node.contentType,
|
|
81
|
+
size: node.size,
|
|
82
|
+
updatedAt: node.updatedAt,
|
|
83
|
+
...(node.tags && Object.keys(node.tags).length ? { tags: node.tags } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const textLike = (type) => !type || /^text\/|json|xml|yaml|javascript|csv|markdown|x-sh/.test(type);
|
|
88
|
+
|
|
89
|
+
async function readText(handle) {
|
|
90
|
+
const { stream } = await handle.read({ range: { start: 0, end: MAX_READ_BYTES } });
|
|
91
|
+
const text = await new Response(stream).text();
|
|
92
|
+
return { text, truncated: handle.size > MAX_READ_BYTES };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Register Trove's tools on an McpServer.
|
|
97
|
+
*
|
|
98
|
+
* `ctx` at call time carries { vfs, collections, principal } — the same objects the HTTP
|
|
99
|
+
* routes get, so the two surfaces cannot drift apart on what a given user may do.
|
|
100
|
+
*/
|
|
101
|
+
export function registerTroveTools(server) {
|
|
102
|
+
server.instructions = INSTRUCTIONS;
|
|
103
|
+
|
|
104
|
+
server.tool({
|
|
105
|
+
name: 'search_files',
|
|
106
|
+
title: 'Search the drive',
|
|
107
|
+
readOnly: true,
|
|
108
|
+
description:
|
|
109
|
+
'Search the drive by meaning and by keyword. This is the primary way to find anything — '
|
|
110
|
+
+ 'there are no folders to browse. A natural description of the content works ("notes about '
|
|
111
|
+
+ 'the boat refit"); so does an exact phrase. Append #tag to require a tag, or #key:value to '
|
|
112
|
+
+ 'compare one (=, !=, <, <=, >, >=). Returns matching files with a snippet where there is one.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
query: { type: 'string', description: 'What to look for, in words.' },
|
|
117
|
+
collection: { type: 'string', description: 'Restrict to one collection. Omit to search everything you can read.' },
|
|
118
|
+
limit: { type: 'integer', description: `Maximum results (default 10, max ${MAX_RESULTS}).` },
|
|
119
|
+
},
|
|
120
|
+
required: ['query'],
|
|
121
|
+
},
|
|
122
|
+
async run({ query, collection, limit }, ctx) {
|
|
123
|
+
if (!query?.trim()) throw TroveError.invalid('query is required');
|
|
124
|
+
const collectionIds = await readable(ctx, collection);
|
|
125
|
+
const { results, resolved } = await ctx.vfs.query(query, {
|
|
126
|
+
limit: Math.min(Math.max(1, limit || 10), MAX_RESULTS),
|
|
127
|
+
collectionIds,
|
|
128
|
+
});
|
|
129
|
+
const items = results.map((r) => ({
|
|
130
|
+
...describeNode(r.node),
|
|
131
|
+
score: r.score,
|
|
132
|
+
...(r.snippet ? { snippet: r.snippet } : {}),
|
|
133
|
+
}));
|
|
134
|
+
if (!items.length) {
|
|
135
|
+
// Saying "nothing matched" beats an empty array: a model reading `[]` often
|
|
136
|
+
// retries the identical query, while a sentence prompts it to rephrase.
|
|
137
|
+
return toolText(`No files matched "${query}". Try different words — search matches meaning, so a description of the content works.`,
|
|
138
|
+
{ structured: { results: [] } });
|
|
139
|
+
}
|
|
140
|
+
return toolText(JSON.stringify({ resolved, results: items }, null, 2), { structured: { results: items } });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
server.tool({
|
|
145
|
+
name: 'list_files',
|
|
146
|
+
title: 'List a collection',
|
|
147
|
+
readOnly: true,
|
|
148
|
+
description:
|
|
149
|
+
'List what is in a collection, most recently changed first. Use this to see what exists; use '
|
|
150
|
+
+ 'search_files to find something specific. Large collections are paged — pass the returned '
|
|
151
|
+
+ 'cursor to continue.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
collection: { type: 'string', description: 'Which collection (default: "default").' },
|
|
156
|
+
cursor: { type: 'string', description: 'Continue from a previous call.' },
|
|
157
|
+
limit: { type: 'integer', description: `Maximum items (default 25, max ${MAX_RESULTS}).` },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
async run({ collection = 'default', cursor, limit }, ctx) {
|
|
161
|
+
// The description promises recency and `list` defaults to alphabetical, so it
|
|
162
|
+
// has to be asked for. An agent answering "what did I add recently?" off the top
|
|
163
|
+
// of an alphabetical list is confidently wrong in a way nothing surfaces.
|
|
164
|
+
const page = await (await ctx.access.collection(collection, 'read')).list({
|
|
165
|
+
cursor, sort: 'updatedAt', order: 'desc',
|
|
166
|
+
limit: Math.min(Math.max(1, limit || 25), MAX_RESULTS),
|
|
167
|
+
});
|
|
168
|
+
const items = (page.items || []).map(describeNode);
|
|
169
|
+
return toolText(JSON.stringify({
|
|
170
|
+
collection, items, ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
171
|
+
}, null, 2), { structured: { items, nextCursor: page.nextCursor || null } });
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
server.tool({
|
|
176
|
+
name: 'read_file',
|
|
177
|
+
title: 'Read a file',
|
|
178
|
+
readOnly: true,
|
|
179
|
+
description:
|
|
180
|
+
'Read a file\'s text. Identify it by id, by name, or by a trove: URI — whichever you have. '
|
|
181
|
+
+ 'Binary files (images, video, archives) are not returned as text; their details are '
|
|
182
|
+
+ 'returned instead so you know what is there.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
file: { type: 'string', description: 'A file id, a name, or a trove: URI.' },
|
|
187
|
+
collection: { type: 'string', description: 'Which collection to look in when given a name (default: "default").' },
|
|
188
|
+
},
|
|
189
|
+
required: ['file'],
|
|
190
|
+
},
|
|
191
|
+
async run({ file, collection = 'default' }, ctx) {
|
|
192
|
+
const handle = await fileHandle(ctx, file, collection, 'read');
|
|
193
|
+
const node = handle.node;
|
|
194
|
+
if (!textLike(node.contentType)) {
|
|
195
|
+
return toolText(`"${node.name}" is ${node.contentType || 'binary'} (${node.size} bytes) and has no text to read.\n`
|
|
196
|
+
+ JSON.stringify(describeNode(node), null, 2), { structured: describeNode(node) });
|
|
197
|
+
}
|
|
198
|
+
const { text, truncated } = await readText(handle);
|
|
199
|
+
// Saying it was cut off matters: a model that believes it read a whole document
|
|
200
|
+
// will confidently answer questions about the part it never saw.
|
|
201
|
+
return toolText(truncated
|
|
202
|
+
? `${text}\n\n[truncated — showing the first ${MAX_READ_BYTES} bytes of ${node.size}]`
|
|
203
|
+
: text);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
server.tool({
|
|
208
|
+
name: 'write_file',
|
|
209
|
+
title: 'Write a file',
|
|
210
|
+
description:
|
|
211
|
+
'Create a file, or replace one that already has this name. Requires write access to the '
|
|
212
|
+
+ 'collection. Link to other files with trove: URIs (trove:default?name=other.md) — that is '
|
|
213
|
+
+ 'how things are related here, since there are no folders.',
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
name: { type: 'string', description: 'The file name, including its extension.' },
|
|
218
|
+
content: { type: 'string', description: 'The text to write.' },
|
|
219
|
+
collection: { type: 'string', description: 'Which collection (default: "default").' },
|
|
220
|
+
contentType: { type: 'string', description: 'Override the type guessed from the name.' },
|
|
221
|
+
},
|
|
222
|
+
required: ['name', 'content'],
|
|
223
|
+
},
|
|
224
|
+
async run({ name, content, collection = 'default', contentType }, ctx) {
|
|
225
|
+
if (!name?.trim()) throw TroveError.invalid('name is required');
|
|
226
|
+
const into = await ctx.access.collection(collection, 'write');
|
|
227
|
+
// No contentType falls through to the vfs's own guess from the name.
|
|
228
|
+
const node = await into.writeFile(name, content ?? '', { contentType });
|
|
229
|
+
return toolText(`Wrote ${node.name} (${node.size} bytes) — ${troveUri(node)}`,
|
|
230
|
+
{ structured: describeNode(node) });
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
server.tool({
|
|
235
|
+
name: 'delete_file',
|
|
236
|
+
title: 'Move a file to the trash',
|
|
237
|
+
description:
|
|
238
|
+
'Move a file to the trash. It leaves the drive but is kept and can be restored, so this is '
|
|
239
|
+
+ 'recoverable — it does not destroy anything. Requires delete access to the collection.',
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
file: { type: 'string', description: 'A file id, a name, or a trove: URI.' },
|
|
244
|
+
collection: { type: 'string', description: 'Which collection to look in when given a name.' },
|
|
245
|
+
},
|
|
246
|
+
required: ['file'],
|
|
247
|
+
},
|
|
248
|
+
async run({ file, collection = 'default' }, ctx) {
|
|
249
|
+
const handle = await fileHandle(ctx, file, collection, 'delete');
|
|
250
|
+
await handle.remove();
|
|
251
|
+
return toolText(`Moved "${handle.name}" to the trash. It can be restored from the drive's trash.`);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
server.tool({
|
|
256
|
+
name: 'list_collections',
|
|
257
|
+
title: 'List collections',
|
|
258
|
+
readOnly: true,
|
|
259
|
+
description:
|
|
260
|
+
'List the collections you can see and what you may do in each. Collections are the top-level '
|
|
261
|
+
+ 'division of the drive — the closest thing here to a folder, except they do not nest.',
|
|
262
|
+
inputSchema: { type: 'object', properties: {} },
|
|
263
|
+
async run(_args, ctx) {
|
|
264
|
+
if (!enforcing(ctx)) {
|
|
265
|
+
return toolText(JSON.stringify({ collections: [{ id: 'default', capabilities: ['read', 'write', 'delete'] }] }, null, 2));
|
|
266
|
+
}
|
|
267
|
+
const list = await ctx.collections.list(ctx.principal);
|
|
268
|
+
return toolText(JSON.stringify({ collections: list }, null, 2), { structured: { collections: list } });
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
server.tool({
|
|
273
|
+
name: 'get_file_info',
|
|
274
|
+
title: 'Get a file\'s details',
|
|
275
|
+
readOnly: true,
|
|
276
|
+
description:
|
|
277
|
+
'Everything known about one file — type, size, when it changed, its tags, and which other '
|
|
278
|
+
+ 'files link to it. Use the backlinks to follow how things are connected.',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
file: { type: 'string', description: 'A file id, a name, or a trove: URI.' },
|
|
283
|
+
collection: { type: 'string', description: 'Which collection to look in when given a name.' },
|
|
284
|
+
},
|
|
285
|
+
required: ['file'],
|
|
286
|
+
},
|
|
287
|
+
async run({ file, collection = 'default' }, ctx) {
|
|
288
|
+
const handle = await fileHandle(ctx, file, collection, 'read');
|
|
289
|
+
const node = handle.node;
|
|
290
|
+
// Scoped, exactly like the HTTP route. Backlinks reach ACROSS collections by
|
|
291
|
+
// design — that is what makes them useful — so an unscoped query hands back the
|
|
292
|
+
// names, ids and trove: URIs of files inside collections the caller cannot read.
|
|
293
|
+
const collectionIds = await readable(ctx);
|
|
294
|
+
const backlinks = await handle.backlinks({ limit: 20, collectionIds })
|
|
295
|
+
// Distinguishable from "nothing links here", which in a drive with no folders is
|
|
296
|
+
// a load-bearing fact an agent will reason from.
|
|
297
|
+
.catch((err) => { throw TroveError.transient(`Could not read backlinks: ${err.message}`); });
|
|
298
|
+
const info = {
|
|
299
|
+
...describeNode(node),
|
|
300
|
+
backlinks: (backlinks || []).map((n) => ({ id: n.id, name: n.name, uri: troveUri(n) })),
|
|
301
|
+
};
|
|
302
|
+
return toolText(JSON.stringify(info, null, 2), { structured: info });
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Resources, so a client that prefers attaching context to a conversation over calling
|
|
307
|
+
// a tool has the same reach. The URIs are Trove's own `trove:` scheme rather than
|
|
308
|
+
// something invented for MCP — one name for a file across the whole system.
|
|
309
|
+
server.resources({
|
|
310
|
+
async list(params, ctx) {
|
|
311
|
+
const ids = await readable(ctx);
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const cid of ids || ['default']) {
|
|
314
|
+
const page = await (await ctx.access.collection(cid, 'read')).list({ limit: 100 });
|
|
315
|
+
for (const node of page.items || []) {
|
|
316
|
+
out.push({
|
|
317
|
+
uri: troveUri(node),
|
|
318
|
+
name: node.name,
|
|
319
|
+
mimeType: node.contentType || 'application/octet-stream',
|
|
320
|
+
...(node.size != null ? { size: node.size } : {}),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { resources: out };
|
|
325
|
+
},
|
|
326
|
+
async read(params, ctx) {
|
|
327
|
+
let handle;
|
|
328
|
+
try {
|
|
329
|
+
handle = await ctx.access.node(params.uri, 'read');
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err?.code === 'not_found') throw TroveError.notFound(`No such resource: ${params.uri}`);
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
const node = handle.node;
|
|
335
|
+
if (!textLike(node.contentType)) {
|
|
336
|
+
// Base64 rather than refusing: a client that asked for an image wants the image.
|
|
337
|
+
const { stream } = await handle.read({ range: { start: 0, end: MAX_READ_BYTES } });
|
|
338
|
+
const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
339
|
+
let bin = '';
|
|
340
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
341
|
+
return { contents: [{ uri: params.uri, mimeType: node.contentType, blob: btoa(bin) }] };
|
|
342
|
+
}
|
|
343
|
+
const { text } = await readText(handle);
|
|
344
|
+
return { contents: [{ uri: params.uri, mimeType: node.contentType || 'text/plain', text }] };
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return server;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export { MAX_READ_BYTES, INSTRUCTIONS };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// A tiny Web-standard router: register (method, pattern) → handler, match an
|
|
2
|
+
// incoming Request, and produce a Response. Patterns use ':param' segments
|
|
3
|
+
// (e.g. '/uploads/:id/parts/:n'). Handlers receive { req, params, query, url }
|
|
4
|
+
// and return a Response (or a plain object → JSON). All errors funnel through
|
|
5
|
+
// one place so a TroveError becomes the right status + JSON body, and anything
|
|
6
|
+
// unexpected becomes a clean 500 without leaking internals.
|
|
7
|
+
|
|
8
|
+
import { TroveError, wrapError, ErrorCode, publicOrigin } from '@3sln/trove/core';
|
|
9
|
+
import { leaseScope } from './scope.js';
|
|
10
|
+
|
|
11
|
+
// Methods that change state. A GET is safe by definition, so it isn't checked.
|
|
12
|
+
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Refuse a state-changing request that another site made using the user's session.
|
|
16
|
+
*
|
|
17
|
+
* The CORS allowlist governs whether an attacker can READ the reply, and it only stops
|
|
18
|
+
* requests that need a preflight. A POST with `content-type: text/plain` is a CORS
|
|
19
|
+
* *simple request*: no preflight is sent, the allowlist is never consulted, and the
|
|
20
|
+
* write happens. The reply being unreadable is no comfort when the call was
|
|
21
|
+
* `delete_file`. Both the JSON API and the MCP endpoint were reachable that way, and
|
|
22
|
+
* the zero-config deployment needs no credential at all — the attacker's own browser
|
|
23
|
+
* is the credential.
|
|
24
|
+
*
|
|
25
|
+
* Browsers send `Sec-Fetch-Site` on every request and `Origin` on every state-changing
|
|
26
|
+
* one, so a cross-site call identifies itself. Non-browser clients (curl, an agent, a
|
|
27
|
+
* script) send neither and are unaffected — they carry no ambient credential, which is
|
|
28
|
+
* the entire basis of the attack. A bearer token is likewise not ambient, but it is not
|
|
29
|
+
* special-cased: a request that presents one is same-origin or scripted anyway.
|
|
30
|
+
*
|
|
31
|
+
* @returns {Response|null} a 403 to return instead of handling, or null to proceed
|
|
32
|
+
*/
|
|
33
|
+
export function crossSiteRefusal(req, config = {}) {
|
|
34
|
+
if (!UNSAFE_METHODS.has(req.method)) return null;
|
|
35
|
+
const site = req.headers.get('sec-fetch-site');
|
|
36
|
+
const origin = req.headers.get('origin');
|
|
37
|
+
if (!site && !origin) return null; // not a browser
|
|
38
|
+
|
|
39
|
+
// An operator who opted into CORS for an origin meant it: that site may call us.
|
|
40
|
+
const allowed = corsOriginFor(config?.corsOrigin, origin);
|
|
41
|
+
if (allowed === '*' || (allowed && allowed === origin)) return null;
|
|
42
|
+
|
|
43
|
+
if (site) {
|
|
44
|
+
if (site === 'same-origin' || site === 'none') return null;
|
|
45
|
+
} else if (origin === publicOrigin(req, config) || origin === new URL(req.url).origin) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return json({
|
|
49
|
+
error: {
|
|
50
|
+
code: 'forbidden',
|
|
51
|
+
message: 'Cross-site requests may not change this drive. If this is intentional, set TROVE_CORS_ORIGIN.',
|
|
52
|
+
retryable: false,
|
|
53
|
+
},
|
|
54
|
+
}, 403);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class Router {
|
|
58
|
+
constructor() {
|
|
59
|
+
this.routes = [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} method
|
|
64
|
+
* @param {string} pattern
|
|
65
|
+
* @param {string[]|Function} depsOrHandler the resources this route needs, by
|
|
66
|
+
* name — or the handler, for a route that needs none.
|
|
67
|
+
* @param {Function} [maybeHandler]
|
|
68
|
+
*
|
|
69
|
+
* Declaring dependencies is the point. Every handler used to receive one
|
|
70
|
+
* object carrying the whole server: vfs, collections, kv, sqlite, plugins,
|
|
71
|
+
* tasks, issues, sidecar, notifications, identity, mcp. That is a service
|
|
72
|
+
* locator — nothing recorded what a route used, so nothing stopped it reaching
|
|
73
|
+
* for more, and "what does this endpoint touch" could only be answered by
|
|
74
|
+
* reading it. Named here, the answer is in the route table, and a route that
|
|
75
|
+
* did not ask for `plugins` does not get `plugins`.
|
|
76
|
+
*/
|
|
77
|
+
add(method, pattern, depsOrHandler, maybeHandler) {
|
|
78
|
+
const handler = maybeHandler ?? depsOrHandler;
|
|
79
|
+
const deps = maybeHandler ? depsOrHandler : [];
|
|
80
|
+
const segs = pattern.split('/').filter(Boolean);
|
|
81
|
+
this.routes.push({ method, segs, handler, deps });
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
get(p, d, h) { return this.add('GET', p, d, h); }
|
|
85
|
+
post(p, d, h) { return this.add('POST', p, d, h); }
|
|
86
|
+
put(p, d, h) { return this.add('PUT', p, d, h); }
|
|
87
|
+
delete(p, d, h) { return this.add('DELETE', p, d, h); }
|
|
88
|
+
|
|
89
|
+
#match(method, pathname) {
|
|
90
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
91
|
+
for (const route of this.routes) {
|
|
92
|
+
if (route.method !== method) continue;
|
|
93
|
+
if (route.segs.length !== parts.length) continue;
|
|
94
|
+
const params = {};
|
|
95
|
+
let ok = true;
|
|
96
|
+
for (let i = 0; i < route.segs.length; i++) {
|
|
97
|
+
const s = route.segs[i];
|
|
98
|
+
// A malformed percent-escape (`/api/uploads/%ZZ/status`) makes
|
|
99
|
+
// decodeURIComponent throw a URIError — and #match runs OUTSIDE the try below,
|
|
100
|
+
// so it escaped the error funnel entirely and rejected the whole handle() call.
|
|
101
|
+
// A bad escape names nothing, which is a 404, not a 500.
|
|
102
|
+
if (s.startsWith(':')) {
|
|
103
|
+
try {
|
|
104
|
+
params[s.slice(1)] = decodeURIComponent(parts[i]);
|
|
105
|
+
} catch {
|
|
106
|
+
ok = false;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (s !== parts[i]) { ok = false; break; }
|
|
111
|
+
}
|
|
112
|
+
if (ok) return { route, params };
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {Request} req
|
|
119
|
+
* @param {object} [ctx] extra context merged into the handler arg (e.g. { vfs })
|
|
120
|
+
*/
|
|
121
|
+
async handle(req, ctx = {}) {
|
|
122
|
+
const url = new URL(req.url);
|
|
123
|
+
// Cross-origin sharing is OFF by default (the app is same-origin); an operator
|
|
124
|
+
// opts in with TROVE_CORS_ORIGIN ('*' or a specific origin) → config.corsOrigin.
|
|
125
|
+
const origin = corsOriginFor(ctx.config?.corsOrigin, req.headers.get('origin'));
|
|
126
|
+
if (req.method === 'OPTIONS') return cors(new Response(null, { status: 204 }), origin);
|
|
127
|
+
|
|
128
|
+
const refused = crossSiteRefusal(req, ctx.config);
|
|
129
|
+
if (refused) return cors(refused, origin);
|
|
130
|
+
|
|
131
|
+
const found = this.#match(req.method, url.pathname);
|
|
132
|
+
if (!found) return cors(json({ error: { code: 'not_found', message: 'No such route' } }, 404), origin);
|
|
133
|
+
|
|
134
|
+
const query = Object.fromEntries(url.searchParams);
|
|
135
|
+
// Exactly what the route declared, leased for exactly the request. Released
|
|
136
|
+
// in `finally`, so a handler that throws still gives its resources back.
|
|
137
|
+
let lease = null;
|
|
138
|
+
// Handles obtained during the request, released with it. `access` is how a
|
|
139
|
+
// handler asks for an AUTHORIZED view of a node, collection or upload: the
|
|
140
|
+
// grant is carried by the object it hands back, so there is no unrestricted
|
|
141
|
+
// service and no raw id left over to use with one.
|
|
142
|
+
const scope = leaseScope(ctx.container, ctx.principal);
|
|
143
|
+
const access = scope.access;
|
|
144
|
+
try {
|
|
145
|
+
lease = ctx.container ? await ctx.container.lease(found.route.deps) : null;
|
|
146
|
+
const result = await found.route.handler({
|
|
147
|
+
req, params: found.params, query, url, access, ...ctx, ...(lease?.resources || {}),
|
|
148
|
+
});
|
|
149
|
+
const res = result instanceof Response ? result : json(result ?? { ok: true });
|
|
150
|
+
return cors(res, origin);
|
|
151
|
+
} catch (raw) {
|
|
152
|
+
const err = raw instanceof TroveError ? raw : wrapError(raw);
|
|
153
|
+
if (err.code === ErrorCode.INTERNAL) console.error('Unhandled:', err.cause || err);
|
|
154
|
+
return cors(json(err.toJSON(), err.status), origin);
|
|
155
|
+
} finally {
|
|
156
|
+
await scope.release();
|
|
157
|
+
await lease?.release();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Resolve the Access-Control-Allow-Origin value: null (no CORS) unless configured.
|
|
163
|
+
// '*' echoes '*'; a configured origin is echoed only when the request matches it
|
|
164
|
+
// (so credentials-mode requests get a specific origin, not a wildcard).
|
|
165
|
+
function corsOriginFor(configured, reqOrigin) {
|
|
166
|
+
if (!configured) return null;
|
|
167
|
+
if (configured === '*') return '*';
|
|
168
|
+
const allowed = String(configured).split(',').map((s) => s.trim()).filter(Boolean);
|
|
169
|
+
return reqOrigin && allowed.includes(reqOrigin) ? reqOrigin : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function json(body, status = 200, headers = {}) {
|
|
173
|
+
return new Response(JSON.stringify(body), {
|
|
174
|
+
status,
|
|
175
|
+
headers: { 'content-type': 'application/json; charset=utf-8', ...headers },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function cors(res, origin = null) {
|
|
180
|
+
const out = writable(res);
|
|
181
|
+
// Never let a browser sniff an API response into a different content type.
|
|
182
|
+
out.headers.set('x-content-type-options', 'nosniff');
|
|
183
|
+
if (origin) {
|
|
184
|
+
out.headers.set('access-control-allow-origin', origin);
|
|
185
|
+
if (origin !== '*') out.headers.set('vary', 'Origin');
|
|
186
|
+
out.headers.set('access-control-allow-methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
|
187
|
+
out.headers.set('access-control-allow-headers', 'content-type, authorization, x-trove-indexer');
|
|
188
|
+
out.headers.set('access-control-expose-headers', 'content-range, accept-ranges, etag, content-length, content-disposition');
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// `Response.redirect()` produces a response whose headers guard is IMMUTABLE — setting
|
|
194
|
+
// anything on it throws `TypeError: immutable`. Every response funnels through cors(),
|
|
195
|
+
// so on a runtime that enforces the guard (Node, Workers; Bun happens not to) a single
|
|
196
|
+
// presigned-download redirect became a 500 caught by the same try/catch that logs it as
|
|
197
|
+
// an internal error. Copy into a plain Response, whose headers are always writable.
|
|
198
|
+
function writable(res) {
|
|
199
|
+
try {
|
|
200
|
+
res.headers.set('x-content-type-options', 'nosniff');
|
|
201
|
+
return res;
|
|
202
|
+
} catch {
|
|
203
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: new Headers(res.headers) });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse a Range header (single range only).
|
|
209
|
+
*
|
|
210
|
+
* `bytes=500-999` → `{start, end}`. `bytes=-500` is the SUFFIX form and means the last
|
|
211
|
+
* 500 bytes, not the first 501 — it resolves against the object's real size, so it comes
|
|
212
|
+
* back as `{suffix}` for the storage layer to apply.
|
|
213
|
+
*
|
|
214
|
+
* @returns {{start:number, end?:number}|{suffix:number}|null}
|
|
215
|
+
*/
|
|
216
|
+
export function parseRange(header) {
|
|
217
|
+
if (!header) return null;
|
|
218
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
219
|
+
if (!m) return null;
|
|
220
|
+
if (m[1] === '') {
|
|
221
|
+
if (m[2] === '') return null; // "bytes=-" is neither form
|
|
222
|
+
const suffix = parseInt(m[2], 10);
|
|
223
|
+
return Number.isNaN(suffix) ? null : { suffix };
|
|
224
|
+
}
|
|
225
|
+
const start = parseInt(m[1], 10);
|
|
226
|
+
const end = m[2] === '' ? undefined : parseInt(m[2], 10);
|
|
227
|
+
if (Number.isNaN(start)) return null;
|
|
228
|
+
return { start, end };
|
|
229
|
+
}
|