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