@3sln/trove 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +18 -4
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/notifications/channel.js +68 -0
- package/packages/core/src/notifications/index.js +72 -43
- package/packages/core/src/notifications/webpush.js +110 -0
- package/packages/core/src/sqlite-d1.js +14 -1
- package/packages/core/src/sqlite-driver.js +73 -1
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +74 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +129 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/adapters/worker-tasks.js +14 -4
- package/packages/server/src/adapters/worker.js +7 -1
- 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 +109 -16
- package/packages/server/src/index.js +137 -12
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +156 -46
- 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
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
VectorStore, KeywordStore, IndexerRegistry,
|
|
16
16
|
accessHost, TroveError,
|
|
17
17
|
protectedResourceMetadata, challengeHeaders, publicOrigin,
|
|
18
|
+
diagnoseStorage, STORAGE_ISSUE_CODES,
|
|
18
19
|
} from '@3sln/trove/core';
|
|
19
|
-
import { createRouter } from './routes.js';
|
|
20
|
+
import { createRouter, routeHelpers } from './routes.js';
|
|
20
21
|
import { createDriveEngine, scanStarter, BACKBONE } from './engine/index.js';
|
|
22
|
+
import { storageRegistry } from './engine/providers/core.js';
|
|
21
23
|
import { createMcpHandler } from './mcp/index.js';
|
|
22
24
|
import { cacheControlFor } from './cachePolicy.js';
|
|
23
25
|
import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
|
|
@@ -51,6 +53,10 @@ export async function createServer(config = {}) {
|
|
|
51
53
|
// 200 lines of statements whose ORDER was the graph is a declaration the
|
|
52
54
|
// container walks, which is also what makes `close()` stop being a hand-kept
|
|
53
55
|
// list that had to agree with it.
|
|
56
|
+
// The driver registry is decided once, here, and shared: the providers build backends
|
|
57
|
+
// from it and /api/capabilities describes it, so the form a user sees and the set of
|
|
58
|
+
// things the server can actually construct cannot drift apart.
|
|
59
|
+
config = { ...config, storageRegistry: storageRegistry(config) };
|
|
54
60
|
const lifecycleState = { closing: false, background: null };
|
|
55
61
|
const engine = createDriveEngine(config, lifecycleState);
|
|
56
62
|
|
|
@@ -60,7 +66,7 @@ export async function createServer(config = {}) {
|
|
|
60
66
|
const backbone = await engine.container.lease(BACKBONE);
|
|
61
67
|
const {
|
|
62
68
|
storage, sqlite: sqliteProvider, metadata, kv, tasks, issues, notifications,
|
|
63
|
-
sidecar, collections, identity, auth, search, vfs, plugins,
|
|
69
|
+
sidecar, collections, identity, auth, search, vfs, plugins, apiKeys, capabilities,
|
|
64
70
|
} = backbone.resources;
|
|
65
71
|
|
|
66
72
|
// Aliased so the rest of this function reads as it did; the container's
|
|
@@ -153,6 +159,68 @@ export async function createServer(config = {}) {
|
|
|
153
159
|
const routeBeginReindex = lifecycleState.background.beginReindex;
|
|
154
160
|
issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
|
|
155
161
|
|
|
162
|
+
// --- storage self-check ----------------------------------------------------
|
|
163
|
+
// The failure this exists for: a bucket with no CORS policy serves the server fine and
|
|
164
|
+
// serves the browser nothing, so the drive looks healthy and every file opens to a
|
|
165
|
+
// spinner. See core/storage/diagnose.js for why the check has to be a real preflight.
|
|
166
|
+
//
|
|
167
|
+
// `origin` is the browser origin to check the policy against, and there is no guessing
|
|
168
|
+
// it: a policy may legitimately name one origin, so checking the wrong one would invent
|
|
169
|
+
// a problem. A request supplies its own; a cron firing has only `config.publicUrl`, and
|
|
170
|
+
// without either the CORS half is skipped rather than assumed.
|
|
171
|
+
const STORAGE_ISSUE_KIND = 'storage';
|
|
172
|
+
async function checkStorage({ origin = null } = {}) {
|
|
173
|
+
// `all()`, not `list(null)`: this has no user, and asking what the anonymous principal
|
|
174
|
+
// may read means checking nothing at all on a drive that is not public.
|
|
175
|
+
const list = collections ? await collections.all().catch(() => []) : [];
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const c of list) {
|
|
178
|
+
let findings;
|
|
179
|
+
try {
|
|
180
|
+
const storage = await collections.storageFor(c.id);
|
|
181
|
+
findings = await diagnoseStorage({
|
|
182
|
+
storage, origin, driver: c.store?.driver || null, fetchImpl: config.fetch,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// Failing to BUILD the store is itself the most severe version of unreachable —
|
|
186
|
+
// an unknown driver or a config missing a required field never gets far enough
|
|
187
|
+
// to be asked whether it can be read.
|
|
188
|
+
findings = [{
|
|
189
|
+
code: 'storage-unreachable',
|
|
190
|
+
severity: 'error',
|
|
191
|
+
title: 'This collection’s store could not be opened',
|
|
192
|
+
detail: err?.message || String(err),
|
|
193
|
+
}];
|
|
194
|
+
}
|
|
195
|
+
const found = new Set(findings.map((f) => f.code));
|
|
196
|
+
for (const f of findings) {
|
|
197
|
+
await issues.raise({
|
|
198
|
+
kind: STORAGE_ISSUE_KIND,
|
|
199
|
+
subject: `${c.id}:${f.code}`,
|
|
200
|
+
title: `${c.name || c.id}: ${f.title}`,
|
|
201
|
+
detail: f.detail,
|
|
202
|
+
remedy: f.remedy || null,
|
|
203
|
+
severity: f.severity,
|
|
204
|
+
collectionId: c.id,
|
|
205
|
+
// Re-running the check IS the fix verification, so Retry rechecks against the
|
|
206
|
+
// same origin the finding was made for. Checking a different one would report
|
|
207
|
+
// a pass for a policy the affected browser still cannot use.
|
|
208
|
+
retry: { op: 'storage-check', origin },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Whatever is no longer true stops being listed. Without this, fixing the bucket
|
|
212
|
+
// leaves the warning up, and a problem list that outlives its problems is one
|
|
213
|
+
// people learn to scroll past.
|
|
214
|
+
for (const code of STORAGE_ISSUE_CODES) {
|
|
215
|
+
if (!found.has(code)) await issues.clear(STORAGE_ISSUE_KIND, `${c.id}:${code}`);
|
|
216
|
+
}
|
|
217
|
+
results.push({ collectionId: c.id, name: c.name || c.id, findings });
|
|
218
|
+
}
|
|
219
|
+
return { checked: results.length, corsChecked: !!origin, results };
|
|
220
|
+
}
|
|
221
|
+
issues.handle('storage-check', (issue) => checkStorage({ origin: issue.retry?.origin || config.publicUrl || null }));
|
|
222
|
+
lifecycleState.storageCheck = checkStorage;
|
|
223
|
+
|
|
156
224
|
issues.handle('reindex-node', (issue) => tasks.run(
|
|
157
225
|
// Carries the issue's collection, so the person who can see the file can also see
|
|
158
226
|
// the task fixing it — a task nobody is allowed to watch is not a task worth having.
|
|
@@ -197,9 +265,9 @@ export async function createServer(config = {}) {
|
|
|
197
265
|
if (config.startFlusher !== false && config.scanIntervalMs) {
|
|
198
266
|
scanTimer = setInterval(() => {
|
|
199
267
|
if (tasks.list().some((t) => t.kind === 'scan' && t.status === 'running')) return; // still going
|
|
200
|
-
Promise.resolve(collections ? collections.list(null).catch(() => []) : [
|
|
268
|
+
Promise.resolve(collections ? collections.list(null).catch(() => []) : [])
|
|
201
269
|
.then(async (list) => {
|
|
202
|
-
for (const c of list
|
|
270
|
+
for (const c of list) {
|
|
203
271
|
await startScan(c.id, { reason: 'Scheduled' }).catch(() => {});
|
|
204
272
|
}
|
|
205
273
|
})
|
|
@@ -210,6 +278,17 @@ export async function createServer(config = {}) {
|
|
|
210
278
|
|
|
211
279
|
const router = createRouter();
|
|
212
280
|
|
|
281
|
+
// Routes contributed by delivery channels — the endpoints a client uses to REGISTER
|
|
282
|
+
// with one, of which a VAPID key and a push subscription are the obvious example.
|
|
283
|
+
// Mounted here rather than declared in routes.js so the drive's API reflects what is
|
|
284
|
+
// actually configured: no web push, no /api/push/*. Added after the core table, so a
|
|
285
|
+
// channel cannot shadow a built-in route by claiming its path.
|
|
286
|
+
for (const channel of notifications?.channels || []) {
|
|
287
|
+
for (const route of channel.routes?.(routeHelpers) || []) {
|
|
288
|
+
router.add(route.method, route.path, route.deps || [], route.handler);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
213
292
|
// Said at boot, because that is when someone is looking and can still fix it. The
|
|
214
293
|
// alternative is discovering it from a client that can't sign in and a 401 that
|
|
215
294
|
// doesn't say why.
|
|
@@ -270,9 +349,20 @@ export async function createServer(config = {}) {
|
|
|
270
349
|
if (url.pathname.startsWith('/api/')) {
|
|
271
350
|
// Authenticate every API request; a bad token is a clean 401, missing is
|
|
272
351
|
// anonymous-or-401 per the provider's policy.
|
|
352
|
+
//
|
|
353
|
+
// A capability grant is resolved FIRST, and when one is found the identity step is
|
|
354
|
+
// skipped entirely. That ordering is the design: an API key answers "what may this
|
|
355
|
+
// request do" and deliberately does not answer "who is this", so there is no
|
|
356
|
+
// principal to attach and nothing downstream can mistake a key for a person.
|
|
357
|
+
//
|
|
358
|
+
// Resolving both would be worse than useless. A request bearing a weak key and a
|
|
359
|
+
// strong session would get the union of the two, which is the confused deputy
|
|
360
|
+
// reached by being accommodating — so it is one or the other, never both.
|
|
273
361
|
let principal = null;
|
|
362
|
+
let grant = null;
|
|
274
363
|
try {
|
|
275
|
-
|
|
364
|
+
grant = await capabilities.resolve(req);
|
|
365
|
+
if (!grant) principal = await identity.authenticate(req);
|
|
276
366
|
} catch (err) {
|
|
277
367
|
const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
|
|
278
368
|
return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
|
|
@@ -284,7 +374,7 @@ export async function createServer(config = {}) {
|
|
|
284
374
|
// locator — nothing recorded what a route used, so nothing stopped it
|
|
285
375
|
// reaching for more.
|
|
286
376
|
container: engine.container,
|
|
287
|
-
config, principal, auth, mcp,
|
|
377
|
+
config, principal, grant, auth, mcp,
|
|
288
378
|
});
|
|
289
379
|
// A route can refuse on its own (a token that verified but names nobody we know,
|
|
290
380
|
// a session that expired between calls). Whatever refused, the answer to "so
|
|
@@ -341,15 +431,40 @@ export async function createServer(config = {}) {
|
|
|
341
431
|
* does, so it does as much as it can and stores where it got to.
|
|
342
432
|
*/
|
|
343
433
|
async function runMaintenance({ budgetMs = 20_000, scan = true } = {}) {
|
|
344
|
-
const out = { swept: false, purged: 0, scans: [] };
|
|
434
|
+
const out = { swept: false, purged: 0, scans: [], notified: 0, storage: 0 };
|
|
345
435
|
await vfs.uploads.sweepExpired(Date.now());
|
|
346
436
|
await sidecar.sweep();
|
|
437
|
+
// Mentions are batched and drained on an interval — a timer, and a timer registered
|
|
438
|
+
// during a request does not outlive it on Workers, where the adapter switches the
|
|
439
|
+
// flusher off for exactly that reason. Nothing else called flush, so on that runtime
|
|
440
|
+
// mentions piled up in the pending store and were never delivered at all: no inbox
|
|
441
|
+
// entry, no push, no error. Maintenance runs from a cron there, which is the one
|
|
442
|
+
// thing that does fire. Harmless where the timer works — concurrent drains collapse.
|
|
443
|
+
out.notified = await notifications.flush().catch((e) => {
|
|
444
|
+
console.error('[trove] mention flush failed', e);
|
|
445
|
+
return 0;
|
|
446
|
+
});
|
|
447
|
+
// Cheap (one preflight per collection) and the only thing that will ever notice a
|
|
448
|
+
// bucket policy that was fine yesterday, so it runs on every firing rather than
|
|
449
|
+
// waiting for someone to open the Activity panel and press a button.
|
|
450
|
+
out.storage = await checkStorage({ origin: config.publicUrl || null })
|
|
451
|
+
.then((r) => r.checked)
|
|
452
|
+
.catch((e) => {
|
|
453
|
+
console.error('[trove] storage check failed', e);
|
|
454
|
+
return 0;
|
|
455
|
+
});
|
|
347
456
|
const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
|
|
348
457
|
if (trashMs > 0) out.purged = (await vfs.purgeTrash({ before: Date.now() - trashMs }))?.purged || 0;
|
|
349
458
|
out.swept = true;
|
|
350
459
|
if (!scan) return out;
|
|
351
|
-
|
|
352
|
-
|
|
460
|
+
// `all()` rather than `list(null)`. Maintenance has no user, and `list(null)` answers
|
|
461
|
+
// "what may the anonymous principal read" — which on any drive that is not open to the
|
|
462
|
+
// public is nothing, so the scheduled scan silently scanned no collection whatsoever.
|
|
463
|
+
// No collections means nothing to scan. It used to mean "scan the one called
|
|
464
|
+
// default", which on a drive that has none is a scan of a collection that does not
|
|
465
|
+
// exist — work that fails every cron firing and reports it as a scan error.
|
|
466
|
+
const targets = collections ? await collections.all().catch(() => []) : [];
|
|
467
|
+
if (!targets.length) return out;
|
|
353
468
|
// Share the budget across collections so one huge bucket can't starve the rest.
|
|
354
469
|
const each = Math.max(1000, Math.floor(budgetMs / targets.length));
|
|
355
470
|
for (const c of targets) {
|
|
@@ -359,7 +474,7 @@ export async function createServer(config = {}) {
|
|
|
359
474
|
return out;
|
|
360
475
|
}
|
|
361
476
|
|
|
362
|
-
return { vfs, handle, router, sidecar, notifications, identity, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
|
|
477
|
+
return { vfs, handle, router, sidecar, notifications, identity, apiKeys, capabilities, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
|
|
363
478
|
// The graph itself, so an action or query can be dispatched directly — by a
|
|
364
479
|
// test, by MCP, by anything that is not an HTTP route.
|
|
365
480
|
engine, engineContainer: engine.container,
|
|
@@ -367,7 +482,7 @@ export async function createServer(config = {}) {
|
|
|
367
482
|
// inside a Durable Object want. `begin*` goes wherever `config.background` says,
|
|
368
483
|
// which for a front-line Worker isolate is the object rather than itself.
|
|
369
484
|
startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
|
|
370
|
-
runMaintenance, mcp, auth, close };
|
|
485
|
+
runMaintenance, checkStorage, mcp, auth, close };
|
|
371
486
|
}
|
|
372
487
|
|
|
373
488
|
/**
|
|
@@ -619,7 +734,17 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
|
|
|
619
734
|
config.kv = { driver: env.TROVE_KV || (config.metadata.driver === 'sqlite' ? 'sqlite' : 'memory'), path: config.metadata.path };
|
|
620
735
|
|
|
621
736
|
// Collections: on by default. Admins (global) + roles that can create collections.
|
|
622
|
-
|
|
737
|
+
// TROVE_COLLECTIONS=false used to mean "one implicit unnamed store, no ACLs". That is
|
|
738
|
+
// precisely the fallback this drive no longer has: every collection-scoped endpoint
|
|
739
|
+
// names its collection in the path, so there is nothing for an unnamed store to answer.
|
|
740
|
+
// Refused loudly rather than ignored — a drive that quietly kept enforcing after being
|
|
741
|
+
// told not to would be a surprise in the wrong direction.
|
|
742
|
+
if (env.TROVE_COLLECTIONS === 'false') {
|
|
743
|
+
throw TroveError.invalid(
|
|
744
|
+
'TROVE_COLLECTIONS=false is no longer supported — endpoints are scoped to a named '
|
|
745
|
+
+ 'collection. Remove the setting; create one collection and use it.',
|
|
746
|
+
);
|
|
747
|
+
}
|
|
623
748
|
config.admins = (env.TROVE_ADMINS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
624
749
|
config.creatorRoles = (env.TROVE_COLLECTION_CREATOR_ROLES || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
625
750
|
// 'default' collection grants everyone all caps unless locked down.
|
|
@@ -98,6 +98,33 @@ async function readText(handle) {
|
|
|
98
98
|
* `ctx` at call time carries { vfs, collections, principal } — the same objects the HTTP
|
|
99
99
|
* routes get, so the two surfaces cannot drift apart on what a given user may do.
|
|
100
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* An agent has to say which collection it means.
|
|
103
|
+
*
|
|
104
|
+
* There used to be a `'default'` here, which made every tool call work on a fresh drive
|
|
105
|
+
* and quietly work on the WRONG collection on a real one — an agent asked to file
|
|
106
|
+
* something would put it wherever the default happened to be, which on a multi-user drive
|
|
107
|
+
* is a collection its user may not even be able to read. Naming it is one extra call to
|
|
108
|
+
* list_collections and removes a whole class of silently-misplaced writes.
|
|
109
|
+
*/
|
|
110
|
+
function requireCollection(collection) {
|
|
111
|
+
if (!collection) {
|
|
112
|
+
throw TroveError.invalid('A collection is required — call list_collections to see which ones you can use');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The same, but only when the reference needs it. A file id or a `trove:` URI already
|
|
118
|
+
* names its collection; a bare NAME is only unique within one.
|
|
119
|
+
*/
|
|
120
|
+
function requireCollectionForName(file, collection) {
|
|
121
|
+
const ref = String(file || '');
|
|
122
|
+
const selfNaming = ref.startsWith('trove:') || /^itm_/.test(ref);
|
|
123
|
+
if (!selfNaming && !collection) {
|
|
124
|
+
throw TroveError.invalid(`"${ref}" is a name, so it needs a collection — or pass a file id or trove: URI`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
export function registerTroveTools(server) {
|
|
102
129
|
server.instructions = INSTRUCTIONS;
|
|
103
130
|
|
|
@@ -152,12 +179,13 @@ export function registerTroveTools(server) {
|
|
|
152
179
|
inputSchema: {
|
|
153
180
|
type: 'object',
|
|
154
181
|
properties: {
|
|
155
|
-
collection: { type: 'string', description: 'Which collection
|
|
182
|
+
collection: { type: 'string', description: 'Which collection to act in. Required — call list_collections first.' },
|
|
156
183
|
cursor: { type: 'string', description: 'Continue from a previous call.' },
|
|
157
184
|
limit: { type: 'integer', description: `Maximum items (default 25, max ${MAX_RESULTS}).` },
|
|
158
185
|
},
|
|
159
186
|
},
|
|
160
|
-
async run({ collection
|
|
187
|
+
async run({ collection, cursor, limit }, ctx) {
|
|
188
|
+
requireCollection(collection);
|
|
161
189
|
// The description promises recency and `list` defaults to alphabetical, so it
|
|
162
190
|
// has to be asked for. An agent answering "what did I add recently?" off the top
|
|
163
191
|
// of an alphabetical list is confidently wrong in a way nothing surfaces.
|
|
@@ -184,11 +212,12 @@ export function registerTroveTools(server) {
|
|
|
184
212
|
type: 'object',
|
|
185
213
|
properties: {
|
|
186
214
|
file: { type: 'string', description: 'A file id, a name, or a trove: URI.' },
|
|
187
|
-
collection: { type: 'string', description: 'Which collection to look in when
|
|
215
|
+
collection: { type: 'string', description: 'Which collection to look in. Required when `file` is a name; a file id or trove: URI names its own.' },
|
|
188
216
|
},
|
|
189
217
|
required: ['file'],
|
|
190
218
|
},
|
|
191
|
-
async run({ file, collection
|
|
219
|
+
async run({ file, collection }, ctx) {
|
|
220
|
+
requireCollectionForName(file, collection);
|
|
192
221
|
const handle = await fileHandle(ctx, file, collection, 'read');
|
|
193
222
|
const node = handle.node;
|
|
194
223
|
if (!textLike(node.contentType)) {
|
|
@@ -216,12 +245,13 @@ export function registerTroveTools(server) {
|
|
|
216
245
|
properties: {
|
|
217
246
|
name: { type: 'string', description: 'The file name, including its extension.' },
|
|
218
247
|
content: { type: 'string', description: 'The text to write.' },
|
|
219
|
-
collection: { type: 'string', description: 'Which collection
|
|
248
|
+
collection: { type: 'string', description: 'Which collection to act in. Required — call list_collections first.' },
|
|
220
249
|
contentType: { type: 'string', description: 'Override the type guessed from the name.' },
|
|
221
250
|
},
|
|
222
251
|
required: ['name', 'content'],
|
|
223
252
|
},
|
|
224
|
-
async run({ name, content, collection
|
|
253
|
+
async run({ name, content, collection, contentType }, ctx) {
|
|
254
|
+
requireCollection(collection);
|
|
225
255
|
if (!name?.trim()) throw TroveError.invalid('name is required');
|
|
226
256
|
const into = await ctx.access.collection(collection, 'write');
|
|
227
257
|
// No contentType falls through to the vfs's own guess from the name.
|
|
@@ -245,7 +275,8 @@ export function registerTroveTools(server) {
|
|
|
245
275
|
},
|
|
246
276
|
required: ['file'],
|
|
247
277
|
},
|
|
248
|
-
async run({ file, collection
|
|
278
|
+
async run({ file, collection }, ctx) {
|
|
279
|
+
requireCollectionForName(file, collection);
|
|
249
280
|
const handle = await fileHandle(ctx, file, collection, 'delete');
|
|
250
281
|
await handle.remove();
|
|
251
282
|
return toolText(`Moved "${handle.name}" to the trash. It can be restored from the drive's trash.`);
|
|
@@ -284,7 +315,8 @@ export function registerTroveTools(server) {
|
|
|
284
315
|
},
|
|
285
316
|
required: ['file'],
|
|
286
317
|
},
|
|
287
|
-
async run({ file, collection
|
|
318
|
+
async run({ file, collection }, ctx) {
|
|
319
|
+
requireCollectionForName(file, collection);
|
|
288
320
|
const handle = await fileHandle(ctx, file, collection, 'read');
|
|
289
321
|
const node = handle.node;
|
|
290
322
|
// Scoped, exactly like the HTTP route. Backlinks reach ACROSS collections by
|
|
@@ -139,7 +139,7 @@ export class Router {
|
|
|
139
139
|
// handler asks for an AUTHORIZED view of a node, collection or upload: the
|
|
140
140
|
// grant is carried by the object it hands back, so there is no unrestricted
|
|
141
141
|
// service and no raw id left over to use with one.
|
|
142
|
-
const scope = leaseScope(ctx.container, ctx.principal);
|
|
142
|
+
const scope = leaseScope(ctx.container, ctx.principal, ctx.grant);
|
|
143
143
|
const access = scope.access;
|
|
144
144
|
try {
|
|
145
145
|
lease = ctx.container ? await ctx.container.lease(found.route.deps) : null;
|