@3sln/trove 0.0.11 → 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/access/externalEvaluation.js +17 -0
- 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 +160 -105
- 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
|
@@ -15,6 +15,25 @@
|
|
|
15
15
|
import { TroveError } from '@3sln/trove/core';
|
|
16
16
|
import { troveUri } from '@3sln/trove/core/links.js';
|
|
17
17
|
import { toolText } from './protocol.js';
|
|
18
|
+
// The one implementation of "what may this caller reach", shared with the HTTP routes.
|
|
19
|
+
// This file had its own, and it FILTERED a named collection out of the readable list
|
|
20
|
+
// where routes.js asserts it — so `search_files` answered "No files matched… Try
|
|
21
|
+
// different words" for a collection the caller may not see, and the model burned turns
|
|
22
|
+
// rephrasing what was really a 403. `ctx.access` is on the MCP ctx, so the assert path
|
|
23
|
+
// was available all along.
|
|
24
|
+
import { readableCollectionIds, listFor } from '../scope.js';
|
|
25
|
+
import { rateSubject } from '@3sln/trove/core';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Charge this call against the caller's budget for that class of work.
|
|
29
|
+
*
|
|
30
|
+
* The HTTP routes declare a class in the route table; a tool declares it here, at the top
|
|
31
|
+
* of the one that costs something. A search is the expensive one — on a deployment with
|
|
32
|
+
* embeddings configured it is a paid third-party call — and "an agent is exactly as
|
|
33
|
+
* privileged as the person whose token it holds" has to include how much they can spend.
|
|
34
|
+
*/
|
|
35
|
+
const meter = (ctx, className) =>
|
|
36
|
+
(ctx.rateLimiter ? ctx.rateLimiter.enforce(rateSubject({ principal: ctx.principal }), className) : null);
|
|
18
37
|
|
|
19
38
|
// A file read has to fit in a context window and in memory. Past this the tool returns
|
|
20
39
|
// the head and says so, which is far more useful than refusing or than silently
|
|
@@ -31,28 +50,6 @@ looking for something, search for it — do not try to construct a path.
|
|
|
31
50
|
Files reference each other with trove: URIs (trove:default?name=notes.md). Search matches
|
|
32
51
|
meaning as well as words, so a description of the content works as a query.`;
|
|
33
52
|
|
|
34
|
-
/**
|
|
35
|
-
* Whether this deployment has an ACL layer at all — from configuration, never from
|
|
36
|
-
* whether `ctx.collections` happens to be there. The two agree while everything is
|
|
37
|
-
* wired correctly and diverge exactly when it is not, and here the failure mode is an
|
|
38
|
-
* agent quietly reading every collection in the drive.
|
|
39
|
-
*/
|
|
40
|
-
const enforcing = (ctx) => ctx.config?.collections !== false;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Collections this principal can read, or undefined when there is no ACL layer.
|
|
44
|
-
*
|
|
45
|
-
* `undefined` means "do not scope the query", which is only correct when there is
|
|
46
|
-
* nothing to scope BY. Search and backlinks reach across collections by design, so an
|
|
47
|
-
* unscoped query hands back names, ids and `trove:` URIs from collections the caller
|
|
48
|
-
* cannot read.
|
|
49
|
-
*/
|
|
50
|
-
async function readable(ctx, narrowTo) {
|
|
51
|
-
if (!enforcing(ctx)) return undefined;
|
|
52
|
-
const ids = (await ctx.collections.list(ctx.principal)).map((c) => c.id);
|
|
53
|
-
return narrowTo ? ids.filter((id) => id === narrowTo) : ids;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
53
|
/**
|
|
57
54
|
* A file, and the operations this agent may perform on it.
|
|
58
55
|
*
|
|
@@ -148,7 +145,8 @@ export function registerTroveTools(server) {
|
|
|
148
145
|
},
|
|
149
146
|
async run({ query, collection, limit }, ctx) {
|
|
150
147
|
if (!query?.trim()) throw TroveError.invalid('query is required');
|
|
151
|
-
|
|
148
|
+
await meter(ctx, 'search');
|
|
149
|
+
const collectionIds = await readableCollectionIds(ctx, collection);
|
|
152
150
|
const { results, resolved } = await ctx.vfs.query(query, {
|
|
153
151
|
limit: Math.min(Math.max(1, limit || 10), MAX_RESULTS),
|
|
154
152
|
collectionIds,
|
|
@@ -292,10 +290,7 @@ export function registerTroveTools(server) {
|
|
|
292
290
|
+ 'division of the drive — the closest thing here to a folder, except they do not nest.',
|
|
293
291
|
inputSchema: { type: 'object', properties: {} },
|
|
294
292
|
async run(_args, ctx) {
|
|
295
|
-
|
|
296
|
-
return toolText(JSON.stringify({ collections: [{ id: 'default', capabilities: ['read', 'write', 'delete'] }] }, null, 2));
|
|
297
|
-
}
|
|
298
|
-
const list = await ctx.collections.list(ctx.principal);
|
|
293
|
+
const list = await listFor(ctx);
|
|
299
294
|
return toolText(JSON.stringify({ collections: list }, null, 2), { structured: { collections: list } });
|
|
300
295
|
},
|
|
301
296
|
});
|
|
@@ -322,7 +317,7 @@ export function registerTroveTools(server) {
|
|
|
322
317
|
// Scoped, exactly like the HTTP route. Backlinks reach ACROSS collections by
|
|
323
318
|
// design — that is what makes them useful — so an unscoped query hands back the
|
|
324
319
|
// names, ids and trove: URIs of files inside collections the caller cannot read.
|
|
325
|
-
const collectionIds = await
|
|
320
|
+
const collectionIds = await readableCollectionIds(ctx);
|
|
326
321
|
const backlinks = await handle.backlinks({ limit: 20, collectionIds })
|
|
327
322
|
// Distinguishable from "nothing links here", which in a drive with no folders is
|
|
328
323
|
// a load-bearing fact an agent will reason from.
|
|
@@ -340,7 +335,7 @@ export function registerTroveTools(server) {
|
|
|
340
335
|
// something invented for MCP — one name for a file across the whole system.
|
|
341
336
|
server.resources({
|
|
342
337
|
async list(params, ctx) {
|
|
343
|
-
const ids = await
|
|
338
|
+
const ids = await readableCollectionIds(ctx);
|
|
344
339
|
const out = [];
|
|
345
340
|
for (const cid of ids || ['default']) {
|
|
346
341
|
const page = await (await ctx.access.collection(cid, 'read')).list({ limit: 100 });
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { TroveError, wrapError, ErrorCode, publicOrigin } from '@3sln/trove/core';
|
|
9
9
|
import { leaseScope } from './scope.js';
|
|
10
|
+
import { rateSubject } from '@3sln/trove/core';
|
|
10
11
|
|
|
11
12
|
// Methods that change state. A GET is safe by definition, so it isn't checked.
|
|
12
13
|
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
@@ -65,6 +66,9 @@ export class Router {
|
|
|
65
66
|
* @param {string[]|Function} depsOrHandler the resources this route needs, by
|
|
66
67
|
* name — or the handler, for a route that needs none.
|
|
67
68
|
* @param {Function} [maybeHandler]
|
|
69
|
+
* @param {{cost?: string}} [opts] which class of work this is, for rate limiting — see
|
|
70
|
+
* core/rateLimit.js. A route that names none is not metered, which is the right answer
|
|
71
|
+
* for the cheap reads the shell issues constantly.
|
|
68
72
|
*
|
|
69
73
|
* Declaring dependencies is the point. Every handler used to receive one
|
|
70
74
|
* object carrying the whole server: vfs, collections, kv, sqlite, plugins,
|
|
@@ -74,17 +78,17 @@ export class Router {
|
|
|
74
78
|
* reading it. Named here, the answer is in the route table, and a route that
|
|
75
79
|
* did not ask for `plugins` does not get `plugins`.
|
|
76
80
|
*/
|
|
77
|
-
add(method, pattern, depsOrHandler, maybeHandler) {
|
|
81
|
+
add(method, pattern, depsOrHandler, maybeHandler, opts = {}) {
|
|
78
82
|
const handler = maybeHandler ?? depsOrHandler;
|
|
79
83
|
const deps = maybeHandler ? depsOrHandler : [];
|
|
80
84
|
const segs = pattern.split('/').filter(Boolean);
|
|
81
|
-
this.routes.push({ method, segs, handler, deps });
|
|
85
|
+
this.routes.push({ method, segs, handler, deps, cost: opts.cost || null });
|
|
82
86
|
return this;
|
|
83
87
|
}
|
|
84
|
-
get(p, d, h) { return this.add('GET', p, d, h); }
|
|
85
|
-
post(p, d, h) { return this.add('POST', p, d, h); }
|
|
86
|
-
put(p, d, h) { return this.add('PUT', p, d, h); }
|
|
87
|
-
delete(p, d, h) { return this.add('DELETE', p, d, h); }
|
|
88
|
+
get(p, d, h, o) { return this.add('GET', p, d, h, o); }
|
|
89
|
+
post(p, d, h, o) { return this.add('POST', p, d, h, o); }
|
|
90
|
+
put(p, d, h, o) { return this.add('PUT', p, d, h, o); }
|
|
91
|
+
delete(p, d, h, o) { return this.add('DELETE', p, d, h, o); }
|
|
88
92
|
|
|
89
93
|
#match(method, pathname) {
|
|
90
94
|
const parts = pathname.split('/').filter(Boolean);
|
|
@@ -142,6 +146,15 @@ export class Router {
|
|
|
142
146
|
const scope = leaseScope(ctx.container, ctx.principal, ctx.grant);
|
|
143
147
|
const access = scope.access;
|
|
144
148
|
try {
|
|
149
|
+
// Before the handler, before the lease, and before any work: the point of a limit is
|
|
150
|
+
// that the expensive thing does not happen. `rateLimiter` is null when limiting is
|
|
151
|
+
// off, and a route that named no class is not metered at all.
|
|
152
|
+
if (found.route.cost && ctx.rateLimiter) {
|
|
153
|
+
await ctx.rateLimiter.enforce(
|
|
154
|
+
rateSubject({ grant: ctx.grant, principal: ctx.principal, req, trustProxy: ctx.config?.trustProxy }),
|
|
155
|
+
found.route.cost,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
145
158
|
lease = ctx.container ? await ctx.container.lease(found.route.deps) : null;
|
|
146
159
|
const result = await found.route.handler({
|
|
147
160
|
req, params: found.params, query, url, access, ...ctx, ...(lease?.resources || {}),
|
|
@@ -151,7 +164,13 @@ export class Router {
|
|
|
151
164
|
} catch (raw) {
|
|
152
165
|
const err = raw instanceof TroveError ? raw : wrapError(raw);
|
|
153
166
|
if (err.code === ErrorCode.INTERNAL) console.error('Unhandled:', err.cause || err);
|
|
154
|
-
|
|
167
|
+
// A 429 without a `Retry-After` makes every client guess, and a client that guesses
|
|
168
|
+
// wrong either hammers or waits far too long. The limiter knows exactly when the
|
|
169
|
+
// window ends, so it says so — in SECONDS, rounded up, which is what RFC 9110 wants.
|
|
170
|
+
const headers = err.details?.retryAfterMs != null
|
|
171
|
+
? { 'retry-after': String(Math.ceil(err.details.retryAfterMs / 1000)) }
|
|
172
|
+
: {};
|
|
173
|
+
return cors(json(err.toJSON(), err.status, headers), origin);
|
|
155
174
|
} finally {
|
|
156
175
|
await scope.release();
|
|
157
176
|
await lease?.release();
|
|
@@ -162,7 +181,11 @@ export class Router {
|
|
|
162
181
|
// Resolve the Access-Control-Allow-Origin value: null (no CORS) unless configured.
|
|
163
182
|
// '*' echoes '*'; a configured origin is echoed only when the request matches it
|
|
164
183
|
// (so credentials-mode requests get a specific origin, not a wildcard).
|
|
165
|
-
|
|
184
|
+
//
|
|
185
|
+
// Exported because the MCP endpoint needs the same answer. It kept its own copy, and two
|
|
186
|
+
// parsers of one allowlist is how the API and the agent endpoint come to disagree about
|
|
187
|
+
// which origins are trusted — mcp/index.js already imports `crossSiteRefusal` from here.
|
|
188
|
+
export function corsOriginFor(configured, reqOrigin) {
|
|
166
189
|
if (!configured) return null;
|
|
167
190
|
if (configured === '*') return '*';
|
|
168
191
|
const allowed = String(configured).split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -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
|
|