@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
@@ -2,10 +2,13 @@
2
2
  // (bytes, range-aware) and direct part uploads (raw body). Downloads redirect to
3
3
  // a presigned URL when the backend supports it, otherwise stream through here.
4
4
 
5
+ import {
6
+ readableCollectionIds, listFor, wholeDriveFor, assertCap, describeFor, refuseGrant,
7
+ } from './scope.js';
5
8
  import { Router, json, parseRange } from './router.js';
6
9
  import {
7
10
  TroveError, assertSafePluginSql, concatBytes, metadataUrl, publicOrigin,
8
- shouldEncrypt, estimateRotationCost,
11
+ estimateRotationCost, describeRateLimits, DEFAULT_RATE_LIMITS,
9
12
  } from '@3sln/trove/core';
10
13
  import { parseContribUri, CORE_DOMAIN } from '@3sln/trove/core/plugins/identity.js';
11
14
 
@@ -29,29 +32,17 @@ async function body(req) {
29
32
  }
30
33
  }
31
34
 
32
- // Read the body as text, aborting if it exceeds `max` bytes (checks Content-Length
33
- // first, then enforces while streaming in case the header lies or is absent).
34
- async function readCapped(req, max) {
35
- const declared = Number(req.headers.get('content-length') || 0);
36
- if (declared && declared > max) throw TroveError.invalid('Request body too large');
37
- const reader = req.body?.getReader?.();
38
- if (!reader) {
39
- const text = await req.text();
40
- if (text.length > max) throw TroveError.invalid('Request body too large');
41
- return text;
42
- }
43
- const chunks = [];
44
- let total = 0;
45
- for (;;) {
46
- const { done, value } = await reader.read();
47
- if (done) break;
48
- total += value.byteLength;
49
- if (total > max) { await reader.cancel().catch(() => {}); throw TroveError.invalid('Request body too large'); }
50
- chunks.push(value);
51
- }
52
- return new TextDecoder().decode(concatBytes(chunks));
53
- }
54
- // Read a raw binary body (e.g. an uploaded plugin package), capped like readCapped —
35
+ /**
36
+ * The text of a request body, capped.
37
+ *
38
+ * Decodes what `readBytesCapped` measured, rather than counting for itself. The two were
39
+ * separate loops and the cap meant different things in each: this one compared
40
+ * `text.length` — UTF-16 code units — against a limit expressed in BYTES, so a body of
41
+ * multibyte characters could be up to three times the cap and pass.
42
+ */
43
+ const readCapped = async (req, max) => new TextDecoder().decode(await readBytesCapped(req, max));
44
+
45
+ // Read a raw binary body (e.g. an uploaded plugin package) up to `max` bytes —
55
46
  // which means enforcing WHILE streaming, not after. Checking `.byteLength` on the result
56
47
  // of `arrayBuffer()` is a check that happens once the whole body is already resident, so
57
48
  // a chunked upload with no Content-Length could park 400 MB in the heap and only then be
@@ -169,7 +160,11 @@ export function createRouter() {
169
160
  }
170
161
  });
171
162
 
172
- r.get('/api/capabilities', ['auth', 'collections', 'notifications', 'sidecar', 'vfs'], async (ctx) => {
163
+ // No `collections` in the leases: the handler reaches collections through
164
+ // `ctx.access.collection`, which comes from `leaseScope` rather than from the route
165
+ // table. A declaration that overstates what an endpoint touches is the same problem as
166
+ // one that understates it — the table exists to answer that question.
167
+ r.get('/api/capabilities', ['auth', 'notifications', 'sidecar', 'vfs'], async (ctx) => {
173
168
  const { vfs, config, sidecar, notifications, principal, query, auth, mcp } = ctx;
174
169
  // Storage is per-collection, so report the backend for the requested collection
175
170
  // (else the client picks the wrong upload strategy on a non-default collection).
@@ -200,6 +195,15 @@ export function createRouter() {
200
195
  // grammar, so it owns the prompt — a client that hardcodes "# filter by tag"
201
196
  // tells people the wrong thing the moment a different transformer is configured.
202
197
  searchPrompt: vfs.searchTransformer?.describe?.() || null,
198
+ // What this deployment will actually enforce, and at what SCOPE. `scope: 'isolate'`
199
+ // is the admission that in-memory counters on a runtime with no long-lived process
200
+ // apply per isolate — reported rather than assumed, so a deployment that cannot
201
+ // enforce a limit says so instead of appearing to. See core/rateLimit.js.
202
+ rateLimits: describeRateLimits({
203
+ enabled: true, store: 'memory', limits: DEFAULT_RATE_LIMITS,
204
+ ...(ctx.config.rateLimit || {}),
205
+ perProcess: ctx.config.startFlusher !== false,
206
+ }),
203
207
 
204
208
  // Where a refused client is sent, and where an agent connects. Both are DEPLOYMENT
205
209
  // facts — env, or a field the library caller passed — so they are reported here
@@ -232,35 +236,35 @@ export function createRouter() {
232
236
  // --- collections -----------------------------------------------------------
233
237
 
234
238
  r.get('/api/collections', ['collections'], async (ctx) => {
235
- const { collections, principal } = ctx;
236
- return { collections: await collections.list(principal), canCreate: collections.canCreate(principal) };
239
+ // `listFor`, not `list(principal)`: a key request has no principal, and the drive it
240
+ // can see is the one its scopes name. `canCreate` stays a principal question — making
241
+ // a collection is not a capability any scope can hold, so a key never can.
242
+ return { collections: await listFor(ctx), canCreate: ctx.collections.canCreate(ctx.principal) };
237
243
  });
238
244
 
239
245
  r.get('/api/collections/:id', ['collections'], async (ctx) => {
240
- const { collections, principal, params } = ctx;
241
- const c = await collections.assert(principal, params.id, 'read');
242
- return { collection: collections.describe(c, principal) };
246
+ return { collection: describeFor(ctx, await assertCap(ctx, ctx.params.id, 'read')) };
243
247
  });
244
248
 
245
249
  r.post('/api/collections', ['collections'], async (ctx) => {
246
- requireCollections(ctx);
250
+ refuseGrant(ctx, 'create collections');
247
251
  return { collection: await ctx.collections.create(await body(ctx.req), ctx.principal) };
248
252
  });
249
253
 
250
254
  r.post('/api/collections/:id', ['collections'], async (ctx) => {
251
- requireCollections(ctx);
255
+ refuseGrant(ctx, 'change a collection');
252
256
  return { collection: await ctx.collections.update(ctx.params.id, await body(ctx.req), ctx.principal) };
253
257
  });
254
258
 
255
259
  r.delete('/api/collections/:id', ['collections', 'vfs'], async (ctx) => {
256
- requireCollections(ctx);
260
+ refuseGrant(ctx, 'delete a collection');
257
261
  const { collections, vfs, principal, params } = ctx;
258
262
  // A collection record is the only thing that knows where its items' BYTES live, so
259
263
  // deleting it while items still reference it stranded every one of them: `storageFor`
260
264
  // throws "Collection not found", and since reindex walks the whole metadata store,
261
265
  // every rebuild — including the one at boot — failed on them forever, raising a
262
266
  // retryable issue whose Retry re-ran the same failure. Refuse, and say what to do.
263
- const n = await vfs.metadata.countItems?.(params.id);
267
+ const n = await vfs.metadata.countItems(params.id);
264
268
  if (n) {
265
269
  throw TroveError.conflict(
266
270
  `“${params.id}” still holds ${n.toLocaleString()} item${n === 1 ? '' : 's'}. `
@@ -281,7 +285,7 @@ export function createRouter() {
281
285
  * listing would carry it. Asking for it is an explicitly admin-gated act.
282
286
  */
283
287
  r.get('/api/collections/:id/grants', ['collections'], async (ctx) => {
284
- requireCollections(ctx);
288
+ refuseGrant(ctx, 'read a collection\u2019s access list');
285
289
  const c = await ctx.collections.assert(ctx.principal, ctx.params.id, 'admin');
286
290
  // Drive administrators come from the DEPLOYMENT (TROVE_ADMINS), not from this ACL, and
287
291
  // returning them alongside rather than inside `grants` is the honest shape: they hold
@@ -294,7 +298,7 @@ export function createRouter() {
294
298
  });
295
299
 
296
300
  r.post('/api/collections/:id/grants', ['collections'], async (ctx) => {
297
- requireCollections(ctx);
301
+ refuseGrant(ctx, 'change who can reach a collection');
298
302
  return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };
299
303
  });
300
304
 
@@ -351,7 +355,7 @@ export function createRouter() {
351
355
  // `stats` describes the COLLECTION; `items` is one page of it. Without this the
352
356
  // client can only report the page it happens to be holding, which on a drive with
353
357
  // more items than fit in a page is simply a wrong number on screen.
354
- const stats = await vfs.metadata.collectionStats?.(collectionId).catch(() => null) ?? null;
358
+ const stats = await vfs.metadata.collectionStats(collectionId).catch(() => null) ?? null;
355
359
  // Space left on the backing store, when it can say. Null for object stores, which
356
360
  // have no such number — and a UI that showed a made-up gauge for S3 would be worse
357
361
  // than one that shows nothing.
@@ -397,6 +401,10 @@ export function createRouter() {
397
401
  });
398
402
 
399
403
  // --- download (presign redirect or range-aware proxy) ----------------------
404
+ //
405
+ // `cost: 'download'`, and generously: a media player range-requests one file many
406
+ // times, which is one file's worth of bandwidth rather than many. The limit is there
407
+ // to bound a loop, not to bound watching a video.
400
408
 
401
409
  r.get('/api/items/download', [], async (ctx) => {
402
410
  const { query, req } = ctx;
@@ -415,9 +423,9 @@ export function createRouter() {
415
423
 
416
424
  // Ranged requests must proxy (we can't add Range to a bare redirect safely
417
425
  // for all clients), so only redirect for full-file GETs.
418
- if (!range) {
419
- const d = await node.download({ download: attach });
420
- if (d.mode === 'redirect') return Response.redirect(d.url, 302);
426
+ if (!range && await node.canRedirect()) {
427
+ const { url } = await node.mintUrl({ op: 'download', download: attach });
428
+ return Response.redirect(url, 302);
421
429
  }
422
430
 
423
431
  const { stream, size, contentType, etag, range: served } = await node.read({ range });
@@ -435,7 +443,7 @@ export function createRouter() {
435
443
  return new Response(stream, { status: 206, headers });
436
444
  }
437
445
  return new Response(stream, { status: 200, headers });
438
- });
446
+ }, { cost: 'download' });
439
447
 
440
448
  // Mint URLs that carry their own authorization, for the things that cannot send a
441
449
  // header. Batched on purpose: a gallery draws hundreds of tiles, and per-object
@@ -466,6 +474,10 @@ export function createRouter() {
466
474
 
467
475
  // --- uploads ---------------------------------------------------------------
468
476
 
477
+ // Metered at NEGOTIATION rather than per part: one session is one file, which is the
478
+ // unit `maxUploadBytes` is also about, and metering parts would punish a large file for
479
+ // being large rather than a caller for being greedy. Encrypted collections proxy BOTH
480
+ // directions since the drive seals, so this is the more expensive it has ever been.
469
481
  r.post('/api/collections/:collection/uploads', ['collections', 'vfs'], async (ctx) => {
470
482
  const b = await body(ctx.req);
471
483
  if (!b.name) throw TroveError.invalid('name is required');
@@ -484,7 +496,7 @@ export function createRouter() {
484
496
  name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
485
497
  overwrite: b.overwrite === true,
486
498
  }));
487
- });
499
+ }, { cost: 'upload' });
488
500
 
489
501
  // An upload spans several requests keyed only by an unguessable id, so each one
490
502
  // re-obtains the handle — which re-asserts `write` on the session's collection. A
@@ -549,8 +561,11 @@ export function createRouter() {
549
561
  });
550
562
  return { query: query.q, results };
551
563
  };
552
- r.get('/api/search', ['collections', 'vfs'], searchHandler);
553
- r.get('/api/collections/:collection/search', ['collections', 'vfs'], searchHandler);
564
+ // `cost: 'search'` on a deployment with TROVE_EMBEDDINGS_URL set, every one of these
565
+ // is a paid call to a third party. It is the one place an attacker spends the operator's
566
+ // money rather than their own CPU. See core/rateLimit.js.
567
+ r.get('/api/search', ['collections', 'vfs'], searchHandler, { cost: 'search' });
568
+ r.get('/api/collections/:collection/search', ['collections', 'vfs'], searchHandler, { cost: 'search' });
554
569
 
555
570
  // Unified query: a raw user string is run through the search transformer (default
556
571
  // parses `#tag` syntax; a plugged-in one may use an LLM), then dispatched. Returns
@@ -569,7 +584,7 @@ export function createRouter() {
569
584
  });
570
585
  return { query: b.q, results, resolved };
571
586
  };
572
- r.post('/api/query', ['collections', 'vfs'], queryHandler);
587
+ r.post('/api/query', ['collections', 'vfs'], queryHandler, { cost: 'search' });
573
588
  r.post('/api/collections/:collection/query', ['collections', 'vfs'], queryHandler);
574
589
 
575
590
  // Drive-wide tag/property filter (the launcher's `#tag` / `#key:op:value`).
@@ -582,8 +597,8 @@ export function createRouter() {
582
597
  });
583
598
  return { items };
584
599
  };
585
- r.post('/api/tags/search', ['collections', 'vfs'], tagSearchHandler);
586
- r.post('/api/collections/:collection/tags/search', ['collections', 'vfs'], tagSearchHandler);
600
+ r.post('/api/tags/search', ['collections', 'vfs'], tagSearchHandler, { cost: 'search' });
601
+ r.post('/api/collections/:collection/tags/search', ['collections', 'vfs'], tagSearchHandler, { cost: 'search' });
587
602
 
588
603
  r.get('/api/indexers', ['vfs'], ({ vfs }) => ({ indexers: vfs.indexers.list() }));
589
604
 
@@ -700,7 +715,7 @@ export function createRouter() {
700
715
  // exists alongside the scheduled one: a bucket policy may legitimately name a single
701
716
  // origin, and the origin that matters is the one browsers are actually using to reach
702
717
  // the drive. A cron firing can only fall back to a configured TROVE_PUBLIC_URL.
703
- r.post('/api/diagnostics/storage', ['collections', 'issues', 'storageCheck'], async (ctx) => {
718
+ r.post('/api/diagnostics/storage', ['collections', 'storageCheck'], async (ctx) => {
704
719
  await requireWholeDrive(ctx, 'check the backing stores');
705
720
  return ctx.storageCheck.run({ origin: publicOrigin(ctx.req, ctx.config) });
706
721
  });
@@ -718,7 +733,7 @@ export function createRouter() {
718
733
  await ctx.access.collection(collectionId, 'admin');
719
734
  requireHumanAdmin(ctx, 'estimate a key rotation');
720
735
  const record = await ctx.collections.get(collectionId);
721
- const stats = await ctx.vfs.metadata.collectionStats?.(collectionId).catch(() => null);
736
+ const stats = await ctx.vfs.metadata.collectionStats(collectionId).catch(() => null);
722
737
  return estimateRotationCost(
723
738
  { driver: record.store?.driver, endpoint: record.store?.endpoint || record.store?.s3?.endpoint },
724
739
  { objects: stats?.items ?? 0, bytes: stats?.bytes ?? 0 },
@@ -743,7 +758,7 @@ export function createRouter() {
743
758
  // hours and holding the request open for it would just time out.
744
759
  const state = await ctx.rotation.begin(collectionId, ctx.principal);
745
760
  return { rotation: state };
746
- });
761
+ }, { cost: 'job' });
747
762
 
748
763
  r.delete('/api/collections/:collection/rotate', ['collections', 'rotation'], async (ctx) => {
749
764
  const collectionId = scopedCollection(ctx);
@@ -757,9 +772,10 @@ export function createRouter() {
757
772
  // Rebuild the search index on demand. Admin-only: it re-reads every object in the
758
773
  // drive, so it is a real load, and it is drive-wide rather than scoped to anything
759
774
  // the caller owns. Returns the task, which is how the caller watches it.
775
+ // `cost: 'job'` — admin-gated is not rate-limited, and an admin key that leaks is
776
+ // otherwise a way to make the drive scan and re-index forever.
760
777
  r.post('/api/reindex', ['backgroundWork', 'collections', 'tasks'], async (ctx) => {
761
778
  await requireWholeDrive(ctx, 'rebuild the search index');
762
- if (!ctx.backgroundWork) throw TroveError.unsupported('Reindexing is not available on this deployment');
763
779
  // Two concurrent full rebuilds would double the work to reach the same place, so
764
780
  // `beginReindex` claims the drive first and says whether it got it. The claim is
765
781
  // shared state rather than this process's task list — the other rebuild may be in
@@ -770,7 +786,7 @@ export function createRouter() {
770
786
  return { task: local || null, alreadyRunning: true };
771
787
  }
772
788
  return { task };
773
- });
789
+ }, { cost: 'job' });
774
790
 
775
791
  // --- trash -----------------------------------------------------------------
776
792
  // Deleting moves an item here rather than destroying it. Everything below needs
@@ -814,7 +830,6 @@ export function createRouter() {
814
830
  // collection, because a scan can create items in it.
815
831
  r.post('/api/collections/:id/scan', ['backgroundWork', 'tasks'], async (ctx) => {
816
832
  await ctx.access.collection(ctx.params.id, 'write');
817
- if (!ctx.backgroundWork) throw TroveError.unsupported('Scanning is not available on this deployment');
818
833
  const { task, alreadyRunning } = await ctx.backgroundWork.beginScan(ctx.params.id, { reason: 'Started manually' });
819
834
  if (alreadyRunning) {
820
835
  const local = (await ctx.tasks.list())
@@ -822,7 +837,7 @@ export function createRouter() {
822
837
  return { task: local || null, alreadyRunning: true };
823
838
  }
824
839
  return { task };
825
- });
840
+ }, { cost: 'job' });
826
841
 
827
842
  // --- identity --------------------------------------------------------------
828
843
 
@@ -836,7 +851,7 @@ export function createRouter() {
836
851
  // which UI the client offers — the routes enforce regardless — but a `collections`
837
852
  // that went missing would tell every visitor they were an administrator, which is
838
853
  // a worse lie than an error.
839
- admin: collectionsEnabled(ctx) ? ctx.collections.isAdmin(ctx.principal) : !!ctx.principal,
854
+ admin: ctx.collections.isAdmin(ctx.principal),
840
855
  }));
841
856
 
842
857
  // --- conversations, tags, sidecar (per file) -------------------------------
@@ -957,7 +972,7 @@ export function createRouter() {
957
972
  const bytes = await readBytesCapped(req, plugins.maxPackageBytes || 32 * 1024 * 1024);
958
973
  const grants = query.grants ? String(query.grants).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
959
974
  return { install: await plugins.install({ principal, bytes, grants }) };
960
- });
975
+ }, { cost: 'install' });
961
976
 
962
977
  // List this account's server-installed plugins (for cross-device sync).
963
978
  r.get('/api/plugins/installed', ['plugins'], async ({ plugins, principal }) => {
@@ -1140,69 +1155,15 @@ async function assertContributorOwned(ctx, contributorId) {
1140
1155
  await ctx.plugins.assertCapability(ctx.principal, parsed.pluginId, 'indexer');
1141
1156
  }
1142
1157
 
1143
- /**
1144
- * The collections this caller may read, optionally narrowed to one they asked for.
1145
- * `undefined` when collections are disabled, which means "don't scope" downstream.
1146
- *
1147
- * Every drive-wide query needs this, and it has to be applied INSIDE the query rather
1148
- * than by filtering results: a LIMIT spent on rows the caller can't see would report
1149
- * "no matches" while matches they can see sit just past the cut.
1150
- */
1151
- async function readableCollectionIds(ctx, narrowTo) {
1152
- if (!collectionsEnabled(ctx)) return undefined;
1153
- // A NAMED collection is asserted, not filtered. Filtering an unreadable id out of the
1154
- // list answers "no results" for a collection the caller may not see — indistinguishable
1155
- // from one that is simply empty, so a permissions problem reads as an indexing problem.
1156
- // `access.collection` throws the 403 that says what actually happened.
1157
- if (narrowTo) {
1158
- await ctx.access.collection(narrowTo, 'read');
1159
- return [narrowTo];
1160
- }
1161
- return (await ctx.collections.list(ctx.principal)).map((c) => c.id);
1162
- }
1163
-
1164
- /**
1165
- * Whether this deployment has an ACL layer at all.
1166
- *
1167
- * Read from configuration, not from whether `ctx.collections` is truthy. The two
1168
- * agree when everything is wired correctly, and diverge exactly when it is not —
1169
- * and a security check that stands down because a service is missing is one that
1170
- * stops enforcing at the worst possible moment. Configuration says whether to
1171
- * enforce; the service does the enforcing, and if it is absent this throws.
1172
- */
1173
- const collectionsEnabled = (ctx) => ctx.config?.collections !== false;
1174
-
1175
- /**
1176
- * Managing collections is only meaningful where there is an ACL layer to manage.
1177
- *
1178
- * From config, not from `ctx.collections` being null — the two agree today only
1179
- * because the provider derives one from the other, and a build failure would make
1180
- * "Collections are not enabled" a lie about a drive that has them.
1181
- */
1182
- function requireCollections(ctx) {
1183
- if (!collectionsEnabled(ctx)) throw TroveError.unsupported('Collections are not enabled');
1184
- }
1185
-
1186
- async function assertCap(ctx, collectionId, capability) {
1187
- if (!collectionsEnabled(ctx)) return; // no ACL layer configured
1188
- await ctx.collections.assert(ctx.principal, collectionId, capability);
1189
- }
1190
-
1191
1158
  /**
1192
1159
  * Gate an operation that acts on the whole drive rather than on anything the caller
1193
1160
  * owns — rebuilding the index, cancelling someone else's task. See
1194
1161
  * CollectionService.hasWholeDrive for why this isn't plain `isAdmin`.
1195
1162
  */
1196
1163
  async function requireWholeDrive(ctx, what) {
1197
- const allowed = collectionsEnabled(ctx)
1198
- ? await ctx.collections.hasWholeDrive(ctx.principal)
1199
- : !!ctx.principal;
1200
- if (!allowed) throw TroveError.forbidden(`You do not have permission to ${what}`);
1164
+ if (!(await wholeDriveFor(ctx))) throw TroveError.forbidden(`You do not have permission to ${what}`);
1201
1165
  }
1202
- const canWholeDrive = (ctx) =>
1203
- (collectionsEnabled(ctx)
1204
- ? ctx.collections.hasWholeDrive(ctx.principal)
1205
- : Promise.resolve(!!ctx.principal));
1166
+ const canWholeDrive = (ctx) => wholeDriveFor(ctx);
1206
1167
 
1207
1168
  /**
1208
1169
  * Who may act on an issue: whoever may act on the thing it is about.
@@ -1237,7 +1198,7 @@ function requireHumanAdmin(ctx, action) {
1237
1198
  throw TroveError.forbidden(`An API key cannot ${action} — sign in as an administrator`);
1238
1199
  }
1239
1200
  requirePrincipal(ctx.principal);
1240
- const isAdmin = collectionsEnabled(ctx) ? ctx.collections.isAdmin(ctx.principal) : !!ctx.principal;
1201
+ const isAdmin = ctx.collections.isAdmin(ctx.principal);
1241
1202
  if (!isAdmin) throw TroveError.forbidden(`You need to be an administrator to ${action}`);
1242
1203
  }
1243
1204
 
@@ -9,6 +9,15 @@
9
9
  // Both request surfaces need it. The HTTP router obviously; MCP just as much, because
10
10
  // an agent holding Alice's token is Alice and there must be no MCP-shaped way around
11
11
  // the ACL. Written once so the two cannot drift.
12
+ //
13
+ // The same goes for WHO IS ASKING and what that subject may reach. Those helpers lived in
14
+ // routes.js, and mcp/tools.js grew its own copies — which drifted, in the direction that
15
+ // surface makes worst: MCP filtered a named collection out of the readable list, so an
16
+ // agent asking about a collection it may not see was told "No files matched… Try different
17
+ // words" and burned turns rephrasing a permissions error. mcp/index.js claims "It is not a
18
+ // second access-control system to keep in sync with the first"; it was one.
19
+
20
+ import { TroveError } from '@3sln/trove/core';
12
21
 
13
22
  /**
14
23
  * @param {object|null} container the engine container, or null where there is none
@@ -41,3 +50,72 @@ export function leaseScope(container, principal, grant = null) {
41
50
  },
42
51
  };
43
52
  }
53
+
54
+ /**
55
+ * The collections this caller may read, optionally narrowed to one they asked for.
56
+ *
57
+ * Every drive-wide query needs this, and it has to be applied INSIDE the query rather
58
+ * than by filtering results: a LIMIT spent on rows the caller can't see would report
59
+ * "no matches" while matches they can see sit just past the cut.
60
+ *
61
+ * Always a list. It used to answer `undefined` for "don't scope" on a drive with ACLs
62
+ * switched off, and there is no such drive: `collections: false` is refused by
63
+ * `configFromEnv` and again by the provider.
64
+ */
65
+ export async function readableCollectionIds(ctx, narrowTo) {
66
+ // A NAMED collection is asserted, not filtered. Filtering an unreadable id out of the
67
+ // list answers "no results" for a collection the caller may not see — indistinguishable
68
+ // from one that is simply empty, so a permissions problem reads as an indexing problem.
69
+ // `access.collection` throws the 403 that says what actually happened.
70
+ if (narrowTo) {
71
+ await ctx.access.collection(narrowTo, 'read');
72
+ return [narrowTo];
73
+ }
74
+ return (await listFor(ctx)).map((c) => c.id);
75
+ }
76
+
77
+ // --- who is asking ------------------------------------------------------------
78
+ //
79
+ // On a key request `ctx.principal` is NULL and the authority lives on `ctx.grant`, so
80
+ // handing the principal to CollectionService asks about the anonymous caller instead of
81
+ // about the key. That failed in both directions: on a locked drive a correctly-scoped
82
+ // key got `list(null) === []` and 403s from search, tags, backlinks, tasks and issues,
83
+ // while on a `defaultOpen` drive the `anyone` grant let a key scoped to one collection
84
+ // read and write every one of them through those same routes. engine/providers/access.js
85
+ // was the only place that had it right.
86
+ //
87
+ // One helper per question, so "does this surface understand API keys" has one answer
88
+ // rather than one per call site. Never a union of the two: a request bearing a key is
89
+ // the key's request, and falling back to whatever session is attached is how a weak
90
+ // credential borrows a strong one.
91
+
92
+ export const listFor = (ctx) =>
93
+ (ctx.grant ? ctx.collections.listForGrant(ctx.grant) : ctx.collections.list(ctx.principal));
94
+
95
+ export const wholeDriveFor = (ctx) =>
96
+ (ctx.grant ? ctx.collections.grantHasWholeDrive(ctx.grant) : ctx.collections.hasWholeDrive(ctx.principal));
97
+
98
+ /** Returns the collection record, so a caller that needs it does not assert twice. */
99
+ export async function assertCap(ctx, collectionId, capability) {
100
+ return ctx.grant
101
+ ? ctx.collections.assertForGrant(ctx.grant, collectionId, capability)
102
+ : ctx.collections.assert(ctx.principal, collectionId, capability);
103
+ }
104
+
105
+ /** The `describe` that matches whoever asked, so the reported capabilities are theirs. */
106
+ export const describeFor = (ctx, c) =>
107
+ (ctx.grant ? ctx.collections.describeForGrant(c, ctx.grant) : ctx.collections.describe(c, ctx.principal));
108
+
109
+ /**
110
+ * Refuse a request that arrived on an API key.
111
+ *
112
+ * For the collection-ADMINISTRATION verbs, whose authority CollectionService reads from
113
+ * the principal alone. A key request has no principal, so those methods judge it as the
114
+ * anonymous caller — allowing everything on a `defaultOpen` drive and nothing on a locked
115
+ * one. Rather than teach create/update/remove/setGrant about grants, keys stay out: a key
116
+ * that can rewrite a collection's ACL can grant itself whatever it lacks, which is the
117
+ * self-escalation shape `requireHumanAdmin` refuses for the same reason.
118
+ */
119
+ export function refuseGrant(ctx, action) {
120
+ if (ctx.grant) throw TroveError.forbidden(`An API key cannot ${action} — sign in instead`);
121
+ }