@3sln/trove 0.0.4 → 0.0.7

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 (41) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/packages/core/src/apiKeys.js +326 -0
  4. package/packages/core/src/collections/index.js +83 -13
  5. package/packages/core/src/index.js +14 -2
  6. package/packages/core/src/issues.js +4 -0
  7. package/packages/core/src/storage/diagnose.js +234 -0
  8. package/packages/core/src/storage/drivers.js +83 -0
  9. package/packages/core/src/storage/filesystem.js +22 -0
  10. package/packages/core/src/storage/registry.js +162 -0
  11. package/packages/server/src/adapters/bun.js +6 -0
  12. package/packages/server/src/adapters/node.js +6 -0
  13. package/packages/server/src/engine/index.js +1 -1
  14. package/packages/server/src/engine/providers/access.js +47 -5
  15. package/packages/server/src/engine/providers/core.js +105 -11
  16. package/packages/server/src/index.js +123 -11
  17. package/packages/server/src/mcp/tools.js +40 -8
  18. package/packages/server/src/router.js +1 -1
  19. package/packages/server/src/routes.js +135 -32
  20. package/packages/server/src/scope.js +2 -2
  21. package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
  22. package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
  23. package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
  24. package/packages/web/dist/index.html +2 -2
  25. package/packages/web/dist/sw.js +58 -9
  26. package/packages/web/src/bl/actions.js +112 -13
  27. package/packages/web/src/bl/activity.js +32 -0
  28. package/packages/web/src/bl/commands.js +41 -2
  29. package/packages/web/src/bl/index.js +9 -4
  30. package/packages/web/src/bl/services.js +78 -1
  31. package/packages/web/src/platform/api.js +57 -14
  32. package/packages/web/src/platform/pluginRpc.js +7 -4
  33. package/packages/web/src/styles.css +137 -0
  34. package/packages/web/src/ui/components/activityPanel.js +28 -1
  35. package/packages/web/src/ui/components/collectionGate.js +81 -0
  36. package/packages/web/src/ui/components/overlays.js +64 -34
  37. package/packages/web/src/ui/components/phoneChrome.js +2 -2
  38. package/packages/web/src/ui/components/settingsView.js +197 -1
  39. package/packages/web/src/ui/components/statusBar.js +19 -2
  40. package/packages/web/src/ui/compositions/workbench.js +9 -2
  41. package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
@@ -21,7 +21,8 @@
21
21
  // means the same thing it always did.
22
22
 
23
23
  import {
24
- StorageBackend, MemoryStorage, FilesystemStorage, S3Storage,
24
+ StorageBackend, MemoryStorage, S3Storage,
25
+ StorageDriverRegistry, portableDrivers,
25
26
  MetadataStore, MemoryStore, SqliteStore,
26
27
  SearchService, EmbeddingProvider, LocalHashEmbedding, HttpEmbedding,
27
28
  SearchTransformer, ParsingSearchTransformer, WorkersAiSearchTransformer,
@@ -33,6 +34,7 @@ import {
33
34
  KeyValueStore, MemoryKV, SqliteKV,
34
35
  SqliteProvider, LocalSqliteProvider,
35
36
  SidecarService, NotificationCenter, WebPushService, WebPushChannel, NotificationChannel,
37
+ ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
36
38
  CollectionService,
37
39
  PluginService, PackageStore, StoragePackageStore, SqlitePluginInstallStore,
38
40
  IndexerRuntime, InProcessIndexerRuntime, PluginIndexers,
@@ -49,12 +51,65 @@ import { need } from '../lazy.js';
49
51
  const resolve = (value, BaseClass, build) =>
50
52
  (value instanceof BaseClass ? value : build(value || {}));
51
53
 
52
- export function buildStorage(cfg) {
53
- switch (cfg.driver) {
54
- case 's3': return new S3Storage(cfg.s3);
55
- case 'filesystem': return new FilesystemStorage({ root: cfg.root });
56
- case 'memory': default: return new MemoryStorage();
54
+ /**
55
+ * The drivers this deployment can build, as a registry.
56
+ *
57
+ * `config.storageDrivers` either IS a registry or is a list of drivers to add to the
58
+ * portable ones so a deployment adds Filesystem (Node/Bun), or a driver written
59
+ * entirely outside this package, by naming it at the entry point. What is not registered
60
+ * is not offered and cannot be built.
61
+ *
62
+ * `config.allowedStorageDrivers` (TROVE_STORAGE_DRIVERS) narrows the result to an explicit
63
+ * set. Adding drivers is a code decision made by an entry point, which knows what the
64
+ * runtime can run; REMOVING them is an operator decision about one deployment, and needs
65
+ * to be reachable from configuration alone. A drive on Workers is the case in point: memory
66
+ * is portable, so it is offered, and choosing it there produces a collection that accepts
67
+ * uploads and loses them when the isolate is recycled. `TROVE_STORAGE_DRIVERS=s3` takes it
68
+ * off the menu without a fork of the entry point.
69
+ *
70
+ * A name that matches nothing throws rather than narrowing to nothing — a typo that left a
71
+ * drive with no way to make a collection would be a puzzle, not a message.
72
+ */
73
+ export function storageRegistry(config = {}) {
74
+ if (config.storageRegistry instanceof StorageDriverRegistry) return config.storageRegistry;
75
+ if (config.storageDrivers instanceof StorageDriverRegistry) return config.storageDrivers;
76
+ const registry = new StorageDriverRegistry(portableDrivers());
77
+ for (const d of config.storageDrivers || []) registry.register(d);
78
+
79
+ const allowed = config.allowedStorageDrivers;
80
+ if (!allowed?.length) return registry;
81
+ const unknown = allowed.filter((k) => !registry.has(k));
82
+ if (unknown.length) {
83
+ throw TroveError.invalid(
84
+ `TROVE_STORAGE_DRIVERS names ${unknown.map((k) => `"${k}"`).join(', ')}, which `
85
+ + `${unknown.length === 1 ? 'is not a driver' : 'are not drivers'} this deployment has: `
86
+ + `${registry.keys().join(', ')}`,
87
+ );
57
88
  }
89
+ // Rebuilt from the descriptors that survived rather than mutated, so a registry never
90
+ // has to support removal — and the drivers keep their registration order.
91
+ const narrowed = new StorageDriverRegistry();
92
+ for (const key of registry.keys()) {
93
+ if (allowed.includes(key)) narrowed.register(registry.driver(key));
94
+ }
95
+ return narrowed;
96
+ }
97
+
98
+ /**
99
+ * Build a backend from a store config.
100
+ *
101
+ * The `default:` arm this replaces returned MemoryStorage, so a typo'd driver produced a
102
+ * store that took writes and lost them at the next restart. Unknown drivers now throw and
103
+ * say what is available.
104
+ */
105
+ export function buildStorage(cfg, config = {}) {
106
+ // ABSENT is not the same as WRONG, and conflating them is what the old `default:` arm
107
+ // did. No storage configured at all is the zero-config path — `createServer()` with
108
+ // nothing, which is ephemeral by definition — so it gets memory. A driver that was
109
+ // NAMED and is not registered is a mistake, and throws: that is the case where a typo
110
+ // used to buy you a store that took writes and lost them.
111
+ if (!cfg?.driver) return new MemoryStorage();
112
+ return storageRegistry(config).build(cfg);
58
113
  }
59
114
 
60
115
  function buildIdentity(cfg) {
@@ -141,8 +196,15 @@ export function coreProviders(config, lifecycleState) {
141
196
  beginReindex: (opts) => lifecycleState.background.beginReindex(opts),
142
197
  }),
143
198
 
199
+ // The storage self-check, late-bound for the same reason: it needs `collections` and
200
+ // `issues` from this container, so it is assembled in createServer and reached back
201
+ // into rather than built here.
202
+ storageCheck: Provider.fromSingleton({
203
+ run: (opts) => lifecycleState.storageCheck(opts),
204
+ }),
205
+
144
206
  storage: Provider.fromLazySingleton(
145
- () => resolve(config.storage ?? config.vfs?.storage, StorageBackend, buildStorage),
207
+ () => resolve(config.storage ?? config.vfs?.storage, StorageBackend, (cfg) => buildStorage(cfg, config)),
146
208
  ),
147
209
 
148
210
  // One shared SQLite provider (a keyed pool) for metadata, kv, and per-plugin
@@ -310,6 +372,30 @@ export function coreProviders(config, lifecycleState) {
310
372
  { deps: ['kv', 'push'] },
311
373
  ),
312
374
 
375
+ // The API key store. Keys grant capabilities and no identity — see core/apiKeys.js.
376
+ apiKeys: Provider.fromLazySingleton(
377
+ async (deps) => {
378
+ const { kv } = await need(deps, ['kv']);
379
+ return resolve(config.apiKeys, ApiKeyService, () => new ApiKeyService({ kv }));
380
+ },
381
+ null,
382
+ { deps: ['kv'] },
383
+ ),
384
+
385
+ // How a credential becomes a capability grant. The counterpart to `identity`, and
386
+ // separate from it on purpose: some credentials answer "what may this do" without
387
+ // answering "who is this". Swap it to authorize from something else — a client
388
+ // certificate, a signed webhook, a service mesh header.
389
+ capabilities: Provider.fromLazySingleton(
390
+ async (deps) => {
391
+ const { apiKeys } = await need(deps, ['apiKeys']);
392
+ return resolve(config.capabilities, CapabilityProvider,
393
+ () => new ApiKeyCapabilityProvider({ apiKeys }));
394
+ },
395
+ null,
396
+ { deps: ['apiKeys'] },
397
+ ),
398
+
313
399
  notifications: Provider.fromLazySingleton(
314
400
  async (deps) => {
315
401
  const { kv, notificationChannels } = await need(deps, ['kv', 'notificationChannels']);
@@ -340,15 +426,23 @@ export function coreProviders(config, lifecycleState) {
340
426
  ),
341
427
 
342
428
  // The ownership + permission boundary; each collection is a store config.
343
- // `config.collections === false` disables it (single open storage, no ACLs),
344
- // and the resource is then null a provider is allowed to provide nothing.
429
+ // There is no "off" any more. Every collection-scoped endpoint names its collection
430
+ // in the path, so a drive with no collection layer has nothing to answer with — and
431
+ // the ACL check standing down because the service is absent was the failure mode this
432
+ // graph was rebuilt to make impossible. Refused here as well as in configFromEnv, so
433
+ // there is one answer whichever way the config arrived.
345
434
  collections: Provider.fromLazySingleton(
346
435
  async (deps) => {
347
- if (config.collections === false) return null;
436
+ if (config.collections === false) {
437
+ throw TroveError.invalid(
438
+ 'collections: false is no longer supported — endpoints are scoped to a named '
439
+ + 'collection. Create one collection and use it.',
440
+ );
441
+ }
348
442
  const { kv, storage } = await need(deps, ['kv', 'storage']);
349
443
  return resolve(config.collections, CollectionService, () => new CollectionService({
350
444
  kv,
351
- storageFactory: (storeConfig) => buildStorage(storeConfig),
445
+ storageFactory: (storeConfig) => buildStorage(storeConfig, config),
352
446
  admins: config.admins || [],
353
447
  creatorRoles: config.creatorRoles || [],
354
448
  defaultOpen: config.defaultOpen !== false,
@@ -15,9 +15,11 @@ import {
15
15
  VectorStore, KeywordStore, IndexerRegistry,
16
16
  accessHost, TroveError,
17
17
  protectedResourceMetadata, challengeHeaders, publicOrigin,
18
+ diagnoseStorage, STORAGE_ISSUE_CODES,
18
19
  } from '@3sln/trove/core';
19
20
  import { createRouter, routeHelpers } from './routes.js';
20
21
  import { createDriveEngine, scanStarter, BACKBONE } from './engine/index.js';
22
+ import { storageRegistry } from './engine/providers/core.js';
21
23
  import { createMcpHandler } from './mcp/index.js';
22
24
  import { cacheControlFor } from './cachePolicy.js';
23
25
  import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
@@ -51,6 +53,10 @@ export async function createServer(config = {}) {
51
53
  // 200 lines of statements whose ORDER was the graph is a declaration the
52
54
  // container walks, which is also what makes `close()` stop being a hand-kept
53
55
  // list that had to agree with it.
56
+ // The driver registry is decided once, here, and shared: the providers build backends
57
+ // from it and /api/capabilities describes it, so the form a user sees and the set of
58
+ // things the server can actually construct cannot drift apart.
59
+ config = { ...config, storageRegistry: storageRegistry(config) };
54
60
  const lifecycleState = { closing: false, background: null };
55
61
  const engine = createDriveEngine(config, lifecycleState);
56
62
 
@@ -60,7 +66,7 @@ export async function createServer(config = {}) {
60
66
  const backbone = await engine.container.lease(BACKBONE);
61
67
  const {
62
68
  storage, sqlite: sqliteProvider, metadata, kv, tasks, issues, notifications,
63
- sidecar, collections, identity, auth, search, vfs, plugins,
69
+ sidecar, collections, identity, auth, search, vfs, plugins, apiKeys, capabilities,
64
70
  } = backbone.resources;
65
71
 
66
72
  // Aliased so the rest of this function reads as it did; the container's
@@ -153,6 +159,68 @@ export async function createServer(config = {}) {
153
159
  const routeBeginReindex = lifecycleState.background.beginReindex;
154
160
  issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
155
161
 
162
+ // --- storage self-check ----------------------------------------------------
163
+ // The failure this exists for: a bucket with no CORS policy serves the server fine and
164
+ // serves the browser nothing, so the drive looks healthy and every file opens to a
165
+ // spinner. See core/storage/diagnose.js for why the check has to be a real preflight.
166
+ //
167
+ // `origin` is the browser origin to check the policy against, and there is no guessing
168
+ // it: a policy may legitimately name one origin, so checking the wrong one would invent
169
+ // a problem. A request supplies its own; a cron firing has only `config.publicUrl`, and
170
+ // without either the CORS half is skipped rather than assumed.
171
+ const STORAGE_ISSUE_KIND = 'storage';
172
+ async function checkStorage({ origin = null } = {}) {
173
+ // `all()`, not `list(null)`: this has no user, and asking what the anonymous principal
174
+ // may read means checking nothing at all on a drive that is not public.
175
+ const list = collections ? await collections.all().catch(() => []) : [];
176
+ const results = [];
177
+ for (const c of list) {
178
+ let findings;
179
+ try {
180
+ const storage = await collections.storageFor(c.id);
181
+ findings = await diagnoseStorage({
182
+ storage, origin, driver: c.store?.driver || null, fetchImpl: config.fetch,
183
+ });
184
+ } catch (err) {
185
+ // Failing to BUILD the store is itself the most severe version of unreachable —
186
+ // an unknown driver or a config missing a required field never gets far enough
187
+ // to be asked whether it can be read.
188
+ findings = [{
189
+ code: 'storage-unreachable',
190
+ severity: 'error',
191
+ title: 'This collection’s store could not be opened',
192
+ detail: err?.message || String(err),
193
+ }];
194
+ }
195
+ const found = new Set(findings.map((f) => f.code));
196
+ for (const f of findings) {
197
+ await issues.raise({
198
+ kind: STORAGE_ISSUE_KIND,
199
+ subject: `${c.id}:${f.code}`,
200
+ title: `${c.name || c.id}: ${f.title}`,
201
+ detail: f.detail,
202
+ remedy: f.remedy || null,
203
+ severity: f.severity,
204
+ collectionId: c.id,
205
+ // Re-running the check IS the fix verification, so Retry rechecks against the
206
+ // same origin the finding was made for. Checking a different one would report
207
+ // a pass for a policy the affected browser still cannot use.
208
+ retry: { op: 'storage-check', origin },
209
+ });
210
+ }
211
+ // Whatever is no longer true stops being listed. Without this, fixing the bucket
212
+ // leaves the warning up, and a problem list that outlives its problems is one
213
+ // people learn to scroll past.
214
+ for (const code of STORAGE_ISSUE_CODES) {
215
+ if (!found.has(code)) await issues.clear(STORAGE_ISSUE_KIND, `${c.id}:${code}`);
216
+ }
217
+ results.push({ collectionId: c.id, name: c.name || c.id, findings });
218
+ }
219
+ return { checked: results.length, corsChecked: !!origin, results };
220
+ }
221
+ issues.handle('storage-check', (issue) => checkStorage({ origin: issue.retry?.origin || config.publicUrl || null }));
222
+ lifecycleState.storageCheck = checkStorage;
223
+
156
224
  issues.handle('reindex-node', (issue) => tasks.run(
157
225
  // Carries the issue's collection, so the person who can see the file can also see
158
226
  // the task fixing it — a task nobody is allowed to watch is not a task worth having.
@@ -197,9 +265,9 @@ export async function createServer(config = {}) {
197
265
  if (config.startFlusher !== false && config.scanIntervalMs) {
198
266
  scanTimer = setInterval(() => {
199
267
  if (tasks.list().some((t) => t.kind === 'scan' && t.status === 'running')) return; // still going
200
- Promise.resolve(collections ? collections.list(null).catch(() => []) : [{ id: 'default' }])
268
+ Promise.resolve(collections ? collections.list(null).catch(() => []) : [])
201
269
  .then(async (list) => {
202
- for (const c of list.length ? list : [{ id: 'default' }]) {
270
+ for (const c of list) {
203
271
  await startScan(c.id, { reason: 'Scheduled' }).catch(() => {});
204
272
  }
205
273
  })
@@ -281,9 +349,20 @@ export async function createServer(config = {}) {
281
349
  if (url.pathname.startsWith('/api/')) {
282
350
  // Authenticate every API request; a bad token is a clean 401, missing is
283
351
  // anonymous-or-401 per the provider's policy.
352
+ //
353
+ // A capability grant is resolved FIRST, and when one is found the identity step is
354
+ // skipped entirely. That ordering is the design: an API key answers "what may this
355
+ // request do" and deliberately does not answer "who is this", so there is no
356
+ // principal to attach and nothing downstream can mistake a key for a person.
357
+ //
358
+ // Resolving both would be worse than useless. A request bearing a weak key and a
359
+ // strong session would get the union of the two, which is the confused deputy
360
+ // reached by being accommodating — so it is one or the other, never both.
284
361
  let principal = null;
362
+ let grant = null;
285
363
  try {
286
- principal = await identity.authenticate(req);
364
+ grant = await capabilities.resolve(req);
365
+ if (!grant) principal = await identity.authenticate(req);
287
366
  } catch (err) {
288
367
  const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
289
368
  return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
@@ -295,7 +374,7 @@ export async function createServer(config = {}) {
295
374
  // locator — nothing recorded what a route used, so nothing stopped it
296
375
  // reaching for more.
297
376
  container: engine.container,
298
- config, principal, auth, mcp,
377
+ config, principal, grant, auth, mcp,
299
378
  });
300
379
  // A route can refuse on its own (a token that verified but names nobody we know,
301
380
  // a session that expired between calls). Whatever refused, the answer to "so
@@ -352,7 +431,7 @@ export async function createServer(config = {}) {
352
431
  * does, so it does as much as it can and stores where it got to.
353
432
  */
354
433
  async function runMaintenance({ budgetMs = 20_000, scan = true } = {}) {
355
- const out = { swept: false, purged: 0, scans: [], notified: 0 };
434
+ const out = { swept: false, purged: 0, scans: [], notified: 0, storage: 0 };
356
435
  await vfs.uploads.sweepExpired(Date.now());
357
436
  await sidecar.sweep();
358
437
  // Mentions are batched and drained on an interval — a timer, and a timer registered
@@ -365,12 +444,27 @@ export async function createServer(config = {}) {
365
444
  console.error('[trove] mention flush failed', e);
366
445
  return 0;
367
446
  });
447
+ // Cheap (one preflight per collection) and the only thing that will ever notice a
448
+ // bucket policy that was fine yesterday, so it runs on every firing rather than
449
+ // waiting for someone to open the Activity panel and press a button.
450
+ out.storage = await checkStorage({ origin: config.publicUrl || null })
451
+ .then((r) => r.checked)
452
+ .catch((e) => {
453
+ console.error('[trove] storage check failed', e);
454
+ return 0;
455
+ });
368
456
  const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
369
457
  if (trashMs > 0) out.purged = (await vfs.purgeTrash({ before: Date.now() - trashMs }))?.purged || 0;
370
458
  out.swept = true;
371
459
  if (!scan) return out;
372
- const list = collections ? await collections.list(null).catch(() => []) : [];
373
- const targets = list.length ? list : [{ id: 'default' }];
460
+ // `all()` rather than `list(null)`. Maintenance has no user, and `list(null)` answers
461
+ // "what may the anonymous principal read" which on any drive that is not open to the
462
+ // public is nothing, so the scheduled scan silently scanned no collection whatsoever.
463
+ // No collections means nothing to scan. It used to mean "scan the one called
464
+ // default", which on a drive that has none is a scan of a collection that does not
465
+ // exist — work that fails every cron firing and reports it as a scan error.
466
+ const targets = collections ? await collections.all().catch(() => []) : [];
467
+ if (!targets.length) return out;
374
468
  // Share the budget across collections so one huge bucket can't starve the rest.
375
469
  const each = Math.max(1000, Math.floor(budgetMs / targets.length));
376
470
  for (const c of targets) {
@@ -380,7 +474,7 @@ export async function createServer(config = {}) {
380
474
  return out;
381
475
  }
382
476
 
383
- return { vfs, handle, router, sidecar, notifications, identity, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
477
+ return { vfs, handle, router, sidecar, notifications, identity, apiKeys, capabilities, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
384
478
  // The graph itself, so an action or query can be dispatched directly — by a
385
479
  // test, by MCP, by anything that is not an HTTP route.
386
480
  engine, engineContainer: engine.container,
@@ -388,7 +482,7 @@ export async function createServer(config = {}) {
388
482
  // inside a Durable Object want. `begin*` goes wherever `config.background` says,
389
483
  // which for a front-line Worker isolate is the object rather than itself.
390
484
  startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
391
- runMaintenance, mcp, auth, close };
485
+ runMaintenance, checkStorage, mcp, auth, close };
392
486
  }
393
487
 
394
488
  /**
@@ -530,6 +624,14 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
530
624
  if (config.storage.driver === 'filesystem') config.storage.root = env.TROVE_FS_ROOT || './data/objects';
531
625
  if (config.storage.driver === 's3') config.storage.s3 = s3FromEnv(env, 'TROVE_');
532
626
 
627
+ // Which store types a COLLECTION may be created on, if not all of the ones this entry
628
+ // point registered. `TROVE_STORAGE` picks the drive's own primary store; this restricts
629
+ // the menu the collection form offers and what `build` will accept — see
630
+ // engine/providers/core.js for why removal is configuration and addition is code.
631
+ if (env.TROVE_STORAGE_DRIVERS) {
632
+ config.allowedStorageDrivers = env.TROVE_STORAGE_DRIVERS.split(',').map((s) => s.trim()).filter(Boolean);
633
+ }
634
+
533
635
  config.metadata.driver = env.TROVE_METADATA || (config.storage.driver === 'memory' ? 'memory' : 'sqlite');
534
636
  config.metadata.path = env.TROVE_DB_PATH || './data/trove.db';
535
637
 
@@ -640,7 +742,17 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
640
742
  config.kv = { driver: env.TROVE_KV || (config.metadata.driver === 'sqlite' ? 'sqlite' : 'memory'), path: config.metadata.path };
641
743
 
642
744
  // Collections: on by default. Admins (global) + roles that can create collections.
643
- if (env.TROVE_COLLECTIONS === 'false') config.collections = false;
745
+ // TROVE_COLLECTIONS=false used to mean "one implicit unnamed store, no ACLs". That is
746
+ // precisely the fallback this drive no longer has: every collection-scoped endpoint
747
+ // names its collection in the path, so there is nothing for an unnamed store to answer.
748
+ // Refused loudly rather than ignored — a drive that quietly kept enforcing after being
749
+ // told not to would be a surprise in the wrong direction.
750
+ if (env.TROVE_COLLECTIONS === 'false') {
751
+ throw TroveError.invalid(
752
+ 'TROVE_COLLECTIONS=false is no longer supported — endpoints are scoped to a named '
753
+ + 'collection. Remove the setting; create one collection and use it.',
754
+ );
755
+ }
644
756
  config.admins = (env.TROVE_ADMINS || '').split(',').map((s) => s.trim()).filter(Boolean);
645
757
  config.creatorRoles = (env.TROVE_COLLECTION_CREATOR_ROLES || '').split(',').map((s) => s.trim()).filter(Boolean);
646
758
  // 'default' collection grants everyone all caps unless locked down.
@@ -98,6 +98,33 @@ async function readText(handle) {
98
98
  * `ctx` at call time carries { vfs, collections, principal } — the same objects the HTTP
99
99
  * routes get, so the two surfaces cannot drift apart on what a given user may do.
100
100
  */
101
+ /**
102
+ * An agent has to say which collection it means.
103
+ *
104
+ * There used to be a `'default'` here, which made every tool call work on a fresh drive
105
+ * and quietly work on the WRONG collection on a real one — an agent asked to file
106
+ * something would put it wherever the default happened to be, which on a multi-user drive
107
+ * is a collection its user may not even be able to read. Naming it is one extra call to
108
+ * list_collections and removes a whole class of silently-misplaced writes.
109
+ */
110
+ function requireCollection(collection) {
111
+ if (!collection) {
112
+ throw TroveError.invalid('A collection is required — call list_collections to see which ones you can use');
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The same, but only when the reference needs it. A file id or a `trove:` URI already
118
+ * names its collection; a bare NAME is only unique within one.
119
+ */
120
+ function requireCollectionForName(file, collection) {
121
+ const ref = String(file || '');
122
+ const selfNaming = ref.startsWith('trove:') || /^itm_/.test(ref);
123
+ if (!selfNaming && !collection) {
124
+ throw TroveError.invalid(`"${ref}" is a name, so it needs a collection — or pass a file id or trove: URI`);
125
+ }
126
+ }
127
+
101
128
  export function registerTroveTools(server) {
102
129
  server.instructions = INSTRUCTIONS;
103
130
 
@@ -152,12 +179,13 @@ export function registerTroveTools(server) {
152
179
  inputSchema: {
153
180
  type: 'object',
154
181
  properties: {
155
- collection: { type: 'string', description: 'Which collection (default: "default").' },
182
+ collection: { type: 'string', description: 'Which collection to act in. Required — call list_collections first.' },
156
183
  cursor: { type: 'string', description: 'Continue from a previous call.' },
157
184
  limit: { type: 'integer', description: `Maximum items (default 25, max ${MAX_RESULTS}).` },
158
185
  },
159
186
  },
160
- async run({ collection = 'default', cursor, limit }, ctx) {
187
+ async run({ collection, cursor, limit }, ctx) {
188
+ requireCollection(collection);
161
189
  // The description promises recency and `list` defaults to alphabetical, so it
162
190
  // has to be asked for. An agent answering "what did I add recently?" off the top
163
191
  // of an alphabetical list is confidently wrong in a way nothing surfaces.
@@ -184,11 +212,12 @@ export function registerTroveTools(server) {
184
212
  type: 'object',
185
213
  properties: {
186
214
  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").' },
215
+ collection: { type: 'string', description: 'Which collection to look in. Required when `file` is a name; a file id or trove: URI names its own.' },
188
216
  },
189
217
  required: ['file'],
190
218
  },
191
- async run({ file, collection = 'default' }, ctx) {
219
+ async run({ file, collection }, ctx) {
220
+ requireCollectionForName(file, collection);
192
221
  const handle = await fileHandle(ctx, file, collection, 'read');
193
222
  const node = handle.node;
194
223
  if (!textLike(node.contentType)) {
@@ -216,12 +245,13 @@ export function registerTroveTools(server) {
216
245
  properties: {
217
246
  name: { type: 'string', description: 'The file name, including its extension.' },
218
247
  content: { type: 'string', description: 'The text to write.' },
219
- collection: { type: 'string', description: 'Which collection (default: "default").' },
248
+ collection: { type: 'string', description: 'Which collection to act in. Required — call list_collections first.' },
220
249
  contentType: { type: 'string', description: 'Override the type guessed from the name.' },
221
250
  },
222
251
  required: ['name', 'content'],
223
252
  },
224
- async run({ name, content, collection = 'default', contentType }, ctx) {
253
+ async run({ name, content, collection, contentType }, ctx) {
254
+ requireCollection(collection);
225
255
  if (!name?.trim()) throw TroveError.invalid('name is required');
226
256
  const into = await ctx.access.collection(collection, 'write');
227
257
  // No contentType falls through to the vfs's own guess from the name.
@@ -245,7 +275,8 @@ export function registerTroveTools(server) {
245
275
  },
246
276
  required: ['file'],
247
277
  },
248
- async run({ file, collection = 'default' }, ctx) {
278
+ async run({ file, collection }, ctx) {
279
+ requireCollectionForName(file, collection);
249
280
  const handle = await fileHandle(ctx, file, collection, 'delete');
250
281
  await handle.remove();
251
282
  return toolText(`Moved "${handle.name}" to the trash. It can be restored from the drive's trash.`);
@@ -284,7 +315,8 @@ export function registerTroveTools(server) {
284
315
  },
285
316
  required: ['file'],
286
317
  },
287
- async run({ file, collection = 'default' }, ctx) {
318
+ async run({ file, collection }, ctx) {
319
+ requireCollectionForName(file, collection);
288
320
  const handle = await fileHandle(ctx, file, collection, 'read');
289
321
  const node = handle.node;
290
322
  // Scoped, exactly like the HTTP route. Backlinks reach ACROSS collections by
@@ -139,7 +139,7 @@ export class Router {
139
139
  // handler asks for an AUTHORIZED view of a node, collection or upload: the
140
140
  // grant is carried by the object it hands back, so there is no unrestricted
141
141
  // service and no raw id left over to use with one.
142
- const scope = leaseScope(ctx.container, ctx.principal);
142
+ const scope = leaseScope(ctx.container, ctx.principal, ctx.grant);
143
143
  const access = scope.access;
144
144
  try {
145
145
  lease = ctx.container ? await ctx.container.lease(found.route.deps) : null;