@3sln/trove 0.0.3 → 0.0.5

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 (47) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/apiKeys.js +326 -0
  3. package/packages/core/src/collections/index.js +83 -13
  4. package/packages/core/src/index.js +18 -4
  5. package/packages/core/src/issues.js +4 -0
  6. package/packages/core/src/notifications/channel.js +68 -0
  7. package/packages/core/src/notifications/index.js +72 -43
  8. package/packages/core/src/notifications/webpush.js +110 -0
  9. package/packages/core/src/sqlite-d1.js +14 -1
  10. package/packages/core/src/sqlite-driver.js +73 -1
  11. package/packages/core/src/storage/diagnose.js +234 -0
  12. package/packages/core/src/storage/drivers.js +74 -0
  13. package/packages/core/src/storage/filesystem.js +22 -0
  14. package/packages/core/src/storage/registry.js +129 -0
  15. package/packages/server/src/adapters/bun.js +6 -0
  16. package/packages/server/src/adapters/node.js +6 -0
  17. package/packages/server/src/adapters/worker-tasks.js +14 -4
  18. package/packages/server/src/adapters/worker.js +7 -1
  19. package/packages/server/src/engine/index.js +1 -1
  20. package/packages/server/src/engine/providers/access.js +47 -5
  21. package/packages/server/src/engine/providers/core.js +109 -16
  22. package/packages/server/src/index.js +137 -12
  23. package/packages/server/src/mcp/tools.js +40 -8
  24. package/packages/server/src/router.js +1 -1
  25. package/packages/server/src/routes.js +156 -46
  26. package/packages/server/src/scope.js +2 -2
  27. package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
  28. package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
  29. package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
  30. package/packages/web/dist/index.html +2 -2
  31. package/packages/web/dist/sw.js +58 -9
  32. package/packages/web/src/bl/actions.js +112 -13
  33. package/packages/web/src/bl/activity.js +32 -0
  34. package/packages/web/src/bl/commands.js +41 -2
  35. package/packages/web/src/bl/index.js +9 -4
  36. package/packages/web/src/bl/services.js +78 -1
  37. package/packages/web/src/platform/api.js +57 -14
  38. package/packages/web/src/platform/pluginRpc.js +7 -4
  39. package/packages/web/src/styles.css +137 -0
  40. package/packages/web/src/ui/components/activityPanel.js +28 -1
  41. package/packages/web/src/ui/components/collectionGate.js +81 -0
  42. package/packages/web/src/ui/components/overlays.js +64 -34
  43. package/packages/web/src/ui/components/phoneChrome.js +2 -2
  44. package/packages/web/src/ui/components/settingsView.js +197 -1
  45. package/packages/web/src/ui/components/statusBar.js +19 -2
  46. package/packages/web/src/ui/compositions/workbench.js +9 -2
  47. 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,
@@ -442,26 +490,44 @@ export function createRouter() {
442
490
 
443
491
  // --- search & indexing -----------------------------------------------------
444
492
 
445
- r.get('/api/search', ['collections', 'vfs'], async (ctx) => {
493
+ // Raw search: the query string is passed through as text, with NO transformer.
494
+ //
495
+ // Which means the `#tag` grammar does not apply here. `/api/capabilities` advertises a
496
+ // searchPrompt telling people `#tag` narrows by tag, and against this endpoint that
497
+ // degrades into a text search for the literal string "#tag" — no error, just quietly
498
+ // different results. POST /api/query is the one that runs the transformer and is what
499
+ // the workbench uses; this stays as the lower-level endpoint for callers that have
500
+ // already resolved their own query.
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) => {
446
510
  const { vfs, query } = ctx;
447
511
  if (!query.q) throw TroveError.invalid('q is required');
448
- const collectionIds = await readableCollectionIds(ctx, query.collection);
512
+ const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
449
513
  const results = await vfs.searchQuery(query.q, {
450
514
  mode: query.mode, limit: clampLimit(query.limit, 40),
451
515
  indexers: query.indexers ? query.indexers.split(',') : undefined,
452
516
  collectionIds,
453
517
  });
454
518
  return { query: query.q, results };
455
- });
519
+ };
520
+ r.get('/api/search', ['collections', 'vfs'], searchHandler);
521
+ r.get('/api/collections/:collection/search', ['collections', 'vfs'], searchHandler);
456
522
 
457
523
  // Unified query: a raw user string is run through the search transformer (default
458
524
  // parses `#tag` syntax; a plugged-in one may use an LLM), then dispatched. Returns
459
525
  // the results AND the `resolved` query (what was actually searched) so the client
460
526
  // can honestly show it.
461
- r.post('/api/query', ['collections', 'vfs'], async (ctx) => {
527
+ const queryHandler = async (ctx) => {
462
528
  const b = await body(ctx.req);
463
529
  if (typeof b.q !== 'string' || !b.q.trim()) throw TroveError.invalid('q is required');
464
- const collectionIds = await readableCollectionIds(ctx, b.collection);
530
+ const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
465
531
  const { results, resolved } = await ctx.vfs.query(b.q, {
466
532
  mode: b.mode, limit: clampLimit(b.limit, 40), collectionIds,
467
533
  // Which views this client can draw with, so the transformer can suggest one of
@@ -470,18 +536,22 @@ export function createRouter() {
470
536
  views: Array.isArray(b.views) ? b.views : undefined,
471
537
  });
472
538
  return { query: b.q, results, resolved };
473
- });
539
+ };
540
+ r.post('/api/query', ['collections', 'vfs'], queryHandler);
541
+ r.post('/api/collections/:collection/query', ['collections', 'vfs'], queryHandler);
474
542
 
475
543
  // Drive-wide tag/property filter (the launcher's `#tag` / `#key:op:value`).
476
- r.post('/api/tags/search', ['collections', 'vfs'], async (ctx) => {
544
+ const tagSearchHandler = async (ctx) => {
477
545
  const b = await body(ctx.req);
478
546
  const filters = Array.isArray(b.filters) ? b.filters : [];
479
- const collectionIds = await readableCollectionIds(ctx, b.collection);
547
+ const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
480
548
  const items = await ctx.vfs.findByTags(filters, {
481
549
  q: b.q, collectionIds, limit: clampLimit(b.limit, 100),
482
550
  });
483
551
  return { items };
484
- });
552
+ };
553
+ r.post('/api/tags/search', ['collections', 'vfs'], tagSearchHandler);
554
+ r.post('/api/collections/:collection/tags/search', ['collections', 'vfs'], tagSearchHandler);
485
555
 
486
556
  r.get('/api/indexers', ['vfs'], ({ vfs }) => ({ indexers: vfs.indexers.list() }));
487
557
 
@@ -590,6 +660,19 @@ export function createRouter() {
590
660
  return { ok: true };
591
661
  });
592
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
+
593
676
  // Rebuild the search index on demand. Admin-only: it re-reads every object in the
594
677
  // drive, so it is a real load, and it is drive-wide rather than scoped to anything
595
678
  // the caller owns. Returns the task, which is how the caller watches it.
@@ -613,8 +696,8 @@ export function createRouter() {
613
696
  // `delete` on the collection, the same capability the delete itself needed — seeing
614
697
  // what you deleted, and undoing it, are not lesser rights than deleting.
615
698
 
616
- r.get('/api/trash', [], async (ctx) => {
617
- const collectionId = collectionOf(ctx.query);
699
+ r.get('/api/collections/:collection/trash', [], async (ctx) => {
700
+ const collectionId = scopedCollection(ctx);
618
701
  const collection = await ctx.access.collection(collectionId, 'delete');
619
702
  return { items: await collection.listTrash({ limit: clampLimit(ctx.query.limit, 200) }), collectionId };
620
703
  });
@@ -629,14 +712,19 @@ export function createRouter() {
629
712
 
630
713
  // Destroy for real. Separate from DELETE /api/items so that emptying the trash can
631
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.
632
716
  r.post('/api/trash/purge', [], async (ctx) => {
633
717
  const b = await body(ctx.req);
634
- if (b.id) {
635
- const node = await ctx.access.node(b.id, 'delete', { trashed: true });
636
- await node.remove({ permanent: true });
637
- return { purged: 1 };
638
- }
639
- 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');
640
728
  return collection.purgeTrash({ limit: MAX_PAGE });
641
729
  });
642
730
 
@@ -748,20 +836,10 @@ export function createRouter() {
748
836
  return notifications.markRead(principal.id, b.ids);
749
837
  });
750
838
 
751
- r.get('/api/push/vapid', ['notifications'], ({ notifications }) => ({ publicKey: notifications?.vapidPublicKey() || null }));
752
-
753
- r.post('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
754
- requireNotifications(notifications);
755
- requirePrincipal(principal);
756
- const b = await body(req);
757
- return notifications.subscribePush(principal.id, b.subscription);
758
- });
759
- r.delete('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
760
- requireNotifications(notifications);
761
- requirePrincipal(principal);
762
- const b = await body(req);
763
- return notifications.unsubscribePush(principal.id, b.endpoint);
764
- });
839
+ // /api/push/* is not here. Registering with a delivery channel is the channel's own
840
+ // business — see WebPushChannel.routes() — so the drive's route table does not carry
841
+ // endpoints for a transport it may not have configured, and adding email or chat does
842
+ // not mean editing this file.
765
843
 
766
844
  // --- plugins: domain verification proxy + per-plugin server storage --------
767
845
 
@@ -991,8 +1069,15 @@ async function assertContributorOwned(ctx, contributorId) {
991
1069
  */
992
1070
  async function readableCollectionIds(ctx, narrowTo) {
993
1071
  if (!collectionsEnabled(ctx)) return undefined;
994
- const readable = (await ctx.collections.list(ctx.principal)).map((c) => c.id);
995
- 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);
996
1081
  }
997
1082
 
998
1083
  /**
@@ -1058,9 +1143,34 @@ async function assertTaskAccess(ctx, task, what) {
1058
1143
  if (task.collectionId == null) return requireWholeDrive(ctx, `${what} a drive-wide task`);
1059
1144
  await assertCap(ctx, task.collectionId, 'write');
1060
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
+
1061
1162
  function requirePrincipal(principal) {
1062
1163
  if (!principal) throw TroveError.unauthorized('Authentication required');
1063
1164
  }
1064
1165
  function requireNotifications(n) {
1065
1166
  if (!n) throw TroveError.unsupported('Notifications are not enabled on this server');
1066
1167
  }
1168
+
1169
+ /**
1170
+ * The request plumbing handed to routes contributed from outside this file.
1171
+ *
1172
+ * `body` is the capped JSON read — the cap is the reason to share it rather than let a
1173
+ * channel call `req.json()` and accept an unbounded body — and `requirePrincipal` is
1174
+ * the same 401 every route here throws.
1175
+ */
1176
+ export const routeHelpers = { body, requirePrincipal };
@@ -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
  };