@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// createServer — assemble a Vfs from config and return a single
|
|
2
|
+
// `handle(request) -> Promise<Response>`. Runtime-agnostic: the Node and Worker
|
|
3
|
+
// adapters both just forward their platform request into `handle`.
|
|
4
|
+
//
|
|
5
|
+
// Config selects the pluggable backends. `configFromEnv` maps environment
|
|
6
|
+
// variables to that config so a container needs no code, and static assets
|
|
7
|
+
// (the built web app) can be served by passing an `assets` fetcher.
|
|
8
|
+
|
|
9
|
+
// Only the names this module still touches. Everything that BUILDS a backend moved
|
|
10
|
+
// to engine/providers/core.js when the drive became a dependency graph; the class
|
|
11
|
+
// names that remain are the JSDoc types of `createServer`'s injection surface, which
|
|
12
|
+
// is the part of that duality callers still read.
|
|
13
|
+
import {
|
|
14
|
+
Vfs, StorageBackend, MetadataStore, SearchService, EmbeddingProvider,
|
|
15
|
+
VectorStore, KeywordStore, IndexerRegistry,
|
|
16
|
+
accessHost, TroveError,
|
|
17
|
+
protectedResourceMetadata, challengeHeaders, publicOrigin,
|
|
18
|
+
} from '@3sln/trove/core';
|
|
19
|
+
import { createRouter } from './routes.js';
|
|
20
|
+
import { createDriveEngine, scanStarter, BACKBONE } from './engine/index.js';
|
|
21
|
+
import { createMcpHandler } from './mcp/index.js';
|
|
22
|
+
import { cacheControlFor } from './cachePolicy.js';
|
|
23
|
+
import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
|
|
24
|
+
|
|
25
|
+
// Every backend is pluggable. Each field of `config` accepts EITHER a ready
|
|
26
|
+
// provider instance (pass your own class) OR a `{ driver, ... }` config object
|
|
27
|
+
// that these builders turn into one. `resolve` keeps that duality in one place,
|
|
28
|
+
// so the server constructor is a clean dependency-injection surface and core
|
|
29
|
+
// stays platform-agnostic.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assemble the server. Any backend may be supplied as a provider instance
|
|
35
|
+
* (dependency injection) or a driver-config object.
|
|
36
|
+
* @param {object} config
|
|
37
|
+
* @param {StorageBackend|{driver,root?,s3?}} [config.storage]
|
|
38
|
+
* @param {MetadataStore|{driver,path?}} [config.metadata]
|
|
39
|
+
* @param {EmbeddingProvider|{driver,http?,dimensions?}} [config.embeddings]
|
|
40
|
+
* @param {VectorStore|{driver,qdrant?}} [config.vectorStore] the pluggable vector DB
|
|
41
|
+
* @param {KeywordStore|{driver}} [config.keywordStore] the pluggable lexical index
|
|
42
|
+
* @param {boolean} [config.rebuildIndexOnStart] false to skip the empty-index rebuild
|
|
43
|
+
* @param {SearchService} [config.search] a fully-built search service (overrides the above)
|
|
44
|
+
* @param {IndexerRegistry} [config.indexers]
|
|
45
|
+
* @param {(req: Request) => Promise<Response|null>} [config.assets] static file fetcher
|
|
46
|
+
* @param {object} [config.clientConfig] extra config surfaced at /api/capabilities
|
|
47
|
+
* @returns {Promise<{ vfs: Vfs, handle: (req: Request) => Promise<Response> }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function createServer(config = {}) {
|
|
50
|
+
// The drive is a dependency graph now — see engine/providers/core.js. What was
|
|
51
|
+
// 200 lines of statements whose ORDER was the graph is a declaration the
|
|
52
|
+
// container walks, which is also what makes `close()` stop being a hand-kept
|
|
53
|
+
// list that had to agree with it.
|
|
54
|
+
const lifecycleState = { closing: false, background: null };
|
|
55
|
+
const engine = createDriveEngine(config, lifecycleState);
|
|
56
|
+
|
|
57
|
+
// Obtained up front because they are this function's return value: callers
|
|
58
|
+
// hold `server.vfs` and `server.kv` directly, and a facade handing back
|
|
59
|
+
// promises for them would break every one of them.
|
|
60
|
+
const backbone = await engine.container.lease(BACKBONE);
|
|
61
|
+
const {
|
|
62
|
+
storage, sqlite: sqliteProvider, metadata, kv, tasks, issues, notifications,
|
|
63
|
+
sidecar, collections, identity, auth, search, vfs, plugins,
|
|
64
|
+
} = backbone.resources;
|
|
65
|
+
|
|
66
|
+
// Aliased so the rest of this function reads as it did; the container's
|
|
67
|
+
// `lifecycle` provider is the same flag, which is how a long-running action
|
|
68
|
+
// can ask whether the server is going down without closing over anything.
|
|
69
|
+
const closing = () => lifecycleState.closing;
|
|
70
|
+
|
|
71
|
+
// ONE reindex verb, used by all three callers — the startup rebuild, the manual
|
|
72
|
+
// command, and the retry on a failed-index issue. Written once so a user watching a
|
|
73
|
+
// rebuild sees the same task whichever of them started it, and so "reindex" can't
|
|
74
|
+
// drift into three subtly different operations.
|
|
75
|
+
// `begin*` hands back the task record straight away and the work as a promise;
|
|
76
|
+
// `start*` is the same thing for callers that only want the outcome. A route needs
|
|
77
|
+
// the first — it has to answer with something the client can watch, and it cannot
|
|
78
|
+
// wait for a reindex to finish to find out what that is.
|
|
79
|
+
const beginReindex = async ({ reason, title } = {}) => {
|
|
80
|
+
// Drive-wide, so one claim for the whole drive. Two concurrent full rebuilds do
|
|
81
|
+
// double the work to reach the same place, and on a metered backend that is real
|
|
82
|
+
// money — the same reason the route checks its local task list, made true across
|
|
83
|
+
// processes rather than only within one.
|
|
84
|
+
const token = await kv.acquire('reindex', 'all', 60_000);
|
|
85
|
+
if (!token) {
|
|
86
|
+
return { task: null, alreadyRunning: true, done: Promise.resolve({ alreadyRunning: true, indexed: 0, failed: 0, total: 0, stopped: false }) };
|
|
87
|
+
}
|
|
88
|
+
{
|
|
89
|
+
const begun = tasks.begin(
|
|
90
|
+
{
|
|
91
|
+
kind: 'index',
|
|
92
|
+
title: title || 'Rebuilding the search index',
|
|
93
|
+
detail: reason || null,
|
|
94
|
+
unit: 'items',
|
|
95
|
+
cancellable: true,
|
|
96
|
+
},
|
|
97
|
+
async (task) => {
|
|
98
|
+
let renewedAt = Date.now();
|
|
99
|
+
const result = await vfs.reindexAll({
|
|
100
|
+
// The registry stops at a cancel; `closing` covers a shutdown, which is the
|
|
101
|
+
// same need with no one to click the button.
|
|
102
|
+
shouldStop: () => closing() || task.cancelled,
|
|
103
|
+
onProgress: ({ indexed, failed, total }) => {
|
|
104
|
+
task.progress({
|
|
105
|
+
done: indexed,
|
|
106
|
+
total,
|
|
107
|
+
detail: failed ? `${failed} could not be indexed` : null,
|
|
108
|
+
});
|
|
109
|
+
if (Date.now() - renewedAt < 20_000) return;
|
|
110
|
+
renewedAt = Date.now();
|
|
111
|
+
kv.renew('reindex', 'all', token, 60_000).catch(() => {});
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (result.stopped) throw TroveError.internal('Reindex stopped before it finished');
|
|
115
|
+
return result;
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
// Released when the WORK ends, not when this function returns — it returns as
|
|
119
|
+
// soon as the task exists, which is long before the rebuild is done.
|
|
120
|
+
const done = begun.done.finally(() => kv.release('reindex', 'all', token).catch(() => {}));
|
|
121
|
+
done.catch(() => {});
|
|
122
|
+
return { task: begun.task, alreadyRunning: false, done };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const startReindex = async (opts) => (await beginReindex(opts)).done;
|
|
126
|
+
// Retrying an issue runs the same work as everything else, and reports it the same
|
|
127
|
+
// way. The issue is not cleared here — it is cleared by the indexing that succeeds,
|
|
128
|
+
// so a retry can't report success over a problem that is still there.
|
|
129
|
+
issues.handle('reindex-all', () => startReindex({ reason: 'Retrying after a failed scan' }));
|
|
130
|
+
// Reconcile a collection against what its store actually holds. Same three callers as
|
|
131
|
+
// the reindex — scheduled, manual, and issue retry — through one verb.
|
|
132
|
+
// Where a scan that ran out of time left off. Persisted, because the whole point is
|
|
133
|
+
// that the next invocation — possibly in a different isolate, minutes later — picks
|
|
134
|
+
// the bucket up rather than starting again.
|
|
135
|
+
// SPIKE: scanning runs as an ngin engine — see engine/index.js. `beginScan`
|
|
136
|
+
// keeps the signature it always had, so every caller and every test is
|
|
137
|
+
// untouched, which is what makes the rewrite checkable rather than hopeful.
|
|
138
|
+
const beginScan = scanStarter(engine);
|
|
139
|
+
const startScan = async (collectionId, opts) => (await beginScan(collectionId, opts)).done;
|
|
140
|
+
// Where a route's "start this" goes. Normally straight to the functions above — the
|
|
141
|
+
// process serving the request is also the one that will do the work. On a runtime
|
|
142
|
+
// where that is not true (Workers: an isolate can be discarded the moment the
|
|
143
|
+
// response resolves), `config.background` points them at whatever owns the work
|
|
144
|
+
// instead, and the routes are none the wiser.
|
|
145
|
+
// Bound once the verbs exist. `config.background` is how a deployment says the
|
|
146
|
+
// work runs somewhere else — the Workers adapter points it at a Durable Object
|
|
147
|
+
// — and everything above this line is unaware either way.
|
|
148
|
+
lifecycleState.background = {
|
|
149
|
+
beginScan: config.background?.beginScan || beginScan,
|
|
150
|
+
beginReindex: config.background?.beginReindex || beginReindex,
|
|
151
|
+
};
|
|
152
|
+
const routeBeginScan = lifecycleState.background.beginScan;
|
|
153
|
+
const routeBeginReindex = lifecycleState.background.beginReindex;
|
|
154
|
+
issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
|
|
155
|
+
|
|
156
|
+
issues.handle('reindex-node', (issue) => tasks.run(
|
|
157
|
+
// Carries the issue's collection, so the person who can see the file can also see
|
|
158
|
+
// the task fixing it — a task nobody is allowed to watch is not a task worth having.
|
|
159
|
+
{ kind: 'index', title: 'Re-indexing an item', detail: issue.title, collectionId: issue.collectionId },
|
|
160
|
+
() => vfs.reindexNode(issue.retry.nodeId),
|
|
161
|
+
));
|
|
162
|
+
|
|
163
|
+
const indexRebuild = config.rebuildIndexOnStart === false
|
|
164
|
+
? null
|
|
165
|
+
: rebuildIndexIfLost(vfs, search, startReindex);
|
|
166
|
+
|
|
167
|
+
// Periodic maintenance. Both of these caches are otherwise unbounded: abandoned
|
|
168
|
+
// upload sessions (a client that starts an upload and never finishes) accumulate in
|
|
169
|
+
// the session store forever, and sidecar documents stay resident after their last
|
|
170
|
+
// access. Each has a sweep that had nothing calling it — this is that caller.
|
|
171
|
+
let maintenance = null;
|
|
172
|
+
if (config.startFlusher !== false && config.maintenanceIntervalMs !== 0) {
|
|
173
|
+
const everyMs = config.maintenanceIntervalMs ?? 5 * 60 * 1000;
|
|
174
|
+
// Trash retention. This is the only thing in Trove that destroys data on a timer,
|
|
175
|
+
// so it is opt-outable (TROVE_TRASH_DAYS=0 keeps the trash forever) and it says what
|
|
176
|
+
// it removed. 30 days is the same grace period the drives people are used to give.
|
|
177
|
+
const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
|
|
178
|
+
maintenance = setInterval(() => {
|
|
179
|
+
Promise.resolve(vfs.uploads.sweepExpired(Date.now()))
|
|
180
|
+
// NOT `sidecar.sweep?.()` — that name did not exist on SidecarService, and the
|
|
181
|
+
// optional call turned "evict idle documents" into a no-op for the process's
|
|
182
|
+
// whole lifetime. It exists now, and the `?.` is gone so a rename shows up.
|
|
183
|
+
.then(() => sidecar.sweep())
|
|
184
|
+
.then(() => (trashMs > 0 ? vfs.purgeTrash({ before: Date.now() - trashMs }) : null))
|
|
185
|
+
.then((r) => { if (r?.purged) console.log(`[trove] purged ${r.purged} item(s) from the trash after ${config.trashRetentionDays ?? 30} days`); })
|
|
186
|
+
.catch((e) => console.error('maintenance sweep failed', e));
|
|
187
|
+
}, everyMs);
|
|
188
|
+
maintenance.unref?.();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Periodic reconciliation with the store. OFF by default (TROVE_SCAN_INTERVAL_MS):
|
|
192
|
+
// a scan lists every object in the bucket, which on a large drive is real money on a
|
|
193
|
+
// metered API and real load on a NAS. A deployment that shares its bucket with other
|
|
194
|
+
// tools wants this on; one where Trove is the only writer doesn't need it at all, and
|
|
195
|
+
// can scan on demand instead.
|
|
196
|
+
let scanTimer = null;
|
|
197
|
+
if (config.startFlusher !== false && config.scanIntervalMs) {
|
|
198
|
+
scanTimer = setInterval(() => {
|
|
199
|
+
if (tasks.list().some((t) => t.kind === 'scan' && t.status === 'running')) return; // still going
|
|
200
|
+
Promise.resolve(collections ? collections.list(null).catch(() => []) : [{ id: 'default' }])
|
|
201
|
+
.then(async (list) => {
|
|
202
|
+
for (const c of list.length ? list : [{ id: 'default' }]) {
|
|
203
|
+
await startScan(c.id, { reason: 'Scheduled' }).catch(() => {});
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
.catch((e) => console.error('scheduled scan failed', e));
|
|
207
|
+
}, config.scanIntervalMs);
|
|
208
|
+
scanTimer.unref?.();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const router = createRouter();
|
|
212
|
+
|
|
213
|
+
// Said at boot, because that is when someone is looking and can still fix it. The
|
|
214
|
+
// alternative is discovering it from a client that can't sign in and a 401 that
|
|
215
|
+
// doesn't say why.
|
|
216
|
+
for (const w of auth.warnings || []) console.warn(`[trove] ${w}`);
|
|
217
|
+
|
|
218
|
+
// MCP: the same drive, the same identity, spoken to by an agent instead of a browser.
|
|
219
|
+
// Null when switched off, and then nothing below routes to it.
|
|
220
|
+
const mcp = createMcpHandler({
|
|
221
|
+
vfs, collections, identity, config, auth,
|
|
222
|
+
// So an agent's tool call obtains the same authorized handles an HTTP route does.
|
|
223
|
+
container: engine.container,
|
|
224
|
+
version: config.version || '0.0.1',
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
async function handle(req) {
|
|
228
|
+
const url = new URL(req.url);
|
|
229
|
+
|
|
230
|
+
// The drive's own protected-resource metadata. Same document MCP serves for its
|
|
231
|
+
// endpoint, describing the drive instead — because "where do I sign in" has one
|
|
232
|
+
// answer here and a client that found the drive should not have to know that MCP
|
|
233
|
+
// exists to get it. Unauthenticated, necessarily: it is the way in.
|
|
234
|
+
if (url.pathname === '/.well-known/oauth-protected-resource') {
|
|
235
|
+
return new Response(JSON.stringify(protectedResourceMetadata(publicOrigin(req, config), auth)), {
|
|
236
|
+
status: 200,
|
|
237
|
+
headers: {
|
|
238
|
+
'content-type': 'application/json',
|
|
239
|
+
'cache-control': 'public, max-age=3600',
|
|
240
|
+
'access-control-allow-origin': '*',
|
|
241
|
+
'x-content-type-options': 'nosniff',
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The installed-app identity, generated rather than served from a file so an
|
|
247
|
+
// operator can put their own name on it (see manifest.js). Ahead of the assets for
|
|
248
|
+
// the obvious reason and unauthenticated for the same reason as the icon: a browser
|
|
249
|
+
// fetches it before anyone has signed in, and a 401 here just means the app cannot
|
|
250
|
+
// be installed.
|
|
251
|
+
if (url.pathname === MANIFEST_PATH) {
|
|
252
|
+
return new Response(JSON.stringify(webManifest(config.manifest), null, 2), {
|
|
253
|
+
status: 200,
|
|
254
|
+
headers: {
|
|
255
|
+
'content-type': 'application/manifest+json',
|
|
256
|
+
// A stable name whose contents change with configuration — exactly the case
|
|
257
|
+
// `immutable` must never be claimed for.
|
|
258
|
+
'cache-control': cacheControlFor(url.pathname),
|
|
259
|
+
'x-content-type-options': 'nosniff',
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Before the API check: the MCP endpoint and its discovery document live outside
|
|
265
|
+
// /api/ because an agent is given ONE URL and everything it needs must hang off it.
|
|
266
|
+
if (mcp) {
|
|
267
|
+
const res = await mcp.handle(req, url);
|
|
268
|
+
if (res) return res;
|
|
269
|
+
}
|
|
270
|
+
if (url.pathname.startsWith('/api/')) {
|
|
271
|
+
// Authenticate every API request; a bad token is a clean 401, missing is
|
|
272
|
+
// anonymous-or-401 per the provider's policy.
|
|
273
|
+
let principal = null;
|
|
274
|
+
try {
|
|
275
|
+
principal = await identity.authenticate(req);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
|
|
278
|
+
return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
|
|
279
|
+
}
|
|
280
|
+
const res = await router.handle(req, {
|
|
281
|
+
// Per-request, and nothing else: the resources a route needs come from
|
|
282
|
+
// the container, by the names that route declared. Handing over `vfs`,
|
|
283
|
+
// `plugins`, `kv`, `sqlite` and the rest to every handler was a service
|
|
284
|
+
// locator — nothing recorded what a route used, so nothing stopped it
|
|
285
|
+
// reaching for more.
|
|
286
|
+
container: engine.container,
|
|
287
|
+
config, principal, auth, mcp,
|
|
288
|
+
});
|
|
289
|
+
// A route can refuse on its own (a token that verified but names nobody we know,
|
|
290
|
+
// a session that expired between calls). Whatever refused, the answer to "so
|
|
291
|
+
// where do I sign in" is the same one, so it is attached in one place rather
|
|
292
|
+
// than at every throw site.
|
|
293
|
+
return withChallenge(res, req);
|
|
294
|
+
}
|
|
295
|
+
if (config.assets) {
|
|
296
|
+
const asset = await config.assets(req);
|
|
297
|
+
if (asset) return hardenAsset(asset, config, req);
|
|
298
|
+
}
|
|
299
|
+
return new Response('Not found', { status: 404 });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Attach the sign-in directions to a 401 that doesn't already carry them. */
|
|
303
|
+
function withChallenge(res, req) {
|
|
304
|
+
if (res.status !== 401 || res.headers.has('www-authenticate')) return res;
|
|
305
|
+
const headers = challengeHeaders(publicOrigin(req, config), auth);
|
|
306
|
+
for (const [k, v] of Object.entries(headers)) res.headers.set(k, v);
|
|
307
|
+
// Without this a browser can't read the header cross-origin, which is exactly the
|
|
308
|
+
// case where a client most needs it.
|
|
309
|
+
const expose = res.headers.get('access-control-expose-headers');
|
|
310
|
+
res.headers.set('access-control-expose-headers', expose ? `${expose}, www-authenticate` : 'www-authenticate');
|
|
311
|
+
return res;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function close() {
|
|
315
|
+
// Tell an in-flight index rebuild to stop before the database goes away, and let it
|
|
316
|
+
// unwind — otherwise every remaining file fails against a closing handle. Stopping
|
|
317
|
+
// early is safe: a half-built index is still an empty-looking one, so the next
|
|
318
|
+
// start rebuilds it.
|
|
319
|
+
lifecycleState.closing = true;
|
|
320
|
+
await indexRebuild?.catch(() => {});
|
|
321
|
+
if (maintenance) clearInterval(maintenance);
|
|
322
|
+
if (scanTimer) clearInterval(scanTimer);
|
|
323
|
+
// Everything else is the container's: it disposes in reverse construction
|
|
324
|
+
// order, so each resource goes down before the ones it was built from. That
|
|
325
|
+
// used to be a hand-written list here which had to agree with a build order
|
|
326
|
+
// two hundred lines above it, and nothing checked that it did.
|
|
327
|
+
await backbone.release();
|
|
328
|
+
await engine.dispose();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* One slice of the periodic work, for a runtime with no timers.
|
|
333
|
+
*
|
|
334
|
+
* `setInterval` is how a long-lived process does this, and it is exactly wrong on
|
|
335
|
+
* Cloudflare Workers: a timer registered inside a request does not survive the
|
|
336
|
+
* request, so the maintenance and scan intervals below simply never fire there.
|
|
337
|
+
* A Worker's answer is a Cron Trigger calling this, awaited inside the `scheduled`
|
|
338
|
+
* handler so the runtime keeps the isolate alive until it settles.
|
|
339
|
+
*
|
|
340
|
+
* `budgetMs` bounds the scan slice — a bucket has no size limit and an invocation
|
|
341
|
+
* does, so it does as much as it can and stores where it got to.
|
|
342
|
+
*/
|
|
343
|
+
async function runMaintenance({ budgetMs = 20_000, scan = true } = {}) {
|
|
344
|
+
const out = { swept: false, purged: 0, scans: [] };
|
|
345
|
+
await vfs.uploads.sweepExpired(Date.now());
|
|
346
|
+
await sidecar.sweep();
|
|
347
|
+
const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
|
|
348
|
+
if (trashMs > 0) out.purged = (await vfs.purgeTrash({ before: Date.now() - trashMs }))?.purged || 0;
|
|
349
|
+
out.swept = true;
|
|
350
|
+
if (!scan) return out;
|
|
351
|
+
const list = collections ? await collections.list(null).catch(() => []) : [];
|
|
352
|
+
const targets = list.length ? list : [{ id: 'default' }];
|
|
353
|
+
// Share the budget across collections so one huge bucket can't starve the rest.
|
|
354
|
+
const each = Math.max(1000, Math.floor(budgetMs / targets.length));
|
|
355
|
+
for (const c of targets) {
|
|
356
|
+
const r = await startScan(c.id, { reason: 'Scheduled', deadlineMs: each }).catch((e) => ({ error: e.message }));
|
|
357
|
+
out.scans.push({ collectionId: c.id, ...r });
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { vfs, handle, router, sidecar, notifications, identity, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
|
|
363
|
+
// The graph itself, so an action or query can be dispatched directly — by a
|
|
364
|
+
// test, by MCP, by anything that is not an HTTP route.
|
|
365
|
+
engine, engineContainer: engine.container,
|
|
366
|
+
// `start*` always runs the work HERE — that is what maintenance and the alarm loop
|
|
367
|
+
// inside a Durable Object want. `begin*` goes wherever `config.background` says,
|
|
368
|
+
// which for a front-line Worker isolate is the object rather than itself.
|
|
369
|
+
startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
|
|
370
|
+
runMaintenance, mcp, auth, close };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Rebuild the search index when it is empty but the drive is not. Resolves to null
|
|
375
|
+
* when no rebuild was needed (the common case — every start after the first).
|
|
376
|
+
* @returns {Promise<{indexed:number, failed:number}|null>}
|
|
377
|
+
*/
|
|
378
|
+
async function rebuildIndexIfLost(vfs, search, startReindex) {
|
|
379
|
+
try {
|
|
380
|
+
if (!search?.looksUnindexed) return null;
|
|
381
|
+
// Cheapest question first: an index that reports documents needs nothing, and a
|
|
382
|
+
// store that can't report (null) is never taken as evidence that it's empty.
|
|
383
|
+
if ((await search.looksUnindexed()) !== true) return null;
|
|
384
|
+
// …then the one that costs a query: is there anything to rebuild FROM?
|
|
385
|
+
if (!(await vfs.metadata.scanItems({ limit: 1 })).length) return null;
|
|
386
|
+
|
|
387
|
+
console.warn('[trove] the search index is empty but the drive is not — rebuilding it in the background');
|
|
388
|
+
const started = Date.now();
|
|
389
|
+
// Goes through the task registry like every other reindex, so a user who opens the
|
|
390
|
+
// app mid-rebuild sees it running rather than a drive that mysteriously finds
|
|
391
|
+
// nothing.
|
|
392
|
+
const result = await startReindex({ reason: 'The index was empty and the drive was not' });
|
|
393
|
+
console.log(`[trove] search index rebuilt: ${result.indexed} items in ${Date.now() - started}ms${result.failed ? `, ${result.failed} failed` : ''}`);
|
|
394
|
+
return result;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
// A failed or interrupted rebuild leaves a searchless-but-working drive; that has
|
|
397
|
+
// to be said out loud, not swallowed into an unhandled rejection. The task record
|
|
398
|
+
// already carries it for the UI; this is for the operator's log.
|
|
399
|
+
console.warn('[trove] search index rebuild did not complete — items may not be findable:', err.message);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// A CSP starting point for deployments that DON'T rely on sandboxed plugins (opt in
|
|
405
|
+
// via TROVE_CSP). It is deliberately not shipped by default: Trove runs plugins in
|
|
406
|
+
// sandboxed, opaque-origin `srcdoc` iframes, which no `frame-src` source expression
|
|
407
|
+
// can match, so a strict shell CSP would break every plugin. The concrete
|
|
408
|
+
// same-origin XSS risk (opening an uploaded .html/.svg) is instead closed by forcing
|
|
409
|
+
// non-inline-safe downloads to `Content-Disposition: attachment` (see routes.js).
|
|
410
|
+
export const SAMPLE_CSP = [
|
|
411
|
+
"default-src 'self'",
|
|
412
|
+
"base-uri 'self'",
|
|
413
|
+
"object-src 'none'",
|
|
414
|
+
"img-src 'self' data: blob:",
|
|
415
|
+
"media-src 'self' blob:",
|
|
416
|
+
"style-src 'self' 'unsafe-inline'",
|
|
417
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
418
|
+
"connect-src 'self'",
|
|
419
|
+
].join('; ');
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Warn (once, to the console) when a configuration is world-open — anonymous auth
|
|
423
|
+
* plus the default collection granting everyone every capability. Safe on
|
|
424
|
+
* localhost, dangerous when exposed. Called by the runnable adapters at startup.
|
|
425
|
+
*/
|
|
426
|
+
export function warnOnOpenAccess(config = {}) {
|
|
427
|
+
const anon = !config.identity || config.identity.driver === 'anonymous' || config.identity === 'anonymous';
|
|
428
|
+
const open = config.collections !== false && config.defaultOpen !== false;
|
|
429
|
+
if (anon && open) {
|
|
430
|
+
console.warn(
|
|
431
|
+
'[trove] SECURITY: anonymous auth + open default collection — anyone who can reach '
|
|
432
|
+
+ 'this server has full read/write/delete access. Set TROVE_AUTH (+ TROVE_AUTH_REQUIRED=true) '
|
|
433
|
+
+ 'and/or TROVE_DEFAULT_OPEN=false, and run behind an authenticating reverse proxy.',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Add security headers to a static/app-shell response (CSP only if configured). */
|
|
439
|
+
function hardenAsset(res, config = {}, req) {
|
|
440
|
+
res.headers.set('x-content-type-options', 'nosniff');
|
|
441
|
+
res.headers.set('x-frame-options', 'SAMEORIGIN');
|
|
442
|
+
res.headers.set('referrer-policy', 'no-referrer');
|
|
443
|
+
if (typeof config.csp === 'string') res.headers.set('content-security-policy', config.csp);
|
|
444
|
+
|
|
445
|
+
// A floor, not an override. The Node and Bun file server already decides this per
|
|
446
|
+
// request — it has to, because an index.html served as an SPA fallback must be
|
|
447
|
+
// revalidated even though the file it came from could not be. What is left is the
|
|
448
|
+
// Workers path, where assets come from a binding that applies Cloudflare's defaults
|
|
449
|
+
// rather than the /assets/ convention this repository's build guarantees.
|
|
450
|
+
if (req && !res.headers.has('cache-control')) {
|
|
451
|
+
try {
|
|
452
|
+
res.headers.set('cache-control', cacheControlFor(decodeURIComponent(new URL(req.url).pathname)));
|
|
453
|
+
} catch { /* a path that will not decode names nothing worth caching */ }
|
|
454
|
+
}
|
|
455
|
+
return res;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Parse a JWKS supplied inline as JSON.
|
|
460
|
+
*
|
|
461
|
+
* Inline only, because this module has to load unchanged on Cloudflare Workers, where
|
|
462
|
+
* there is no filesystem to read a path from. Reading a KEY FILE is a Node/Bun concern
|
|
463
|
+
* and lives in those adapters (TROVE_JWT_JWKS_FILE), which set this var before calling.
|
|
464
|
+
*
|
|
465
|
+
* A bad value throws at STARTUP rather than on the first request: a server that boots
|
|
466
|
+
* with unreadable key material would authenticate nobody while looking perfectly
|
|
467
|
+
* healthy, and would only admit it when someone tried to sign in.
|
|
468
|
+
*/
|
|
469
|
+
function parseJwks(value) {
|
|
470
|
+
if (!value) return undefined;
|
|
471
|
+
try {
|
|
472
|
+
const parsed = JSON.parse(value);
|
|
473
|
+
if (!parsed || (!Array.isArray(parsed) && !Array.isArray(parsed.keys))) {
|
|
474
|
+
throw new Error('expected a JWKS document ({ keys: [...] }) or a bare array of JWKs');
|
|
475
|
+
}
|
|
476
|
+
return parsed;
|
|
477
|
+
} catch (err) {
|
|
478
|
+
throw TroveError.invalid(`TROVE_JWT_JWKS is not a usable key set: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Map process.env → createServer config. */
|
|
483
|
+
// Build an S3 config block from env vars under `prefix` (e.g. 'TROVE_' or
|
|
484
|
+
// 'TROVE_PACKAGE_'), falling back through `fallbacks` prefixes then the standard AWS_*
|
|
485
|
+
// vars for credentials — so the same mapping serves primary storage and the package
|
|
486
|
+
// store without copy-paste.
|
|
487
|
+
function s3FromEnv(env, prefix, fallbacks = []) {
|
|
488
|
+
const pick = (suffix, ...extra) => {
|
|
489
|
+
for (const k of [prefix + suffix, ...fallbacks.map((f) => f + suffix), ...extra]) {
|
|
490
|
+
if (env[k] != null && env[k] !== '') return env[k];
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
bucket: pick('S3_BUCKET'),
|
|
496
|
+
region: pick('S3_REGION') || 'us-east-1',
|
|
497
|
+
endpoint: pick('S3_ENDPOINT'),
|
|
498
|
+
accessKeyId: pick('S3_ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID'),
|
|
499
|
+
secretAccessKey: pick('S3_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY'),
|
|
500
|
+
sessionToken: pick('S3_SESSION_TOKEN'),
|
|
501
|
+
forcePathStyle: pick('S3_PATH_STYLE') === 'true',
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function configFromEnv(env = (typeof process !== 'undefined' ? process.env : {})) {
|
|
506
|
+
const config = { storage: {}, metadata: {}, embeddings: {}, vectorStore: {} };
|
|
507
|
+
|
|
508
|
+
config.storage.driver = env.TROVE_STORAGE || 'memory';
|
|
509
|
+
if (config.storage.driver === 'filesystem') config.storage.root = env.TROVE_FS_ROOT || './data/objects';
|
|
510
|
+
if (config.storage.driver === 's3') config.storage.s3 = s3FromEnv(env, 'TROVE_');
|
|
511
|
+
|
|
512
|
+
config.metadata.driver = env.TROVE_METADATA || (config.storage.driver === 'memory' ? 'memory' : 'sqlite');
|
|
513
|
+
config.metadata.path = env.TROVE_DB_PATH || './data/trove.db';
|
|
514
|
+
|
|
515
|
+
if (env.TROVE_EMBEDDINGS_URL) {
|
|
516
|
+
config.embeddings.driver = 'http';
|
|
517
|
+
config.embeddings.http = {
|
|
518
|
+
url: env.TROVE_EMBEDDINGS_URL,
|
|
519
|
+
apiKey: env.TROVE_EMBEDDINGS_API_KEY,
|
|
520
|
+
model: env.TROVE_EMBEDDINGS_MODEL,
|
|
521
|
+
dimensions: Number(env.TROVE_EMBEDDINGS_DIM || 1536),
|
|
522
|
+
};
|
|
523
|
+
} else {
|
|
524
|
+
config.embeddings.driver = 'local';
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Pluggable search stores. Deliberately left UNSET unless asked for: createServer
|
|
528
|
+
// picks 'sqlite' or 'memory' from the SQLite provider it actually resolved, which is
|
|
529
|
+
// the only thing that knows whether there's a durable database to write into (a
|
|
530
|
+
// Worker supplying its own metadata store must not get a local SQLite index).
|
|
531
|
+
// TROVE_VECTOR = sqlite | memory | qdrant | vectorize
|
|
532
|
+
// TROVE_KEYWORD = sqlite | memory
|
|
533
|
+
if (env.TROVE_VECTOR) config.vectorStore.driver = env.TROVE_VECTOR;
|
|
534
|
+
if (env.TROVE_KEYWORD) config.keywordStore = { driver: env.TROVE_KEYWORD };
|
|
535
|
+
if (config.vectorStore.driver === 'qdrant') {
|
|
536
|
+
config.vectorStore.qdrant = {
|
|
537
|
+
url: env.TROVE_QDRANT_URL || 'http://localhost:6333',
|
|
538
|
+
collection: env.TROVE_QDRANT_COLLECTION || 'trove',
|
|
539
|
+
apiKey: env.TROVE_QDRANT_API_KEY,
|
|
540
|
+
distance: env.TROVE_QDRANT_DISTANCE || 'Cosine',
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (config.vectorStore.driver === 'vectorize') {
|
|
544
|
+
// On Workers the binding is injected by the adapter; over REST use API creds.
|
|
545
|
+
config.vectorStore.vectorize = {
|
|
546
|
+
accountId: env.TROVE_VECTORIZE_ACCOUNT_ID || env.CF_ACCOUNT_ID,
|
|
547
|
+
apiKey: env.TROVE_VECTORIZE_API_TOKEN,
|
|
548
|
+
indexName: env.TROVE_VECTORIZE_INDEX || 'trove',
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Where a refused client is told to sign in — one value for the whole drive, used by
|
|
553
|
+
// every 401 the API returns and by the MCP discovery document alike. Left unset it
|
|
554
|
+
// falls back to TROVE_JWT_ISSUER below, which for essentially every OIDC provider is
|
|
555
|
+
// the same URL; set it when they genuinely differ.
|
|
556
|
+
if (env.TROVE_AUTH_SERVER) config.authServer = env.TROVE_AUTH_SERVER;
|
|
557
|
+
// The drive's own public URL. Behind a proxy the socket says http://internal:8787,
|
|
558
|
+
// which is no use in a discovery document — but X-Forwarded-* is set by whoever is
|
|
559
|
+
// talking to us unless a proxy is guaranteed to be in front, so honouring it is opt-in.
|
|
560
|
+
if (env.TROVE_PUBLIC_URL) config.publicUrl = env.TROVE_PUBLIC_URL;
|
|
561
|
+
if (env.TROVE_TRUST_PROXY != null) config.trustProxy = !/^(0|off|false|no)$/i.test(String(env.TROVE_TRUST_PROXY));
|
|
562
|
+
|
|
563
|
+
// Identity: default anonymous; 'jwt' for a generic IdP, 'cloudflare-access' for Zero
|
|
564
|
+
// Trust (which only needs the team name).
|
|
565
|
+
config.identity = { driver: env.TROVE_AUTH || 'anonymous' };
|
|
566
|
+
if (config.identity.driver === 'cloudflare-access') {
|
|
567
|
+
config.identity.access = {
|
|
568
|
+
team: env.TROVE_CF_ACCESS_TEAM,
|
|
569
|
+
// The Access application's AUD tag. The one value that can't be derived from the
|
|
570
|
+
// team, and the one that stops a token minted for a DIFFERENT application in the
|
|
571
|
+
// same Access account from being accepted here.
|
|
572
|
+
audience: env.TROVE_CF_ACCESS_AUD,
|
|
573
|
+
required: env.TROVE_AUTH_REQUIRED !== 'false',
|
|
574
|
+
};
|
|
575
|
+
// Access is also where agents sign in (managed OAuth), and that is the same domain.
|
|
576
|
+
// Derived rather than asked for again — see resolveAuthDiscovery's issuer fallback,
|
|
577
|
+
// which this is just making explicit and immune to the issuer being unset.
|
|
578
|
+
if (!config.authServer && env.TROVE_CF_ACCESS_TEAM) {
|
|
579
|
+
config.authServer = `https://${accessHost(env.TROVE_CF_ACCESS_TEAM)}`;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (config.identity.driver === 'jwt') {
|
|
583
|
+
config.identity.jwt = {
|
|
584
|
+
jwksUrl: env.TROVE_JWKS_URL, // e.g. https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
|
|
585
|
+
// The keychain: the keys this deployment trusts, named directly. A JWKS URL
|
|
586
|
+
// assumes someone is running an endpoint to serve one, which a deployment that
|
|
587
|
+
// mints its own tokens has no reason to do. Accepts inline JSON or a path to a
|
|
588
|
+
// file — the file form keeps a multi-line document out of the environment, and
|
|
589
|
+
// out of `docker inspect`.
|
|
590
|
+
jwks: parseJwks(env.TROVE_JWT_JWKS),
|
|
591
|
+
issuer: env.TROVE_JWT_ISSUER,
|
|
592
|
+
audience: env.TROVE_JWT_AUDIENCE, // the Access application AUD
|
|
593
|
+
secret: env.TROVE_JWT_SECRET, // HS256 dev only
|
|
594
|
+
// Explicit allowlist. Without one, verifyJwt infers it from the key material
|
|
595
|
+
// (HS256 for a secret, RS256/ES256 for a key set), which is the safe default —
|
|
596
|
+
// set this only to narrow it further.
|
|
597
|
+
algorithms: env.TROVE_JWT_ALGS ? env.TROVE_JWT_ALGS.split(',').map((a) => a.trim()).filter(Boolean) : undefined,
|
|
598
|
+
required: env.TROVE_AUTH_REQUIRED === 'true',
|
|
599
|
+
};
|
|
600
|
+
} else if (config.identity.driver === 'header') {
|
|
601
|
+
config.identity.header = {
|
|
602
|
+
idHeader: env.TROVE_AUTH_ID_HEADER || 'cf-access-authenticated-user-email',
|
|
603
|
+
emailHeader: env.TROVE_AUTH_EMAIL_HEADER || 'cf-access-authenticated-user-email',
|
|
604
|
+
required: env.TROVE_AUTH_REQUIRED === 'true',
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Web push (VAPID) for mention notifications — optional.
|
|
609
|
+
if (env.TROVE_VAPID_PUBLIC_KEY && env.TROVE_VAPID_PRIVATE_KEY) {
|
|
610
|
+
config.vapid = {
|
|
611
|
+
publicKey: env.TROVE_VAPID_PUBLIC_KEY,
|
|
612
|
+
privateKey: env.TROVE_VAPID_PRIVATE_KEY,
|
|
613
|
+
subject: env.TROVE_VAPID_SUBJECT || 'mailto:admin@example.com',
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (env.TROVE_MENTION_FLUSH_MS) config.mentionFlushMs = Number(env.TROVE_MENTION_FLUSH_MS);
|
|
617
|
+
|
|
618
|
+
// KV store for subscriptions/inboxes: follows the metadata driver by default.
|
|
619
|
+
config.kv = { driver: env.TROVE_KV || (config.metadata.driver === 'sqlite' ? 'sqlite' : 'memory'), path: config.metadata.path };
|
|
620
|
+
|
|
621
|
+
// Collections: on by default. Admins (global) + roles that can create collections.
|
|
622
|
+
if (env.TROVE_COLLECTIONS === 'false') config.collections = false;
|
|
623
|
+
config.admins = (env.TROVE_ADMINS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
624
|
+
config.creatorRoles = (env.TROVE_COLLECTION_CREATOR_ROLES || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
625
|
+
// 'default' collection grants everyone all caps unless locked down.
|
|
626
|
+
config.defaultOpen = env.TROVE_DEFAULT_OPEN !== 'false';
|
|
627
|
+
|
|
628
|
+
// How long a deleted item stays recoverable. 0 keeps the trash forever — the only
|
|
629
|
+
// setting here that can cause data loss, so it is explicit rather than inferred.
|
|
630
|
+
if (env.TROVE_TRASH_DAYS != null && env.TROVE_TRASH_DAYS !== '') config.trashRetentionDays = Number(env.TROVE_TRASH_DAYS);
|
|
631
|
+
|
|
632
|
+
// Reconcile with the object store on a timer. Off unless set: a scan lists the whole
|
|
633
|
+
// bucket, which costs API calls and load. Turn it on when something other than Trove
|
|
634
|
+
// writes to the same bucket.
|
|
635
|
+
if (env.TROVE_SCAN_INTERVAL_MS) config.scanIntervalMs = Number(env.TROVE_SCAN_INTERVAL_MS);
|
|
636
|
+
|
|
637
|
+
// Sweeping abandoned upload sessions and cold sidecars. Both caches are otherwise
|
|
638
|
+
// unbounded, so this is a knob rather than a switch — 0 turns it off entirely.
|
|
639
|
+
if (env.TROVE_MAINTENANCE_INTERVAL_MS != null && env.TROVE_MAINTENANCE_INTERVAL_MS !== '') {
|
|
640
|
+
config.maintenanceIntervalMs = Number(env.TROVE_MAINTENANCE_INTERVAL_MS);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Rebuilding the index at startup when it is empty and the drive is not. Wanted almost
|
|
644
|
+
// always — an empty index in a search-first app reads as data loss — but on a very
|
|
645
|
+
// large drive an operator may want to schedule it instead of paying for it on boot.
|
|
646
|
+
if (env.TROVE_REBUILD_INDEX_ON_START != null && env.TROVE_REBUILD_INDEX_ON_START !== '') {
|
|
647
|
+
config.rebuildIndexOnStart = !/^(0|off|false|no)$/i.test(String(env.TROVE_REBUILD_INDEX_ON_START));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Per-file upload quota (bytes). Unbounded unless set.
|
|
651
|
+
if (env.TROVE_MAX_UPLOAD_BYTES) config.maxUploadBytes = Number(env.TROVE_MAX_UPLOAD_BYTES);
|
|
652
|
+
|
|
653
|
+
// Deny plugin API calls with no server install record (fully closes the "any client
|
|
654
|
+
// can name any pluginId" gap). Off by default for back-compat with pre-existing
|
|
655
|
+
// local-only installs; flip on once clients have re-uploaded their account plugins.
|
|
656
|
+
if (env.TROVE_ENFORCE_PLUGIN_CAPS === 'true') config.enforcePluginCaps = true;
|
|
657
|
+
|
|
658
|
+
// Server indexer sub-packages run in-process (trusted; admin-gated at install).
|
|
659
|
+
// TROVE_SERVER_INDEXERS=0/false refuses server-indexer plugins on this deployment.
|
|
660
|
+
if (env.TROVE_SERVER_INDEXERS === '0' || env.TROVE_SERVER_INDEXERS === 'false') config.serverIndexers = false;
|
|
661
|
+
|
|
662
|
+
// Plugin package blob store: defaults to the primary storage backend (prefixed).
|
|
663
|
+
// Point it at a separate bucket/root with TROVE_PACKAGE_STORE (+ its own settings).
|
|
664
|
+
if (env.TROVE_PACKAGE_STORE) {
|
|
665
|
+
config.packageStore = { driver: env.TROVE_PACKAGE_STORE };
|
|
666
|
+
if (env.TROVE_PACKAGE_STORE === 'filesystem') config.packageStore.root = env.TROVE_PACKAGE_FS_ROOT || './data/packages';
|
|
667
|
+
if (env.TROVE_PACKAGE_STORE === 's3') config.packageStore.s3 = s3FromEnv(env, 'TROVE_PACKAGE_', ['TROVE_']);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Search transformer: 'parse' (default) or 'workers-ai' (Cloudflare Workers AI —
|
|
671
|
+
// the binding is injected by the worker adapter; TROVE_SEARCH_MODEL picks the model).
|
|
672
|
+
if (env.TROVE_SEARCH_TRANSFORMER === 'workers-ai') {
|
|
673
|
+
config.searchTransformer = { driver: 'workers-ai', model: env.TROVE_SEARCH_MODEL };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Cross-origin API access is off unless an origin (or '*') is configured.
|
|
677
|
+
config.corsOrigin = env.TROVE_CORS_ORIGIN || null;
|
|
678
|
+
// App-shell CSP is opt-in (see SAMPLE_CSP) — provide a full policy string to
|
|
679
|
+
// enable it. Off by default because sandboxed plugin iframes can't satisfy one.
|
|
680
|
+
if (env.TROVE_CSP && env.TROVE_CSP !== 'off') config.csp = env.TROVE_CSP;
|
|
681
|
+
|
|
682
|
+
// What the installed app calls itself. Every field is optional and every default
|
|
683
|
+
// reproduces the document that used to be a static file, so a drive that sets none of
|
|
684
|
+
// these is unchanged — see manifest.js.
|
|
685
|
+
config.manifest = manifestFromEnv(env);
|
|
686
|
+
|
|
687
|
+
// The MCP endpoint reads its own settings out of here (mcpConfigFromEnv), and this
|
|
688
|
+
// was never populated — so `TROVE_MCP=off`, `TROVE_MCP_REQUIRE_AUTH=true`,
|
|
689
|
+
// `TROVE_MCP_PATH` and `TROVE_MCP_RESOURCE` were all silently dead. An operator who
|
|
690
|
+
// used the documented way to lock down or remove the agent endpoint still had it
|
|
691
|
+
// live at /mcp, unauthenticated on a zero-config drive, with write_file and
|
|
692
|
+
// delete_file on it. Carrying the environment forward is what makes those real.
|
|
693
|
+
config.env = env;
|
|
694
|
+
|
|
695
|
+
return config;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export { createRouter };
|