@3sln/trove 0.0.12 → 0.0.13

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 (89) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/adapters/bun.js +1 -2
  27. package/packages/server/src/adapters/node.js +1 -2
  28. package/packages/server/src/engine/index.js +1 -1
  29. package/packages/server/src/engine/providers/access.js +17 -34
  30. package/packages/server/src/engine/providers/core.js +112 -14
  31. package/packages/server/src/index.js +145 -103
  32. package/packages/server/src/mcp/index.js +7 -12
  33. package/packages/server/src/mcp/tools.js +24 -29
  34. package/packages/server/src/router.js +31 -8
  35. package/packages/server/src/routes.js +74 -113
  36. package/packages/server/src/scope.js +78 -0
  37. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  39. package/packages/web/dist/index.html +1 -1
  40. package/packages/web/dist/sw.js +1 -1
  41. package/packages/web/src/bl/actions.js +226 -104
  42. package/packages/web/src/bl/activity.js +32 -82
  43. package/packages/web/src/bl/index.js +23 -11
  44. package/packages/web/src/bl/launcher.js +2 -9
  45. package/packages/web/src/bl/links.js +31 -18
  46. package/packages/web/src/bl/offline.js +24 -10
  47. package/packages/web/src/bl/pluginInstall.js +29 -26
  48. package/packages/web/src/bl/queries.js +76 -68
  49. package/packages/web/src/bl/services.js +22 -14
  50. package/packages/web/src/bl/social.js +39 -25
  51. package/packages/web/src/bl/state.js +22 -4
  52. package/packages/web/src/bl/viewState.js +9 -0
  53. package/packages/web/src/bl/watchQuery.js +1 -8
  54. package/packages/web/src/dispatch.js +26 -0
  55. package/packages/web/src/platform/api.js +32 -0
  56. package/packages/web/src/platform/commands.js +14 -15
  57. package/packages/web/src/platform/context.js +3 -58
  58. package/packages/web/src/platform/contributions.js +1 -35
  59. package/packages/web/src/platform/fileChunks.js +281 -0
  60. package/packages/web/src/platform/index.js +8 -9
  61. package/packages/web/src/platform/mediaUrls.js +10 -0
  62. package/packages/web/src/platform/navigation.js +35 -21
  63. package/packages/web/src/platform/pickers.js +39 -19
  64. package/packages/web/src/platform/pluginHost.js +4 -1
  65. package/packages/web/src/platform/pluginRpc.js +47 -2
  66. package/packages/web/src/platform/spatialNav.js +1 -1
  67. package/packages/web/src/platform/viewport.js +14 -6
  68. package/packages/web/src/platform/voiceSearch.js +17 -9
  69. package/packages/web/src/platform/whenclause.js +9 -19
  70. package/packages/web/src/runtime.js +2 -37
  71. package/packages/web/src/ui/activate.js +18 -2
  72. package/packages/web/src/ui/components/activityPanel.js +1 -1
  73. package/packages/web/src/ui/components/adminView.js +4 -7
  74. package/packages/web/src/ui/components/editorArea.js +10 -1
  75. package/packages/web/src/ui/components/launcher.js +29 -13
  76. package/packages/web/src/ui/components/openers/index.js +17 -22
  77. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  78. package/packages/web/src/ui/components/overlays.js +31 -5
  79. package/packages/web/src/ui/components/pluginReview.js +7 -4
  80. package/packages/web/src/ui/components/pluginsView.js +1 -1
  81. package/packages/web/src/ui/components/settingsView.js +30 -27
  82. package/packages/web/src/ui/components/statusBar.js +8 -6
  83. package/packages/web/src/ui/components/views/grid.js +2 -2
  84. package/packages/web/src/ui/components/views/parts.js +1 -1
  85. package/packages/web/src/ui/compositions/workbench.js +20 -6
  86. package/packages/web/src/ui/sanitize.js +0 -5
  87. package/packages/web/src/workbench.js +37 -19
  88. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  89. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -15,7 +15,7 @@ import {
15
15
  VectorStore, KeywordStore, IndexerRegistry,
16
16
  accessHost, TroveError,
17
17
  protectedResourceMetadata, challengeHeaders, publicOrigin,
18
- diagnoseStorage, STORAGE_ISSUE_CODES,
18
+ DEFAULT_RATE_LIMITS, RATE_CLASSES, describeRateLimits,
19
19
  } from '@3sln/trove/core';
20
20
  import { createRouter, routeHelpers } from './routes.js';
21
21
  import { createDriveEngine, scanStarter, BACKBONE } from './engine/index.js';
@@ -58,6 +58,15 @@ export async function createServer(config = {}) {
58
58
  // from it and /api/capabilities describes it, so the form a user sees and the set of
59
59
  // things the server can actually construct cannot drift apart.
60
60
  config = { ...config, storageRegistry: storageRegistry(config) };
61
+ // Here, not in the adapters. Three of the four called it and the Worker one did not —
62
+ // and a default `wrangler deploy` satisfies the condition exactly: `configFromEnv`
63
+ // leaves identity anonymous when TROVE_AUTH is unset and `defaultOpen` defaults true, so
64
+ // the drive went up world-readable and world-writable on a public URL with a clean log,
65
+ // while the identical config under Bun printed the warning. No adapter can forget it
66
+ // from here. It fires per isolate cold start rather than per process, which is noisier
67
+ // only for the deployments that really are open to everyone.
68
+ warnOnOpenAccess(config);
69
+ warnOnUnenforceableLimits(config);
61
70
  const lifecycleState = { closing: false, background: null };
62
71
  const engine = createDriveEngine(config, lifecycleState);
63
72
 
@@ -68,6 +77,7 @@ export async function createServer(config = {}) {
68
77
  const {
69
78
  storage, sqlite: sqliteProvider, metadata, kv, tasks, issues, notifications,
70
79
  sidecar, collections, identity, auth, search, vfs, plugins, apiKeys, capabilities, rotation,
80
+ storageCheck, rateLimiter,
71
81
  } = backbone.resources;
72
82
 
73
83
  // Aliased so the rest of this function reads as it did; the container's
@@ -159,68 +169,12 @@ export async function createServer(config = {}) {
159
169
  const routeBeginScan = lifecycleState.background.beginScan;
160
170
  const routeBeginReindex = lifecycleState.background.beginReindex;
161
171
  issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
172
+ issues.handle('storage-check', (issue) => storageCheck.run({ origin: issue.retry?.origin || config.publicUrl || null }));
173
+ // The one retry that matters most: the user has been told a comment saved and it exists
174
+ // only in memory. The op was raised for years with no handler registered for it — and in
175
+ // a shape `canRetry` rejected, so the button never rendered to reveal that.
176
+ issues.handle('sidecar-flush', () => sidecar.retryPending());
162
177
 
163
- // --- storage self-check ----------------------------------------------------
164
- // The failure this exists for: a bucket with no CORS policy serves the server fine and
165
- // serves the browser nothing, so the drive looks healthy and every file opens to a
166
- // spinner. See core/storage/diagnose.js for why the check has to be a real preflight.
167
- //
168
- // `origin` is the browser origin to check the policy against, and there is no guessing
169
- // it: a policy may legitimately name one origin, so checking the wrong one would invent
170
- // a problem. A request supplies its own; a cron firing has only `config.publicUrl`, and
171
- // without either the CORS half is skipped rather than assumed.
172
- const STORAGE_ISSUE_KIND = 'storage';
173
- async function checkStorage({ origin = null } = {}) {
174
- // `all()`, not `list(null)`: this has no user, and asking what the anonymous principal
175
- // may read means checking nothing at all on a drive that is not public.
176
- const list = collections ? await collections.all().catch(() => []) : [];
177
- const results = [];
178
- for (const c of list) {
179
- let findings;
180
- try {
181
- const storage = await collections.storageFor(c.id);
182
- findings = await diagnoseStorage({
183
- storage, origin, driver: c.store?.driver || null, fetchImpl: config.fetch,
184
- });
185
- } catch (err) {
186
- // Failing to BUILD the store is itself the most severe version of unreachable —
187
- // an unknown driver or a config missing a required field never gets far enough
188
- // to be asked whether it can be read.
189
- findings = [{
190
- code: 'storage-unreachable',
191
- severity: 'error',
192
- title: 'This collection’s store could not be opened',
193
- detail: err?.message || String(err),
194
- }];
195
- }
196
- const found = new Set(findings.map((f) => f.code));
197
- for (const f of findings) {
198
- await issues.raise({
199
- kind: STORAGE_ISSUE_KIND,
200
- subject: `${c.id}:${f.code}`,
201
- title: `${c.name || c.id}: ${f.title}`,
202
- detail: f.detail,
203
- remedy: f.remedy || null,
204
- severity: f.severity,
205
- collectionId: c.id,
206
- // Re-running the check IS the fix verification, so Retry rechecks against the
207
- // same origin the finding was made for. Checking a different one would report
208
- // a pass for a policy the affected browser still cannot use.
209
- retry: { op: 'storage-check', origin },
210
- });
211
- }
212
- // Whatever is no longer true stops being listed. Without this, fixing the bucket
213
- // leaves the warning up, and a problem list that outlives its problems is one
214
- // people learn to scroll past.
215
- for (const code of STORAGE_ISSUE_CODES) {
216
- if (!found.has(code)) await issues.clear(STORAGE_ISSUE_KIND, `${c.id}:${code}`);
217
- }
218
- results.push({ collectionId: c.id, name: c.name || c.id, findings });
219
- }
220
- return { checked: results.length, corsChecked: !!origin, results };
221
- }
222
- issues.handle('storage-check', (issue) => checkStorage({ origin: issue.retry?.origin || config.publicUrl || null }));
223
- lifecycleState.storageCheck = checkStorage;
224
178
 
225
179
  issues.handle('reindex-node', (issue) => tasks.run(
226
180
  // Carries the issue's collection, so the person who can see the file can also see
@@ -263,31 +217,24 @@ export async function createServer(config = {}) {
263
217
  return out;
264
218
  }
265
219
 
266
- // Periodic maintenance. Both of these caches are otherwise unbounded: abandoned
267
- // upload sessions (a client that starts an upload and never finishes) accumulate in
268
- // the session store forever, and sidecar documents stay resident after their last
269
- // access. Each has a sweep that had nothing calling it — this is that caller.
220
+ // Periodic maintenance for a runtime whose timers survive the request.
221
+ //
222
+ // The BODY is `runMaintenance`, the same function the Worker's cron calls, because this
223
+ // is where the two schedulers drift. `stepRotations`'s docblock already demanded it —
224
+ // "One function, both callers, so the next thing added to periodic work cannot land in
225
+ // one scheduler and not the other" — and two things had since landed in one and not the
226
+ // other: `checkStorage`, so a bucket whose CORS or credentials changed went unnoticed on
227
+ // the runtime Trove is actually self-hosted on until someone opened Activity and pressed
228
+ // a button, and `notifications.flush()`.
229
+ //
230
+ // `scan: false` keeps the opt-in split below. Everything else — the unbounded upload
231
+ // session and sidecar caches, trash retention, mention delivery, the storage preflight,
232
+ // and finishing a rotation someone started — happens on every firing on every runtime.
270
233
  let maintenance = null;
271
234
  if (config.startFlusher !== false && config.maintenanceIntervalMs !== 0) {
272
235
  const everyMs = config.maintenanceIntervalMs ?? 5 * 60 * 1000;
273
- // Trash retention. This is the only thing in Trove that destroys data on a timer,
274
- // so it is opt-outable (TROVE_TRASH_DAYS=0 keeps the trash forever) and it says what
275
- // it removed. 30 days is the same grace period the drives people are used to give.
276
- const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
277
236
  maintenance = setInterval(() => {
278
- Promise.resolve(vfs.uploads.sweepExpired(Date.now()))
279
- // NOT `sidecar.sweep?.()` — that name did not exist on SidecarService, and the
280
- // optional call turned "evict idle documents" into a no-op for the process's
281
- // whole lifetime. It exists now, and the `?.` is gone so a rename shows up.
282
- .then(() => sidecar.sweep())
283
- .then(() => (trashMs > 0 ? vfs.purgeTrash({ before: Date.now() - trashMs }) : null))
284
- .then((r) => { if (r?.purged) console.log(`[trove] purged ${r.purged} item(s) from the trash after ${config.trashRetentionDays ?? 30} days`); })
285
- // A rotation started through the API finishes on its own here. Unlike a scan this
286
- // is NOT opt-in: it only touches collections someone has explicitly put into
287
- // rotation, and leaving one half-moved is worse than the work of finishing it.
288
- .then(() => (collections ? collections.all().catch(() => []) : []))
289
- .then((targets) => stepRotations(targets, Math.max(1000, Math.floor(everyMs / 4))))
290
- .then((moved) => { for (const m of moved) if (m.moved) console.log(`[trove] rotation ${m.collectionId}: ${m.moved} moved, ${m.status}`); })
237
+ runMaintenance({ scan: false, budgetMs: Math.max(4000, Math.floor(everyMs / 4)) })
291
238
  .catch((e) => console.error('maintenance sweep failed', e));
292
239
  }, everyMs);
293
240
  maintenance.unref?.();
@@ -298,16 +245,18 @@ export async function createServer(config = {}) {
298
245
  // metered API and real load on a NAS. A deployment that shares its bucket with other
299
246
  // tools wants this on; one where Trove is the only writer doesn't need it at all, and
300
247
  // can scan on demand instead.
248
+ //
249
+ // The same function again, with scanning on. It repeats the sweeps that the maintenance
250
+ // tick also does — all of them idempotent, and concurrent drains collapse — which is a
251
+ // smaller price than a third hand-written body of periodic work. What it is NOT allowed
252
+ // to do is pick its own collection list: this used to walk `collections.list(null)`,
253
+ // "what may the anonymous principal read", so on any drive that is not open to the
254
+ // public the scheduled scan silently scanned nothing whatsoever.
301
255
  let scanTimer = null;
302
256
  if (config.startFlusher !== false && config.scanIntervalMs) {
303
257
  scanTimer = setInterval(() => {
304
258
  if (tasks.list().some((t) => t.kind === 'scan' && t.status === 'running')) return; // still going
305
- Promise.resolve(collections ? collections.list(null).catch(() => []) : [])
306
- .then(async (list) => {
307
- for (const c of list) {
308
- await startScan(c.id, { reason: 'Scheduled' }).catch(() => {});
309
- }
310
- })
259
+ runMaintenance({ scan: true, budgetMs: Math.max(4000, Math.floor(config.scanIntervalMs / 2)) })
311
260
  .catch((e) => console.error('scheduled scan failed', e));
312
261
  }, config.scanIntervalMs);
313
262
  scanTimer.unref?.();
@@ -322,7 +271,7 @@ export async function createServer(config = {}) {
322
271
  // channel cannot shadow a built-in route by claiming its path.
323
272
  for (const channel of notifications?.channels || []) {
324
273
  for (const route of channel.routes?.(routeHelpers) || []) {
325
- router.add(route.method, route.path, route.deps || [], route.handler);
274
+ router.add(route.method, route.path, route.deps || [], route.handler, { cost: route.cost || 'write' });
326
275
  }
327
276
  }
328
277
 
@@ -340,7 +289,12 @@ export async function createServer(config = {}) {
340
289
  ? externalEvaluation({ ...config.accessEvaluation, team: config.accessEvaluation.team || config.identity?.access?.team })
341
290
  : null;
342
291
  for (const route of accessPolicy?.routes?.(routeHelpers) || []) {
343
- router.add(route.method, route.path, route.deps || [], route.handler);
292
+ // `evaluate` by default for a contributed public route: this is the one endpoint a
293
+ // stranger reaches with no credential at all, and each call is a JWKS fetch or cache
294
+ // read and an RSA verify. Refusing to ANSWER an unattributable caller is the right
295
+ // security property and does nothing about the cost of asking.
296
+ router.add(route.method, route.path, route.deps || [], route.handler,
297
+ { cost: route.cost || (route.public ? 'evaluate' : 'write') });
344
298
  if (route.public) publicPaths.add(route.path);
345
299
  }
346
300
 
@@ -352,7 +306,7 @@ export async function createServer(config = {}) {
352
306
  // MCP: the same drive, the same identity, spoken to by an agent instead of a browser.
353
307
  // Null when switched off, and then nothing below routes to it.
354
308
  const mcp = createMcpHandler({
355
- vfs, collections, identity, config, auth,
309
+ vfs, collections, identity, config, auth, rateLimiter,
356
310
  // So an agent's tool call obtains the same authorized handles an HTTP route does.
357
311
  container: engine.container,
358
312
  version: config.version || '0.0.1',
@@ -435,7 +389,13 @@ export async function createServer(config = {}) {
435
389
  // locator — nothing recorded what a route used, so nothing stopped it
436
390
  // reaching for more.
437
391
  container: engine.container,
438
- config, principal, grant, auth, mcp,
392
+ // `mcp` is the one exception, and it says so here rather than being engineered
393
+ // around: it is constructed AFTER the container, so a route that wanted it would
394
+ // need `backgroundWork`-style late binding for a single field. `auth` used to ride
395
+ // along too, and /api/capabilities — its only reader — already declares it as a
396
+ // dep, so it arrived twice by two mechanisms and the route table understated by
397
+ // one what that endpoint touches.
398
+ config, principal, grant, mcp, rateLimiter,
439
399
  });
440
400
  // A route can refuse on its own (a token that verified but names nobody we know,
441
401
  // a session that expired between calls). Whatever refused, the answer to "so
@@ -495,6 +455,11 @@ export async function createServer(config = {}) {
495
455
  const out = { swept: false, purged: 0, scans: [], notified: 0, storage: 0 };
496
456
  await vfs.uploads.sweepExpired(Date.now());
497
457
  await sidecar.sweep();
458
+ // Rate-limit buckets. One key per subject per class per window, and nothing else
459
+ // removes them — the window passing makes a bucket unreachable, not absent. The object
460
+ // guard is real (there is no limiter when limiting is off); the method is not optional,
461
+ // because both stores have one.
462
+ await rateLimiter?.store.sweep().catch(() => {});
498
463
  // Mentions are batched and drained on an interval — a timer, and a timer registered
499
464
  // during a request does not outlive it on Workers, where the adapter switches the
500
465
  // flusher off for exactly that reason. Nothing else called flush, so on that runtime
@@ -508,7 +473,7 @@ export async function createServer(config = {}) {
508
473
  // Cheap (one preflight per collection) and the only thing that will ever notice a
509
474
  // bucket policy that was fine yesterday, so it runs on every firing rather than
510
475
  // waiting for someone to open the Activity panel and press a button.
511
- out.storage = await checkStorage({ origin: config.publicUrl || null })
476
+ out.storage = await storageCheck.run({ origin: config.publicUrl || null })
512
477
  .then((r) => r.checked)
513
478
  .catch((e) => {
514
479
  console.error('[trove] storage check failed', e);
@@ -516,8 +481,14 @@ export async function createServer(config = {}) {
516
481
  });
517
482
  const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
518
483
  if (trashMs > 0) out.purged = (await vfs.purgeTrash({ before: Date.now() - trashMs }))?.purged || 0;
484
+ // Said out loud, on every runtime. This is the only thing in Trove that destroys data
485
+ // on a timer, so it is opt-outable (TROVE_TRASH_DAYS=0 keeps the trash forever) and it
486
+ // reports what it removed.
487
+ if (out.purged) {
488
+ console.log(`[trove] purged ${out.purged} item(s) from the trash after ${config.trashRetentionDays ?? 30} days`);
489
+ }
519
490
  out.swept = true;
520
- if (!scan) return out;
491
+
521
492
  // `all()` rather than `list(null)`. Maintenance has no user, and `list(null)` answers
522
493
  // "what may the anonymous principal read" — which on any drive that is not open to the
523
494
  // public is nothing, so the scheduled scan silently scanned no collection whatsoever.
@@ -528,17 +499,25 @@ export async function createServer(config = {}) {
528
499
  if (!targets.length) return out;
529
500
  // Share the budget across collections so one huge bucket can't starve the rest.
530
501
  const each = Math.max(1000, Math.floor(budgetMs / targets.length));
531
- for (const c of targets) {
532
- const r = await startScan(c.id, { reason: 'Scheduled', deadlineMs: each }).catch((e) => ({ error: e.message }));
533
- out.scans.push({ collectionId: c.id, ...r });
502
+ if (scan) {
503
+ for (const c of targets) {
504
+ const r = await startScan(c.id, { reason: 'Scheduled', deadlineMs: each }).catch((e) => ({ error: e.message }));
505
+ out.scans.push({ collectionId: c.id, ...r });
506
+ }
534
507
  }
535
508
 
536
- // A rotation that has been started finishes on its own see `stepRotations`, which
537
- // the interval scheduler shares so the two cannot drift apart.
509
+ // A rotation that has been started finishes on its own, and NOT gated on `scan`.
510
+ // Scanning is opt-in because it costs money on a bucket nobody asked us to walk; a
511
+ // rotation only touches collections someone explicitly put into rotation, and leaving
512
+ // one half-moved is worse than the work of finishing it. Tying the two together is how
513
+ // `POST /rotate` came to mint a key, report "running", and move nothing forever.
538
514
  //
539
515
  // Last, and out of what the scans left, because a rotation is elective and a scan is
540
516
  // how the drive notices files that changed underneath it.
541
517
  out.rotated = await stepRotations(targets, each);
518
+ for (const m of out.rotated) {
519
+ if (m.moved) console.log(`[trove] rotation ${m.collectionId}: ${m.moved} moved, ${m.status}`);
520
+ }
542
521
  return out;
543
522
  }
544
523
 
@@ -550,7 +529,7 @@ export async function createServer(config = {}) {
550
529
  // inside a Durable Object want. `begin*` goes wherever `config.background` says,
551
530
  // which for a front-line Worker isolate is the object rather than itself.
552
531
  startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
553
- runMaintenance, checkStorage, rotation, mcp, auth, close };
532
+ runMaintenance, checkStorage: (opts) => storageCheck.run(opts), rotation, mcp, auth, close };
554
533
  }
555
534
 
556
535
  /**
@@ -601,12 +580,42 @@ export const SAMPLE_CSP = [
601
580
  "connect-src 'self'",
602
581
  ].join('; ');
603
582
 
583
+ /**
584
+ * Say so when the limits cannot actually be enforced.
585
+ *
586
+ * In-memory counters are exact on a runtime where one process serves every request, and on
587
+ * Workers they count PER ISOLATE — so "60 a minute" becomes 60 times however many isolates
588
+ * the platform decided to run, which is not a limit. `startFlusher: false` is how the
589
+ * Worker adapter already says "timers do not survive a request here", which is the same
590
+ * fact about the same absence of a long-lived process.
591
+ *
592
+ * A warning rather than a refusal, because there is a correct answer at that layer —
593
+ * Cloudflare's own rate limiting, configured outside the app — and refusing to boot over a
594
+ * limit an operator may have handled elsewhere would be worse than saying it.
595
+ */
596
+ function warnOnUnenforceableLimits(config = {}) {
597
+ const rl = config.rateLimit;
598
+ if (!rl?.enabled || rl.store === 'kv') return;
599
+ if (config.startFlusher !== false) return; // a long-lived process; memory counters are exact
600
+ console.warn(
601
+ '[trove] Rate limits are counted IN MEMORY on a runtime with no long-lived process, so '
602
+ + 'they apply per isolate rather than per drive. Set TROVE_RATE_LIMIT_STORE=kv for one '
603
+ + 'shared budget, or configure rate limiting at the edge.',
604
+ );
605
+ }
606
+
604
607
  /**
605
608
  * Warn (once, to the console) when a configuration is world-open — anonymous auth
606
609
  * plus the default collection granting everyone every capability. Safe on
607
- * localhost, dangerous when exposed. Called by the runnable adapters at startup.
610
+ * localhost, dangerous when exposed.
611
+ *
612
+ * Called from `createServer`, so it covers every adapter that exists and every one that
613
+ * will. It was called by the adapters, and the Worker adapter — the one whose default
614
+ * deploy is world-open on a public URL — was the one that did not call it. Not exported
615
+ * for the same reason: an export is a second way to reach this, and a second way is how
616
+ * one caller came to skip it.
608
617
  */
609
- export function warnOnOpenAccess(config = {}) {
618
+ function warnOnOpenAccess(config = {}) {
610
619
  const anon = !config.identity || config.identity.driver === 'anonymous' || config.identity === 'anonymous';
611
620
  const open = config.collections !== false && config.defaultOpen !== false;
612
621
  if (anon && open) {
@@ -851,6 +860,39 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
851
860
  // Per-file upload quota (bytes). Unbounded unless set.
852
861
  if (env.TROVE_MAX_UPLOAD_BYTES) config.maxUploadBytes = Number(env.TROVE_MAX_UPLOAD_BYTES);
853
862
 
863
+ // What one caller may cost, per class of work. On by default with the generous defaults
864
+ // in core/rateLimit.js — a limit nobody turns on is not a limit, and these are chosen so
865
+ // that using the drive normally never reaches one.
866
+ //
867
+ // TROVE_RATE_LIMIT=off no limiting at all (a single-user drive on a laptop)
868
+ // TROVE_RATE_LIMIT_STORE=kv counters in the shared store, so every instance sees one
869
+ // budget. Costs a read and a write per limited request, and
870
+ // it is what a multi-instance or Workers deployment needs —
871
+ // see describeRateLimits for why memory is not enough there.
872
+ // TROVE_RATE_LIMITS='{"search":{"limit":20,"windowMs":60000}}' per-class overrides,
873
+ // merged over the defaults so naming one class keeps the rest.
874
+ config.rateLimit = { enabled: true, store: 'memory', limits: { ...DEFAULT_RATE_LIMITS } };
875
+ if (env.TROVE_RATE_LIMIT != null && /^(0|off|false|no)$/i.test(String(env.TROVE_RATE_LIMIT))) {
876
+ config.rateLimit.enabled = false;
877
+ }
878
+ if (env.TROVE_RATE_LIMIT_STORE) config.rateLimit.store = String(env.TROVE_RATE_LIMIT_STORE);
879
+ if (env.TROVE_RATE_LIMITS) {
880
+ // Refused loudly. A malformed limits document that fell back to the defaults would be
881
+ // an operator believing they had tightened something they had not.
882
+ let overrides;
883
+ try {
884
+ overrides = JSON.parse(env.TROVE_RATE_LIMITS);
885
+ } catch (err) {
886
+ throw TroveError.invalid(`TROVE_RATE_LIMITS is not valid JSON: ${err.message}`);
887
+ }
888
+ for (const [name, rule] of Object.entries(overrides)) {
889
+ if (!RATE_CLASSES.includes(name)) {
890
+ throw TroveError.invalid(`Unknown rate-limit class "${name}" — expected one of: ${RATE_CLASSES.join(', ')}`);
891
+ }
892
+ config.rateLimit.limits[name] = { ...config.rateLimit.limits[name], ...rule };
893
+ }
894
+ }
895
+
854
896
  // Deny plugin API calls with no server install record (fully closes the "any client
855
897
  // can name any pluginId" gap). Off by default for back-compat with pre-existing
856
898
  // local-only installs; flip on once clients have re-uploaded their account plugins.
@@ -17,19 +17,11 @@ import { TroveError, challengeHeaders, protectedResourceMetadata } from '@3sln/t
17
17
  import { McpServer, rpcError, JSONRPC_ERRORS } from './protocol.js';
18
18
  import { registerTroveTools } from './tools.js';
19
19
  import { mcpConfigFromEnv, mcpResourceUri } from './auth.js';
20
- import { crossSiteRefusal } from '../router.js';
20
+ import { crossSiteRefusal, corsOriginFor } from '../router.js';
21
21
  import { leaseScope } from '../scope.js';
22
22
 
23
23
  const MAX_BODY_BYTES = 1024 * 1024;
24
24
 
25
- /** The CORS origin to echo, or null for "no cross-origin access" — same rule as the API. */
26
- function allowedOrigin(configured, reqOrigin) {
27
- if (!configured) return null;
28
- if (configured === '*') return '*';
29
- const allowed = String(configured).split(',').map((s) => s.trim()).filter(Boolean);
30
- return reqOrigin && allowed.includes(reqOrigin) ? reqOrigin : null;
31
- }
32
-
33
25
  export { McpServer, mcpConfigFromEnv, mcpResourceUri };
34
26
 
35
27
  export function createMcpServer({ name = 'trove', version = '0.0.1' } = {}) {
@@ -69,7 +61,7 @@ function authRequired(cfg, identity) {
69
61
  * @param {object} deps
70
62
  * @param {object} deps.auth the drive's resolved auth discovery (see resolveAuthDiscovery)
71
63
  */
72
- export function createMcpHandler({ vfs, collections, container, identity, config = {}, auth = {}, version } = {}) {
64
+ export function createMcpHandler({ vfs, collections, container, identity, config = {}, auth = {}, version, rateLimiter = null } = {}) {
73
65
  const cfg = { path: '/mcp', enabled: true, ...mcpConfigFromEnv(config.env || {}), ...(config.mcp || {}) };
74
66
  if (cfg.enabled === false) return null;
75
67
  const server = createMcpServer({ version });
@@ -106,7 +98,7 @@ export function createMcpHandler({ vfs, collections, container, identity, config
106
98
  // unreadable to the attacker, but the deletions still happen. Agents are not
107
99
  // browsers and do not need CORS at all, so this follows the same
108
100
  // TROVE_CORS_ORIGIN allowlist the JSON API does, and stays off by default.
109
- const origin = allowedOrigin(config?.corsOrigin, req.headers.get('origin'));
101
+ const origin = corsOriginFor(config?.corsOrigin, req.headers.get('origin'));
110
102
  if (!origin) return new Response(null, { status: 204 });
111
103
  return new Response(null, {
112
104
  status: 204,
@@ -180,7 +172,10 @@ export function createMcpHandler({ vfs, collections, container, identity, config
180
172
  // node or collection HANDLE and operates through it, so there is no MCP-shaped path
181
173
  // around the collection ACL and no unrestricted `vfs` sitting in a tool body.
182
174
  const scope = leaseScope(container, principal);
183
- const ctx = { vfs, collections, principal, config, access: scope.access };
175
+ // The limiter reaches this surface too. An agent holding somebody's token can search
176
+ // as fast as it likes otherwise, and a search is the paid one — "exactly as privileged
177
+ // as the person whose token it holds" has to include how much they can spend.
178
+ const ctx = { vfs, collections, principal, config, access: scope.access, rateLimiter };
184
179
 
185
180
  try {
186
181
  // A batch is an array. Notifications inside it contribute nothing to the reply,
@@ -15,6 +15,25 @@
15
15
  import { TroveError } from '@3sln/trove/core';
16
16
  import { troveUri } from '@3sln/trove/core/links.js';
17
17
  import { toolText } from './protocol.js';
18
+ // The one implementation of "what may this caller reach", shared with the HTTP routes.
19
+ // This file had its own, and it FILTERED a named collection out of the readable list
20
+ // where routes.js asserts it — so `search_files` answered "No files matched… Try
21
+ // different words" for a collection the caller may not see, and the model burned turns
22
+ // rephrasing what was really a 403. `ctx.access` is on the MCP ctx, so the assert path
23
+ // was available all along.
24
+ import { readableCollectionIds, listFor } from '../scope.js';
25
+ import { rateSubject } from '@3sln/trove/core';
26
+
27
+ /**
28
+ * Charge this call against the caller's budget for that class of work.
29
+ *
30
+ * The HTTP routes declare a class in the route table; a tool declares it here, at the top
31
+ * of the one that costs something. A search is the expensive one — on a deployment with
32
+ * embeddings configured it is a paid third-party call — and "an agent is exactly as
33
+ * privileged as the person whose token it holds" has to include how much they can spend.
34
+ */
35
+ const meter = (ctx, className) =>
36
+ (ctx.rateLimiter ? ctx.rateLimiter.enforce(rateSubject({ principal: ctx.principal }), className) : null);
18
37
 
19
38
  // A file read has to fit in a context window and in memory. Past this the tool returns
20
39
  // the head and says so, which is far more useful than refusing or than silently
@@ -31,28 +50,6 @@ looking for something, search for it — do not try to construct a path.
31
50
  Files reference each other with trove: URIs (trove:default?name=notes.md). Search matches
32
51
  meaning as well as words, so a description of the content works as a query.`;
33
52
 
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
53
  /**
57
54
  * A file, and the operations this agent may perform on it.
58
55
  *
@@ -148,7 +145,8 @@ export function registerTroveTools(server) {
148
145
  },
149
146
  async run({ query, collection, limit }, ctx) {
150
147
  if (!query?.trim()) throw TroveError.invalid('query is required');
151
- const collectionIds = await readable(ctx, collection);
148
+ await meter(ctx, 'search');
149
+ const collectionIds = await readableCollectionIds(ctx, collection);
152
150
  const { results, resolved } = await ctx.vfs.query(query, {
153
151
  limit: Math.min(Math.max(1, limit || 10), MAX_RESULTS),
154
152
  collectionIds,
@@ -292,10 +290,7 @@ export function registerTroveTools(server) {
292
290
  + 'division of the drive — the closest thing here to a folder, except they do not nest.',
293
291
  inputSchema: { type: 'object', properties: {} },
294
292
  async run(_args, ctx) {
295
- if (!enforcing(ctx)) {
296
- return toolText(JSON.stringify({ collections: [{ id: 'default', capabilities: ['read', 'write', 'delete'] }] }, null, 2));
297
- }
298
- const list = await ctx.collections.list(ctx.principal);
293
+ const list = await listFor(ctx);
299
294
  return toolText(JSON.stringify({ collections: list }, null, 2), { structured: { collections: list } });
300
295
  },
301
296
  });
@@ -322,7 +317,7 @@ export function registerTroveTools(server) {
322
317
  // Scoped, exactly like the HTTP route. Backlinks reach ACROSS collections by
323
318
  // design — that is what makes them useful — so an unscoped query hands back the
324
319
  // names, ids and trove: URIs of files inside collections the caller cannot read.
325
- const collectionIds = await readable(ctx);
320
+ const collectionIds = await readableCollectionIds(ctx);
326
321
  const backlinks = await handle.backlinks({ limit: 20, collectionIds })
327
322
  // Distinguishable from "nothing links here", which in a drive with no folders is
328
323
  // a load-bearing fact an agent will reason from.
@@ -340,7 +335,7 @@ export function registerTroveTools(server) {
340
335
  // something invented for MCP — one name for a file across the whole system.
341
336
  server.resources({
342
337
  async list(params, ctx) {
343
- const ids = await readable(ctx);
338
+ const ids = await readableCollectionIds(ctx);
344
339
  const out = [];
345
340
  for (const cid of ids || ['default']) {
346
341
  const page = await (await ctx.access.collection(cid, 'read')).list({ limit: 100 });
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { TroveError, wrapError, ErrorCode, publicOrigin } from '@3sln/trove/core';
9
9
  import { leaseScope } from './scope.js';
10
+ import { rateSubject } from '@3sln/trove/core';
10
11
 
11
12
  // Methods that change state. A GET is safe by definition, so it isn't checked.
12
13
  const UNSAFE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
@@ -65,6 +66,9 @@ export class Router {
65
66
  * @param {string[]|Function} depsOrHandler the resources this route needs, by
66
67
  * name — or the handler, for a route that needs none.
67
68
  * @param {Function} [maybeHandler]
69
+ * @param {{cost?: string}} [opts] which class of work this is, for rate limiting — see
70
+ * core/rateLimit.js. A route that names none is not metered, which is the right answer
71
+ * for the cheap reads the shell issues constantly.
68
72
  *
69
73
  * Declaring dependencies is the point. Every handler used to receive one
70
74
  * object carrying the whole server: vfs, collections, kv, sqlite, plugins,
@@ -74,17 +78,17 @@ export class Router {
74
78
  * reading it. Named here, the answer is in the route table, and a route that
75
79
  * did not ask for `plugins` does not get `plugins`.
76
80
  */
77
- add(method, pattern, depsOrHandler, maybeHandler) {
81
+ add(method, pattern, depsOrHandler, maybeHandler, opts = {}) {
78
82
  const handler = maybeHandler ?? depsOrHandler;
79
83
  const deps = maybeHandler ? depsOrHandler : [];
80
84
  const segs = pattern.split('/').filter(Boolean);
81
- this.routes.push({ method, segs, handler, deps });
85
+ this.routes.push({ method, segs, handler, deps, cost: opts.cost || null });
82
86
  return this;
83
87
  }
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
+ get(p, d, h, o) { return this.add('GET', p, d, h, o); }
89
+ post(p, d, h, o) { return this.add('POST', p, d, h, o); }
90
+ put(p, d, h, o) { return this.add('PUT', p, d, h, o); }
91
+ delete(p, d, h, o) { return this.add('DELETE', p, d, h, o); }
88
92
 
89
93
  #match(method, pathname) {
90
94
  const parts = pathname.split('/').filter(Boolean);
@@ -142,6 +146,15 @@ export class Router {
142
146
  const scope = leaseScope(ctx.container, ctx.principal, ctx.grant);
143
147
  const access = scope.access;
144
148
  try {
149
+ // Before the handler, before the lease, and before any work: the point of a limit is
150
+ // that the expensive thing does not happen. `rateLimiter` is null when limiting is
151
+ // off, and a route that named no class is not metered at all.
152
+ if (found.route.cost && ctx.rateLimiter) {
153
+ await ctx.rateLimiter.enforce(
154
+ rateSubject({ grant: ctx.grant, principal: ctx.principal, req, trustProxy: ctx.config?.trustProxy }),
155
+ found.route.cost,
156
+ );
157
+ }
145
158
  lease = ctx.container ? await ctx.container.lease(found.route.deps) : null;
146
159
  const result = await found.route.handler({
147
160
  req, params: found.params, query, url, access, ...ctx, ...(lease?.resources || {}),
@@ -151,7 +164,13 @@ export class Router {
151
164
  } catch (raw) {
152
165
  const err = raw instanceof TroveError ? raw : wrapError(raw);
153
166
  if (err.code === ErrorCode.INTERNAL) console.error('Unhandled:', err.cause || err);
154
- return cors(json(err.toJSON(), err.status), origin);
167
+ // A 429 without a `Retry-After` makes every client guess, and a client that guesses
168
+ // wrong either hammers or waits far too long. The limiter knows exactly when the
169
+ // window ends, so it says so — in SECONDS, rounded up, which is what RFC 9110 wants.
170
+ const headers = err.details?.retryAfterMs != null
171
+ ? { 'retry-after': String(Math.ceil(err.details.retryAfterMs / 1000)) }
172
+ : {};
173
+ return cors(json(err.toJSON(), err.status, headers), origin);
155
174
  } finally {
156
175
  await scope.release();
157
176
  await lease?.release();
@@ -162,7 +181,11 @@ export class Router {
162
181
  // Resolve the Access-Control-Allow-Origin value: null (no CORS) unless configured.
163
182
  // '*' echoes '*'; a configured origin is echoed only when the request matches it
164
183
  // (so credentials-mode requests get a specific origin, not a wildcard).
165
- function corsOriginFor(configured, reqOrigin) {
184
+ //
185
+ // Exported because the MCP endpoint needs the same answer. It kept its own copy, and two
186
+ // parsers of one allowlist is how the API and the agent endpoint come to disagree about
187
+ // which origins are trusted — mcp/index.js already imports `crossSiteRefusal` from here.
188
+ export function corsOriginFor(configured, reqOrigin) {
166
189
  if (!configured) return null;
167
190
  if (configured === '*') return '*';
168
191
  const allowed = String(configured).split(',').map((s) => s.trim()).filter(Boolean);