@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
@@ -134,8 +134,22 @@ function assertPublicHost(hostname) {
134
134
 
135
135
  // Which collection a request targets. There is no folder to infer one from any more,
136
136
  // so it's named explicitly or it's the default.
137
- function collectionOf(src) {
138
- return src?.collection || src?.collectionId || 'default';
137
+ /**
138
+ * The collection a request is scoped to, from the PATH.
139
+ *
140
+ * There is no fallback. A collection-scoped route names its collection in the URL —
141
+ * `/api/collections/:collection/items` — so a request that does not name one cannot
142
+ * reach the handler at all, and the router refuses it before this is called.
143
+ *
144
+ * The fallback that used to live here (`?collection=` or else `'default'`) was the same
145
+ * class of bug as everything else in this file's history: a missing value silently
146
+ * became a specific one, so an unscoped write went somewhere real and looked fine. On a
147
+ * multi-user drive that somewhere was a collection plenty of people cannot even read.
148
+ */
149
+ function scopedCollection(ctx) {
150
+ const id = ctx.params?.collection;
151
+ if (!id) throw TroveError.invalid('This endpoint is scoped to a collection');
152
+ return id;
139
153
  }
140
154
 
141
155
  export function createRouter() {
@@ -163,8 +177,13 @@ export function createRouter() {
163
177
  storage = await (await ctx.access.collection(query.collection, 'read')).storage();
164
178
  }
165
179
  return {
166
- collection: query.collection || 'default',
180
+ collection: query.collection || null,
167
181
  storage: storage.capabilities,
182
+ // What kinds of backing store THIS deployment can build, with the fields each one
183
+ // needs. The collection form used to hardcode this list, which is how a Cloudflare
184
+ // Workers drive came to offer "Filesystem / NAS" — a choice its runtime cannot
185
+ // honour. Answered by the server now, so the form can only offer what exists.
186
+ storageDrivers: ctx.config.storageRegistry?.describe?.() || [],
168
187
  indexers: vfs.indexers.list(),
169
188
  partSize: vfs.uploads.partSize,
170
189
  features: {
@@ -213,13 +232,11 @@ export function createRouter() {
213
232
 
214
233
  r.get('/api/collections', ['collections'], async (ctx) => {
215
234
  const { collections, principal } = ctx;
216
- if (!collectionsEnabled(ctx)) return { collections: [{ id: 'default', name: 'My Drive', capabilities: ['read', 'write', 'delete', 'admin'] }] };
217
235
  return { collections: await collections.list(principal), canCreate: collections.canCreate(principal) };
218
236
  });
219
237
 
220
238
  r.get('/api/collections/:id', ['collections'], async (ctx) => {
221
239
  const { collections, principal, params } = ctx;
222
- if (!collectionsEnabled(ctx)) return { collection: { id: 'default', name: 'My Drive' } };
223
240
  const c = await collections.assert(principal, params.id, 'read');
224
241
  return { collection: collections.describe(c, principal) };
225
242
  });
@@ -259,6 +276,37 @@ export function createRouter() {
259
276
  return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };
260
277
  });
261
278
 
279
+ // --- API keys ---------------------------------------------------------------
280
+ //
281
+ // Managing keys requires a real admin PRINCIPAL, and a key can never be used to reach
282
+ // these routes — `requireHumanAdmin` refuses when the request arrived on a grant. That
283
+ // asymmetry is deliberate: a key that could mint keys is a key that can escalate
284
+ // itself, and revoking it would not revoke what it had already issued. The blast
285
+ // radius of a leaked key stops at the capabilities it was given.
286
+
287
+ r.get('/api/keys', ['apiKeys', 'collections'], async (ctx) => {
288
+ requireHumanAdmin(ctx, 'manage API keys');
289
+ return { keys: await ctx.apiKeys.list() };
290
+ });
291
+
292
+ r.post('/api/keys', ['apiKeys', 'collections'], async (ctx) => {
293
+ requireHumanAdmin(ctx, 'mint API keys');
294
+ const b = await body(ctx.req);
295
+ // The secret comes back exactly once, here, and is never retrievable again.
296
+ const { record, secret } = await ctx.apiKeys.mint({
297
+ name: b.name,
298
+ scopes: b.scopes,
299
+ expiresAt: b.expiresAt ?? null,
300
+ createdBy: ctx.principal?.id ?? null,
301
+ });
302
+ return { key: record, secret };
303
+ });
304
+
305
+ r.delete('/api/keys/:id', ['apiKeys', 'collections'], async (ctx) => {
306
+ requireHumanAdmin(ctx, 'revoke API keys');
307
+ return { key: await ctx.apiKeys.revoke(ctx.params.id) };
308
+ });
309
+
262
310
  // --- browse ----------------------------------------------------------------
263
311
 
264
312
  // --- items -----------------------------------------------------------------
@@ -269,9 +317,9 @@ export function createRouter() {
269
317
 
270
318
  // Every item in a collection. There is nothing to descend into — a drive is browsed
271
319
  // by search and by following links, and this is the "show me everything" fallback.
272
- r.get('/api/items', ['vfs'], async (ctx) => {
320
+ r.get('/api/collections/:collection/items', ['vfs'], async (ctx) => {
273
321
  const { vfs, query } = ctx;
274
- const collectionId = collectionOf(query);
322
+ const collectionId = scopedCollection(ctx);
275
323
  const collection = await ctx.access.collection(collectionId, 'read');
276
324
  const { items, nextCursor } = await collection.list({
277
325
  sort: query.sort, order: query.order,
@@ -290,12 +338,12 @@ export function createRouter() {
290
338
  });
291
339
 
292
340
  // Resolve an item: by id, by `?name=` within a collection, or by a `trove:` URI.
293
- r.get('/api/items/resolve', [], async (ctx) => {
341
+ r.get('/api/collections/:collection/items/resolve', [], async (ctx) => {
294
342
  const ref = ctx.query.id || ctx.query.uri || ctx.query.name;
295
343
  if (!ref) throw TroveError.invalid('id, name or uri is required');
296
- // A name is only unique within a collection, so the hint goes to `stat`; the
297
- // capability is asserted on the collection the node turns out to be in.
298
- const handle = await ctx.access.node(ref, 'read', { collectionId: collectionOf(ctx.query) });
344
+ // A name is only unique within a collection, which is the reason this one is scoped
345
+ // by path at all: resolving `notes.md` is a different question in each collection.
346
+ const handle = await ctx.access.node(ref, 'read', { collectionId: scopedCollection(ctx) });
299
347
  return { node: handle.node };
300
348
  });
301
349
 
@@ -396,10 +444,10 @@ export function createRouter() {
396
444
 
397
445
  // --- uploads ---------------------------------------------------------------
398
446
 
399
- r.post('/api/uploads', [], async (ctx) => {
447
+ r.post('/api/collections/:collection/uploads', [], async (ctx) => {
400
448
  const b = await body(ctx.req);
401
449
  if (!b.name) throw TroveError.invalid('name is required');
402
- const collection = await ctx.access.collection(collectionOf(b), 'write');
450
+ const collection = await ctx.access.collection(scopedCollection(ctx), 'write');
403
451
  return uploadDescriptor(await collection.createUpload({
404
452
  name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
405
453
  overwrite: b.overwrite === true,
@@ -450,26 +498,36 @@ export function createRouter() {
450
498
  // different results. POST /api/query is the one that runs the transformer and is what
451
499
  // the workbench uses; this stays as the lower-level endpoint for callers that have
452
500
  // already resolved their own query.
453
- r.get('/api/search', ['collections', 'vfs'], async (ctx) => {
501
+ // Searching comes in two shapes, and which one you get is in the URL rather than in
502
+ // the presence of a parameter. The flat route searches every collection the caller can
503
+ // read; the scoped one searches exactly the collection it names.
504
+ //
505
+ // Two routes rather than `?collection=` because omitting a query parameter used to mean
506
+ // "the whole drive" — a missing value silently choosing the broadest possible scope,
507
+ // which is the same failure shape as the old `'default'` fallback pointing the other
508
+ // way. Neither is something to arrive at by accident.
509
+ const searchHandler = async (ctx) => {
454
510
  const { vfs, query } = ctx;
455
511
  if (!query.q) throw TroveError.invalid('q is required');
456
- const collectionIds = await readableCollectionIds(ctx, query.collection);
512
+ const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
457
513
  const results = await vfs.searchQuery(query.q, {
458
514
  mode: query.mode, limit: clampLimit(query.limit, 40),
459
515
  indexers: query.indexers ? query.indexers.split(',') : undefined,
460
516
  collectionIds,
461
517
  });
462
518
  return { query: query.q, results };
463
- });
519
+ };
520
+ r.get('/api/search', ['collections', 'vfs'], searchHandler);
521
+ r.get('/api/collections/:collection/search', ['collections', 'vfs'], searchHandler);
464
522
 
465
523
  // Unified query: a raw user string is run through the search transformer (default
466
524
  // parses `#tag` syntax; a plugged-in one may use an LLM), then dispatched. Returns
467
525
  // the results AND the `resolved` query (what was actually searched) so the client
468
526
  // can honestly show it.
469
- r.post('/api/query', ['collections', 'vfs'], async (ctx) => {
527
+ const queryHandler = async (ctx) => {
470
528
  const b = await body(ctx.req);
471
529
  if (typeof b.q !== 'string' || !b.q.trim()) throw TroveError.invalid('q is required');
472
- const collectionIds = await readableCollectionIds(ctx, b.collection);
530
+ const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
473
531
  const { results, resolved } = await ctx.vfs.query(b.q, {
474
532
  mode: b.mode, limit: clampLimit(b.limit, 40), collectionIds,
475
533
  // Which views this client can draw with, so the transformer can suggest one of
@@ -478,18 +536,22 @@ export function createRouter() {
478
536
  views: Array.isArray(b.views) ? b.views : undefined,
479
537
  });
480
538
  return { query: b.q, results, resolved };
481
- });
539
+ };
540
+ r.post('/api/query', ['collections', 'vfs'], queryHandler);
541
+ r.post('/api/collections/:collection/query', ['collections', 'vfs'], queryHandler);
482
542
 
483
543
  // Drive-wide tag/property filter (the launcher's `#tag` / `#key:op:value`).
484
- r.post('/api/tags/search', ['collections', 'vfs'], async (ctx) => {
544
+ const tagSearchHandler = async (ctx) => {
485
545
  const b = await body(ctx.req);
486
546
  const filters = Array.isArray(b.filters) ? b.filters : [];
487
- const collectionIds = await readableCollectionIds(ctx, b.collection);
547
+ const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
488
548
  const items = await ctx.vfs.findByTags(filters, {
489
549
  q: b.q, collectionIds, limit: clampLimit(b.limit, 100),
490
550
  });
491
551
  return { items };
492
- });
552
+ };
553
+ r.post('/api/tags/search', ['collections', 'vfs'], tagSearchHandler);
554
+ r.post('/api/collections/:collection/tags/search', ['collections', 'vfs'], tagSearchHandler);
493
555
 
494
556
  r.get('/api/indexers', ['vfs'], ({ vfs }) => ({ indexers: vfs.indexers.list() }));
495
557
 
@@ -598,6 +660,19 @@ export function createRouter() {
598
660
  return { ok: true };
599
661
  });
600
662
 
663
+ // Check the backing stores on demand, and answer with what was found rather than only
664
+ // leaving issues behind — an admin who just pressed "Check storage" is owed the result
665
+ // of the check they asked for.
666
+ //
667
+ // Origin comes from THIS request, which is the whole reason the on-demand version
668
+ // exists alongside the scheduled one: a bucket policy may legitimately name a single
669
+ // origin, and the origin that matters is the one browsers are actually using to reach
670
+ // the drive. A cron firing can only fall back to a configured TROVE_PUBLIC_URL.
671
+ r.post('/api/diagnostics/storage', ['collections', 'issues', 'storageCheck'], async (ctx) => {
672
+ await requireWholeDrive(ctx, 'check the backing stores');
673
+ return ctx.storageCheck.run({ origin: publicOrigin(ctx.req, ctx.config) });
674
+ });
675
+
601
676
  // Rebuild the search index on demand. Admin-only: it re-reads every object in the
602
677
  // drive, so it is a real load, and it is drive-wide rather than scoped to anything
603
678
  // the caller owns. Returns the task, which is how the caller watches it.
@@ -621,8 +696,8 @@ export function createRouter() {
621
696
  // `delete` on the collection, the same capability the delete itself needed — seeing
622
697
  // what you deleted, and undoing it, are not lesser rights than deleting.
623
698
 
624
- r.get('/api/trash', [], async (ctx) => {
625
- const collectionId = collectionOf(ctx.query);
699
+ r.get('/api/collections/:collection/trash', [], async (ctx) => {
700
+ const collectionId = scopedCollection(ctx);
626
701
  const collection = await ctx.access.collection(collectionId, 'delete');
627
702
  return { items: await collection.listTrash({ limit: clampLimit(ctx.query.limit, 200) }), collectionId };
628
703
  });
@@ -637,14 +712,19 @@ export function createRouter() {
637
712
 
638
713
  // Destroy for real. Separate from DELETE /api/items so that emptying the trash can
639
714
  // never be something you reach by accident from the ordinary delete path.
715
+ // One item, by id. The node names its own collection, so this stays flat.
640
716
  r.post('/api/trash/purge', [], async (ctx) => {
641
717
  const b = await body(ctx.req);
642
- if (b.id) {
643
- const node = await ctx.access.node(b.id, 'delete', { trashed: true });
644
- await node.remove({ permanent: true });
645
- return { purged: 1 };
646
- }
647
- const collection = await ctx.access.collection(collectionOf(b), 'delete');
718
+ if (!b.id) throw TroveError.invalid('id is required — to empty a collection\u2019s trash, use /api/collections/:collection/trash/purge');
719
+ const node = await ctx.access.node(b.id, 'delete', { trashed: true });
720
+ await node.remove({ permanent: true });
721
+ return { purged: 1 };
722
+ });
723
+
724
+ // Empty a whole collection's trash. Scoped by path, because "everything in here" is
725
+ // exactly the request that must never be able to mean a collection you did not name.
726
+ r.post('/api/collections/:collection/trash/purge', [], async (ctx) => {
727
+ const collection = await ctx.access.collection(scopedCollection(ctx), 'delete');
648
728
  return collection.purgeTrash({ limit: MAX_PAGE });
649
729
  });
650
730
 
@@ -989,8 +1069,15 @@ async function assertContributorOwned(ctx, contributorId) {
989
1069
  */
990
1070
  async function readableCollectionIds(ctx, narrowTo) {
991
1071
  if (!collectionsEnabled(ctx)) return undefined;
992
- const readable = (await ctx.collections.list(ctx.principal)).map((c) => c.id);
993
- return narrowTo ? readable.filter((id) => id === narrowTo) : readable;
1072
+ // A NAMED collection is asserted, not filtered. Filtering an unreadable id out of the
1073
+ // list answers "no results" for a collection the caller may not see — indistinguishable
1074
+ // from one that is simply empty, so a permissions problem reads as an indexing problem.
1075
+ // `access.collection` throws the 403 that says what actually happened.
1076
+ if (narrowTo) {
1077
+ await ctx.access.collection(narrowTo, 'read');
1078
+ return [narrowTo];
1079
+ }
1080
+ return (await ctx.collections.list(ctx.principal)).map((c) => c.id);
994
1081
  }
995
1082
 
996
1083
  /**
@@ -1056,6 +1143,22 @@ async function assertTaskAccess(ctx, task, what) {
1056
1143
  if (task.collectionId == null) return requireWholeDrive(ctx, `${what} a drive-wide task`);
1057
1144
  await assertCap(ctx, task.collectionId, 'write');
1058
1145
  }
1146
+ /**
1147
+ * An admin, in person.
1148
+ *
1149
+ * Two refusals, not one. A grant is refused outright — an API key must never be able to
1150
+ * manage API keys, however broadly it was scoped, because a key that can mint keys can
1151
+ * outlive its own revocation. Then the ordinary admin check on the principal.
1152
+ */
1153
+ function requireHumanAdmin(ctx, action) {
1154
+ if (ctx.grant) {
1155
+ throw TroveError.forbidden(`An API key cannot ${action} — sign in as an administrator`);
1156
+ }
1157
+ requirePrincipal(ctx.principal);
1158
+ const isAdmin = collectionsEnabled(ctx) ? ctx.collections.isAdmin(ctx.principal) : !!ctx.principal;
1159
+ if (!isAdmin) throw TroveError.forbidden(`You need to be an administrator to ${action}`);
1160
+ }
1161
+
1059
1162
  function requirePrincipal(principal) {
1060
1163
  if (!principal) throw TroveError.unauthorized('Authentication required');
1061
1164
  }
@@ -15,12 +15,12 @@
15
15
  * @param {object|null} principal who is asking
16
16
  * @returns {{access: object|null, release: () => Promise<void>}}
17
17
  */
18
- export function leaseScope(container, principal) {
18
+ export function leaseScope(container, principal, grant = null) {
19
19
  const held = [];
20
20
  if (!container) return { access: null, release: async () => {} };
21
21
 
22
22
  const obtain = async (name, request) => {
23
- const lease = await container.lease({ [name]: { principal, ...request } });
23
+ const lease = await container.lease({ [name]: { principal, grant, ...request } });
24
24
  held.push(lease);
25
25
  return lease.resources[name];
26
26
  };