@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.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. 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
+ }