@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.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. 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 };