@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.
- package/README.md +6 -0
- package/package.json +1 -1
- package/packages/core/src/apiKeys.js +326 -0
- package/packages/core/src/collections/index.js +83 -13
- package/packages/core/src/index.js +14 -2
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +83 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +162 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +47 -5
- package/packages/server/src/engine/providers/core.js +105 -11
- package/packages/server/src/index.js +123 -11
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +135 -32
- package/packages/server/src/scope.js +2 -2
- package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
- package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
- package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +58 -9
- package/packages/web/src/bl/actions.js +112 -13
- package/packages/web/src/bl/activity.js +32 -0
- package/packages/web/src/bl/commands.js +41 -2
- package/packages/web/src/bl/index.js +9 -4
- package/packages/web/src/bl/services.js +78 -1
- package/packages/web/src/platform/api.js +57 -14
- package/packages/web/src/platform/pluginRpc.js +7 -4
- package/packages/web/src/styles.css +137 -0
- package/packages/web/src/ui/components/activityPanel.js +28 -1
- package/packages/web/src/ui/components/collectionGate.js +81 -0
- package/packages/web/src/ui/components/overlays.js +64 -34
- package/packages/web/src/ui/components/phoneChrome.js +2 -2
- package/packages/web/src/ui/components/settingsView.js +197 -1
- package/packages/web/src/ui/components/statusBar.js +19 -2
- package/packages/web/src/ui/compositions/workbench.js +9 -2
- 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
|
-
|
|
138
|
-
|
|
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 ||
|
|
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 =
|
|
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,
|
|
297
|
-
//
|
|
298
|
-
const handle = await ctx.access.node(ref, 'read', { collectionId:
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
993
|
-
|
|
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
|
};
|