@3sln/trove 0.0.4 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +1 -1
- package/packages/core/src/apiKeys.js +326 -0
- package/packages/core/src/collections/index.js +83 -13
- package/packages/core/src/index.js +14 -2
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +83 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +162 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +47 -5
- package/packages/server/src/engine/providers/core.js +105 -11
- package/packages/server/src/index.js +123 -11
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +135 -32
- package/packages/server/src/scope.js +2 -2
- package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
- package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
- package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +58 -9
- package/packages/web/src/bl/actions.js +112 -13
- package/packages/web/src/bl/activity.js +32 -0
- package/packages/web/src/bl/commands.js +41 -2
- package/packages/web/src/bl/index.js +9 -4
- package/packages/web/src/bl/services.js +78 -1
- package/packages/web/src/platform/api.js +57 -14
- package/packages/web/src/platform/pluginRpc.js +7 -4
- package/packages/web/src/styles.css +137 -0
- package/packages/web/src/ui/components/activityPanel.js +28 -1
- package/packages/web/src/ui/components/collectionGate.js +81 -0
- package/packages/web/src/ui/components/overlays.js +64 -34
- package/packages/web/src/ui/components/phoneChrome.js +2 -2
- package/packages/web/src/ui/components/settingsView.js +197 -1
- package/packages/web/src/ui/components/statusBar.js +19 -2
- package/packages/web/src/ui/compositions/workbench.js +9 -2
- package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// means the same thing it always did.
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
|
-
StorageBackend, MemoryStorage,
|
|
24
|
+
StorageBackend, MemoryStorage, S3Storage,
|
|
25
|
+
StorageDriverRegistry, portableDrivers,
|
|
25
26
|
MetadataStore, MemoryStore, SqliteStore,
|
|
26
27
|
SearchService, EmbeddingProvider, LocalHashEmbedding, HttpEmbedding,
|
|
27
28
|
SearchTransformer, ParsingSearchTransformer, WorkersAiSearchTransformer,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
KeyValueStore, MemoryKV, SqliteKV,
|
|
34
35
|
SqliteProvider, LocalSqliteProvider,
|
|
35
36
|
SidecarService, NotificationCenter, WebPushService, WebPushChannel, NotificationChannel,
|
|
37
|
+
ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
|
|
36
38
|
CollectionService,
|
|
37
39
|
PluginService, PackageStore, StoragePackageStore, SqlitePluginInstallStore,
|
|
38
40
|
IndexerRuntime, InProcessIndexerRuntime, PluginIndexers,
|
|
@@ -49,12 +51,65 @@ import { need } from '../lazy.js';
|
|
|
49
51
|
const resolve = (value, BaseClass, build) =>
|
|
50
52
|
(value instanceof BaseClass ? value : build(value || {}));
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The drivers this deployment can build, as a registry.
|
|
56
|
+
*
|
|
57
|
+
* `config.storageDrivers` either IS a registry or is a list of drivers to add to the
|
|
58
|
+
* portable ones — so a deployment adds Filesystem (Node/Bun), or a driver written
|
|
59
|
+
* entirely outside this package, by naming it at the entry point. What is not registered
|
|
60
|
+
* is not offered and cannot be built.
|
|
61
|
+
*
|
|
62
|
+
* `config.allowedStorageDrivers` (TROVE_STORAGE_DRIVERS) narrows the result to an explicit
|
|
63
|
+
* set. Adding drivers is a code decision made by an entry point, which knows what the
|
|
64
|
+
* runtime can run; REMOVING them is an operator decision about one deployment, and needs
|
|
65
|
+
* to be reachable from configuration alone. A drive on Workers is the case in point: memory
|
|
66
|
+
* is portable, so it is offered, and choosing it there produces a collection that accepts
|
|
67
|
+
* uploads and loses them when the isolate is recycled. `TROVE_STORAGE_DRIVERS=s3` takes it
|
|
68
|
+
* off the menu without a fork of the entry point.
|
|
69
|
+
*
|
|
70
|
+
* A name that matches nothing throws rather than narrowing to nothing — a typo that left a
|
|
71
|
+
* drive with no way to make a collection would be a puzzle, not a message.
|
|
72
|
+
*/
|
|
73
|
+
export function storageRegistry(config = {}) {
|
|
74
|
+
if (config.storageRegistry instanceof StorageDriverRegistry) return config.storageRegistry;
|
|
75
|
+
if (config.storageDrivers instanceof StorageDriverRegistry) return config.storageDrivers;
|
|
76
|
+
const registry = new StorageDriverRegistry(portableDrivers());
|
|
77
|
+
for (const d of config.storageDrivers || []) registry.register(d);
|
|
78
|
+
|
|
79
|
+
const allowed = config.allowedStorageDrivers;
|
|
80
|
+
if (!allowed?.length) return registry;
|
|
81
|
+
const unknown = allowed.filter((k) => !registry.has(k));
|
|
82
|
+
if (unknown.length) {
|
|
83
|
+
throw TroveError.invalid(
|
|
84
|
+
`TROVE_STORAGE_DRIVERS names ${unknown.map((k) => `"${k}"`).join(', ')}, which `
|
|
85
|
+
+ `${unknown.length === 1 ? 'is not a driver' : 'are not drivers'} this deployment has: `
|
|
86
|
+
+ `${registry.keys().join(', ')}`,
|
|
87
|
+
);
|
|
57
88
|
}
|
|
89
|
+
// Rebuilt from the descriptors that survived rather than mutated, so a registry never
|
|
90
|
+
// has to support removal — and the drivers keep their registration order.
|
|
91
|
+
const narrowed = new StorageDriverRegistry();
|
|
92
|
+
for (const key of registry.keys()) {
|
|
93
|
+
if (allowed.includes(key)) narrowed.register(registry.driver(key));
|
|
94
|
+
}
|
|
95
|
+
return narrowed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a backend from a store config.
|
|
100
|
+
*
|
|
101
|
+
* The `default:` arm this replaces returned MemoryStorage, so a typo'd driver produced a
|
|
102
|
+
* store that took writes and lost them at the next restart. Unknown drivers now throw and
|
|
103
|
+
* say what is available.
|
|
104
|
+
*/
|
|
105
|
+
export function buildStorage(cfg, config = {}) {
|
|
106
|
+
// ABSENT is not the same as WRONG, and conflating them is what the old `default:` arm
|
|
107
|
+
// did. No storage configured at all is the zero-config path — `createServer()` with
|
|
108
|
+
// nothing, which is ephemeral by definition — so it gets memory. A driver that was
|
|
109
|
+
// NAMED and is not registered is a mistake, and throws: that is the case where a typo
|
|
110
|
+
// used to buy you a store that took writes and lost them.
|
|
111
|
+
if (!cfg?.driver) return new MemoryStorage();
|
|
112
|
+
return storageRegistry(config).build(cfg);
|
|
58
113
|
}
|
|
59
114
|
|
|
60
115
|
function buildIdentity(cfg) {
|
|
@@ -141,8 +196,15 @@ export function coreProviders(config, lifecycleState) {
|
|
|
141
196
|
beginReindex: (opts) => lifecycleState.background.beginReindex(opts),
|
|
142
197
|
}),
|
|
143
198
|
|
|
199
|
+
// The storage self-check, late-bound for the same reason: it needs `collections` and
|
|
200
|
+
// `issues` from this container, so it is assembled in createServer and reached back
|
|
201
|
+
// into rather than built here.
|
|
202
|
+
storageCheck: Provider.fromSingleton({
|
|
203
|
+
run: (opts) => lifecycleState.storageCheck(opts),
|
|
204
|
+
}),
|
|
205
|
+
|
|
144
206
|
storage: Provider.fromLazySingleton(
|
|
145
|
-
() => resolve(config.storage ?? config.vfs?.storage, StorageBackend, buildStorage),
|
|
207
|
+
() => resolve(config.storage ?? config.vfs?.storage, StorageBackend, (cfg) => buildStorage(cfg, config)),
|
|
146
208
|
),
|
|
147
209
|
|
|
148
210
|
// One shared SQLite provider (a keyed pool) for metadata, kv, and per-plugin
|
|
@@ -310,6 +372,30 @@ export function coreProviders(config, lifecycleState) {
|
|
|
310
372
|
{ deps: ['kv', 'push'] },
|
|
311
373
|
),
|
|
312
374
|
|
|
375
|
+
// The API key store. Keys grant capabilities and no identity — see core/apiKeys.js.
|
|
376
|
+
apiKeys: Provider.fromLazySingleton(
|
|
377
|
+
async (deps) => {
|
|
378
|
+
const { kv } = await need(deps, ['kv']);
|
|
379
|
+
return resolve(config.apiKeys, ApiKeyService, () => new ApiKeyService({ kv }));
|
|
380
|
+
},
|
|
381
|
+
null,
|
|
382
|
+
{ deps: ['kv'] },
|
|
383
|
+
),
|
|
384
|
+
|
|
385
|
+
// How a credential becomes a capability grant. The counterpart to `identity`, and
|
|
386
|
+
// separate from it on purpose: some credentials answer "what may this do" without
|
|
387
|
+
// answering "who is this". Swap it to authorize from something else — a client
|
|
388
|
+
// certificate, a signed webhook, a service mesh header.
|
|
389
|
+
capabilities: Provider.fromLazySingleton(
|
|
390
|
+
async (deps) => {
|
|
391
|
+
const { apiKeys } = await need(deps, ['apiKeys']);
|
|
392
|
+
return resolve(config.capabilities, CapabilityProvider,
|
|
393
|
+
() => new ApiKeyCapabilityProvider({ apiKeys }));
|
|
394
|
+
},
|
|
395
|
+
null,
|
|
396
|
+
{ deps: ['apiKeys'] },
|
|
397
|
+
),
|
|
398
|
+
|
|
313
399
|
notifications: Provider.fromLazySingleton(
|
|
314
400
|
async (deps) => {
|
|
315
401
|
const { kv, notificationChannels } = await need(deps, ['kv', 'notificationChannels']);
|
|
@@ -340,15 +426,23 @@ export function coreProviders(config, lifecycleState) {
|
|
|
340
426
|
),
|
|
341
427
|
|
|
342
428
|
// The ownership + permission boundary; each collection is a store config.
|
|
343
|
-
//
|
|
344
|
-
//
|
|
429
|
+
// There is no "off" any more. Every collection-scoped endpoint names its collection
|
|
430
|
+
// in the path, so a drive with no collection layer has nothing to answer with — and
|
|
431
|
+
// the ACL check standing down because the service is absent was the failure mode this
|
|
432
|
+
// graph was rebuilt to make impossible. Refused here as well as in configFromEnv, so
|
|
433
|
+
// there is one answer whichever way the config arrived.
|
|
345
434
|
collections: Provider.fromLazySingleton(
|
|
346
435
|
async (deps) => {
|
|
347
|
-
if (config.collections === false)
|
|
436
|
+
if (config.collections === false) {
|
|
437
|
+
throw TroveError.invalid(
|
|
438
|
+
'collections: false is no longer supported — endpoints are scoped to a named '
|
|
439
|
+
+ 'collection. Create one collection and use it.',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
348
442
|
const { kv, storage } = await need(deps, ['kv', 'storage']);
|
|
349
443
|
return resolve(config.collections, CollectionService, () => new CollectionService({
|
|
350
444
|
kv,
|
|
351
|
-
storageFactory: (storeConfig) => buildStorage(storeConfig),
|
|
445
|
+
storageFactory: (storeConfig) => buildStorage(storeConfig, config),
|
|
352
446
|
admins: config.admins || [],
|
|
353
447
|
creatorRoles: config.creatorRoles || [],
|
|
354
448
|
defaultOpen: config.defaultOpen !== false,
|
|
@@ -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
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
|
})
|
|
@@ -281,9 +349,20 @@ export async function createServer(config = {}) {
|
|
|
281
349
|
if (url.pathname.startsWith('/api/')) {
|
|
282
350
|
// Authenticate every API request; a bad token is a clean 401, missing is
|
|
283
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.
|
|
284
361
|
let principal = null;
|
|
362
|
+
let grant = null;
|
|
285
363
|
try {
|
|
286
|
-
|
|
364
|
+
grant = await capabilities.resolve(req);
|
|
365
|
+
if (!grant) principal = await identity.authenticate(req);
|
|
287
366
|
} catch (err) {
|
|
288
367
|
const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
|
|
289
368
|
return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
|
|
@@ -295,7 +374,7 @@ export async function createServer(config = {}) {
|
|
|
295
374
|
// locator — nothing recorded what a route used, so nothing stopped it
|
|
296
375
|
// reaching for more.
|
|
297
376
|
container: engine.container,
|
|
298
|
-
config, principal, auth, mcp,
|
|
377
|
+
config, principal, grant, auth, mcp,
|
|
299
378
|
});
|
|
300
379
|
// A route can refuse on its own (a token that verified but names nobody we know,
|
|
301
380
|
// a session that expired between calls). Whatever refused, the answer to "so
|
|
@@ -352,7 +431,7 @@ export async function createServer(config = {}) {
|
|
|
352
431
|
* does, so it does as much as it can and stores where it got to.
|
|
353
432
|
*/
|
|
354
433
|
async function runMaintenance({ budgetMs = 20_000, scan = true } = {}) {
|
|
355
|
-
const out = { swept: false, purged: 0, scans: [], notified: 0 };
|
|
434
|
+
const out = { swept: false, purged: 0, scans: [], notified: 0, storage: 0 };
|
|
356
435
|
await vfs.uploads.sweepExpired(Date.now());
|
|
357
436
|
await sidecar.sweep();
|
|
358
437
|
// Mentions are batched and drained on an interval — a timer, and a timer registered
|
|
@@ -365,12 +444,27 @@ export async function createServer(config = {}) {
|
|
|
365
444
|
console.error('[trove] mention flush failed', e);
|
|
366
445
|
return 0;
|
|
367
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
|
+
});
|
|
368
456
|
const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
|
|
369
457
|
if (trashMs > 0) out.purged = (await vfs.purgeTrash({ before: Date.now() - trashMs }))?.purged || 0;
|
|
370
458
|
out.swept = true;
|
|
371
459
|
if (!scan) return out;
|
|
372
|
-
|
|
373
|
-
|
|
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;
|
|
374
468
|
// Share the budget across collections so one huge bucket can't starve the rest.
|
|
375
469
|
const each = Math.max(1000, Math.floor(budgetMs / targets.length));
|
|
376
470
|
for (const c of targets) {
|
|
@@ -380,7 +474,7 @@ export async function createServer(config = {}) {
|
|
|
380
474
|
return out;
|
|
381
475
|
}
|
|
382
476
|
|
|
383
|
-
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,
|
|
384
478
|
// The graph itself, so an action or query can be dispatched directly — by a
|
|
385
479
|
// test, by MCP, by anything that is not an HTTP route.
|
|
386
480
|
engine, engineContainer: engine.container,
|
|
@@ -388,7 +482,7 @@ export async function createServer(config = {}) {
|
|
|
388
482
|
// inside a Durable Object want. `begin*` goes wherever `config.background` says,
|
|
389
483
|
// which for a front-line Worker isolate is the object rather than itself.
|
|
390
484
|
startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
|
|
391
|
-
runMaintenance, mcp, auth, close };
|
|
485
|
+
runMaintenance, checkStorage, mcp, auth, close };
|
|
392
486
|
}
|
|
393
487
|
|
|
394
488
|
/**
|
|
@@ -530,6 +624,14 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
|
|
|
530
624
|
if (config.storage.driver === 'filesystem') config.storage.root = env.TROVE_FS_ROOT || './data/objects';
|
|
531
625
|
if (config.storage.driver === 's3') config.storage.s3 = s3FromEnv(env, 'TROVE_');
|
|
532
626
|
|
|
627
|
+
// Which store types a COLLECTION may be created on, if not all of the ones this entry
|
|
628
|
+
// point registered. `TROVE_STORAGE` picks the drive's own primary store; this restricts
|
|
629
|
+
// the menu the collection form offers and what `build` will accept — see
|
|
630
|
+
// engine/providers/core.js for why removal is configuration and addition is code.
|
|
631
|
+
if (env.TROVE_STORAGE_DRIVERS) {
|
|
632
|
+
config.allowedStorageDrivers = env.TROVE_STORAGE_DRIVERS.split(',').map((s) => s.trim()).filter(Boolean);
|
|
633
|
+
}
|
|
634
|
+
|
|
533
635
|
config.metadata.driver = env.TROVE_METADATA || (config.storage.driver === 'memory' ? 'memory' : 'sqlite');
|
|
534
636
|
config.metadata.path = env.TROVE_DB_PATH || './data/trove.db';
|
|
535
637
|
|
|
@@ -640,7 +742,17 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
|
|
|
640
742
|
config.kv = { driver: env.TROVE_KV || (config.metadata.driver === 'sqlite' ? 'sqlite' : 'memory'), path: config.metadata.path };
|
|
641
743
|
|
|
642
744
|
// Collections: on by default. Admins (global) + roles that can create collections.
|
|
643
|
-
|
|
745
|
+
// TROVE_COLLECTIONS=false used to mean "one implicit unnamed store, no ACLs". That is
|
|
746
|
+
// precisely the fallback this drive no longer has: every collection-scoped endpoint
|
|
747
|
+
// names its collection in the path, so there is nothing for an unnamed store to answer.
|
|
748
|
+
// Refused loudly rather than ignored — a drive that quietly kept enforcing after being
|
|
749
|
+
// told not to would be a surprise in the wrong direction.
|
|
750
|
+
if (env.TROVE_COLLECTIONS === 'false') {
|
|
751
|
+
throw TroveError.invalid(
|
|
752
|
+
'TROVE_COLLECTIONS=false is no longer supported — endpoints are scoped to a named '
|
|
753
|
+
+ 'collection. Remove the setting; create one collection and use it.',
|
|
754
|
+
);
|
|
755
|
+
}
|
|
644
756
|
config.admins = (env.TROVE_ADMINS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
645
757
|
config.creatorRoles = (env.TROVE_COLLECTION_CREATOR_ROLES || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
646
758
|
// '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;
|