@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
package/README.md ADDED
@@ -0,0 +1,1227 @@
1
+ # 🗄️ Trove
2
+
3
+ A **self-hostable Google Drive** you actually own — with **semantic search**,
4
+ **pluggable storage** (S3 / filesystem / NAS), a **search-first workbench**, and
5
+ a **sandboxed plugin system**. Ships as a runtime-agnostic library plus a server
6
+ that speaks plain `Request → Response`, so it runs on **Node**, **Bun**, or
7
+ **Cloudflare Workers** with a light wrapper.
8
+
9
+ Built on the [3sln stack](https://github.com/3sln/stack): **ngin** (DI / CQRS) and
10
+ **dodo** (functional VDOM, with its own reactive cells).
11
+
12
+ ```
13
+ ┌──────────────────────────────────────────────────────────────┐
14
+ │ @3sln/trove/web search-first workbench (dodo · ngin) │
15
+ │ contributions · commands · keymaps · settings · plugin host │
16
+ ├──────────────────────────────────────────────────────────────┤
17
+ │ @3sln/trove/server Request → Response (Node · Worker) │
18
+ ├──────────────────────────────────────────────────────────────┤
19
+ │ @3sln/trove/core Vfs · Storage · Metadata · Uploads · Search │
20
+ │ S3 / filesystem / NAS · SQLite / memory · embeddings │
21
+ └──────────────────────────────────────────────────────────────┘
22
+ ```
23
+
24
+ ## Highlights
25
+
26
+ - **Pluggable storage** — S3-compatible (AWS S3, Cloudflare R2, MinIO, B2),
27
+ local filesystem, or a NAS mount. S3 uses **presigned URLs** so large uploads
28
+ and downloads go **straight to the bucket**, never proxied through the server.
29
+ SigV4 is implemented on Web Crypto, so it works on Workers too — no AWS SDK.
30
+ - **Resumable large transfers** — multipart uploads with bounded concurrency,
31
+ per-part retry, live progress, and resume-after-drop. Range-aware downloads
32
+ (media seeking, partial fetch).
33
+ - **Semantic + keyword search** — a hybrid `SearchService` blends dense vector
34
+ similarity with lexical matching. **Every piece is a pluggable, async provider
35
+ you inject into the server constructor**: the embeddings (offline hash model or
36
+ any OpenAI-compatible endpoint), the **vector store** (in-memory brute-force by
37
+ default, or an external DB — a Qdrant adapter ships in core, and the
38
+ `VectorStore` interface fits pgvector/Pinecone/Milvus/LanceDB), and the keyword
39
+ store. Core hardcodes none of them and stays platform-agnostic.
40
+ - **Pluggable indexers** — attach searchable content to files, namespaced under
41
+ the indexer that owns it. A built-in text/code extractor runs server-side;
42
+ plugins push their own documents through the API under their namespace.
43
+ - **No folders** — a collection is a **flat set of uniquely-named items**. You find
44
+ things by searching, and you group them by **linking**: any item is addressable as
45
+ `trove:<collection>?name=…` (or `?id=…`), so a markdown document that links its
46
+ sources does what a folder did — except it can say *why* those things belong
47
+ together, an item can appear in as many documents as you like, and the grouping is
48
+ searchable content rather than an invisible box. A links indexer records those
49
+ references, so every item shows **what links to it**.
50
+ - **Search-first workbench** — the main panel is a launcher (Spotlight/Raycast
51
+ style): type to search files, `!` to run a command, `#tag` / `#key:>=value` to
52
+ filter by tag or property; recents and the collection sit underneath. Opening
53
+ a file shows the opener **beside** the launcher (split) or **over** it (modal) —
54
+ your last choice is the default, and you can swap. Underneath is a real
55
+ contribution system: commands, a command palette + quick-open, keybindings
56
+ (chords, user overrides), when-clauses, schema-driven settings, and media openers.
57
+ - **Media openers** — markdown (with live `trove:` links), text, image, audio and
58
+ video. Deliberately plain: an opener is a contribution, so a richer player is
59
+ something a build or a plugin adds rather than something this one has to guess at.
60
+ - **Views** — how the *results* are drawn is a contribution too. A list and a grid
61
+ ship; the switcher sits in the search box and remembers your choice, and a
62
+ collection that is mostly photographs opens as a grid without being asked. A gallery,
63
+ a map or a table is a `view` contribution, not a patch to the launcher — the
64
+ launcher still owns the items, the highlight and the keyboard, so every view
65
+ navigates the same way. The **search transformer can suggest one** — it is the only
66
+ thing in the stack that read the sentence, and "photos from the trip last summer"
67
+ asks for a gallery as much as it asks for files. It names a view the client offered,
68
+ and a view you picked yourself still wins.
69
+ - **Build your own drive** — `createWorkbench({ openers, views })` is the entry point,
70
+ so a bespoke or hosted build ships its own first-party openers and views through the
71
+ same registry plugins use. No fork of `@3sln/trove/web`.
72
+ - **Sandboxed plugins** — plugins are **self-contained ZIP packages** (a
73
+ `manifest.json`, an entry script, and any assets) installed by **URL or file
74
+ upload** — no central catalogue. Each runs in a **hidden, sandboxed iframe on an
75
+ opaque origin** (`allow-scripts`, no `allow-same-origin`): it can't touch the
76
+ host DOM, cookies, or storage, and can't even fetch its own package files. The
77
+ host injects the SDK + the plugin's entry script into the frame and hands it a
78
+ single `MessagePort`; **package resources arrive as opaque byte handles** over
79
+ that port. Everything a plugin can do — file access, storage, UI — is gated by
80
+ the **capabilities the user grants at install time**. A plugin has **no direct network
81
+ access** — the sandbox blocks all egress (`connect-src 'none'`); to reach the
82
+ web it must **declare each endpoint** in its manifest, and the host brokers every
83
+ request, refusing anything off the declared allowlist (including redirects) and
84
+ sending no ambient cookies. Before anything runs, a
85
+ **pre-install review** shows the package's identity, capabilities (each
86
+ explained), contributions, and settings so the user can decide whether to trust
87
+ it. Signed packages show a **domain-verified** badge: the manifest declares a
88
+ domain, and the host checks the signing key's fingerprint against an
89
+ `assetlinks`-style document published at that domain (Digital Asset Links
90
+ style). Plugins get **persistent SQLite storage** — an isolated database per
91
+ scope, both **server-side** (native SQLite via a keyed provider) and **on-device**
92
+ (sql.js/wasm run in the host, persisted to IndexedDB) behind one async SQL
93
+ interface. Scopes are `plugin` (private) and `domain` (shared across a vendor's
94
+ plugins — **verified packages only**); Trove **tracks which plugin owns what
95
+ data** so uninstalling wipes it. They contribute commands, openers, indexers,
96
+ status items, and keybindings, and can surface a popup UI panel. Plugins
97
+ **announce a live capability manifest** on connect (and re-announce when the app
98
+ goes on/offline), each contribution flagged offline-capable or not — so the
99
+ workbench knows which plugin features work right now, disables the ones that
100
+ don't (e.g. a network-only previewer while offline), and treats a plugin that
101
+ sends no manifest as not running. The host also **re-requests the manifest on a
102
+ heartbeat**, so a plugin that hangs or crashes between events is noticed and its
103
+ features are marked unavailable.
104
+ - **Conversations on every file** — threaded comments with @mentions, reactions,
105
+ and tags, stored in a **CRDT sidecar document** kept cold in object storage
106
+ (one `sidecars/<id>.json` next to your data — no extra database) and loaded
107
+ into a hot, debounced, merge-on-write manager when active. Indexer facets live
108
+ there too, scoped to the indexer that wrote them.
109
+ - **Bring-your-own identity** — Trove ships no login. It verifies an identity JWT
110
+ (Cloudflare Access / Zero Trust, oauth2-proxy, any IdP) via JWKS/RS256/ES256 —
111
+ or a proxy-set header — on Web Crypto, and builds a profile from the claims. You
112
+ can also name the keys you trust directly (`TROVE_JWT_JWKS`), so a deployment that
113
+ mints its own tokens needs no JWKS endpoint. With no identity configured at all
114
+ there is one shared anonymous user and **no profile is shown** — an avatar for
115
+ somebody who doesn't exist implies an account there is no way to sign in to.
116
+ - **Mention notifications over Web Push** — as conversations change, @mentions
117
+ batch per user and flush on an interval as **bodyless VAPID web pushes**; the
118
+ service worker wakes and pulls the inbox. No mention text ever touches a
119
+ third-party push service.
120
+ - **Cloudflare-native** — the vector store speaks **Vectorize** (binding or REST),
121
+ storage speaks **R2**, and identity speaks **Access** — first-class, env-driven.
122
+ - **Collections** — every item belongs to a collection, which is both a permission
123
+ boundary (read / write / delete / admin grants by user, role, or anyone) and a
124
+ **store config**: each collection points at its own backend (an S3 bucket+prefix,
125
+ a filesystem path, …). Users with the create capability provision new
126
+ collections dynamically by configuring the backing store.
127
+ - **Offline mode (PWA)** — a service worker caches the app shell and every
128
+ built-in media player/previewer; "make available offline" pins a file's bytes
129
+ (served from cache) and indexes its text for **offline hybrid search**
130
+ (lexical + local vectors). Comments and tags written offline are **queued and
131
+ merged** (the sidecar is a CRDT) when you reconnect.
132
+
133
+ ## Quick start
134
+
135
+ ```sh
136
+ npm install
137
+
138
+ # 1. Run the API + web app together, in-memory (zero config):
139
+ npm run build:web # builds with Bun
140
+ npm run serve # Bun runtime → http://localhost:8787
141
+ # (npm run serve:node # same server under Node ≥22.5, if you prefer)
142
+
143
+ # — or, for development with hot reload —
144
+ npm run serve & # API on :8787
145
+ npm run dev # @web/dev-server on :5173 (unbundled ESM + HMR, proxies /api to :8787)
146
+ ```
147
+
148
+ Then open the app, drag files in, and try the command palette (`⌘/Ctrl‑Shift‑P`)
149
+ or semantic search (`⌘/Ctrl‑Shift‑F`).
150
+
151
+ ### Configure the backends (env)
152
+
153
+ ```sh
154
+ # Storage
155
+ TROVE_STORAGE=filesystem # memory | filesystem | s3
156
+ TROVE_FS_ROOT=./data/objects # for filesystem/NAS (point at a mount)
157
+ # …or S3 / R2 / MinIO:
158
+ TROVE_STORAGE=s3
159
+ TROVE_S3_BUCKET=my-bucket
160
+ TROVE_S3_REGION=auto
161
+ TROVE_S3_ENDPOINT=https://<acct>.r2.cloudflarestorage.com # omit for AWS
162
+ TROVE_S3_ACCESS_KEY_ID=… # or AWS_ACCESS_KEY_ID
163
+ TROVE_S3_SECRET_ACCESS_KEY=… # or AWS_SECRET_ACCESS_KEY
164
+ TROVE_S3_PATH_STYLE=true # MinIO / custom endpoints
165
+
166
+ # Metadata (file tree + facets)
167
+ TROVE_METADATA=sqlite # memory | sqlite
168
+ TROVE_DB_PATH=./data/trove.db
169
+
170
+ # Semantic search embeddings (optional — defaults to an offline local model)
171
+ TROVE_EMBEDDINGS_URL=https://api.openai.com/v1/embeddings
172
+ TROVE_EMBEDDINGS_API_KEY=sk-…
173
+ TROVE_EMBEDDINGS_MODEL=text-embedding-3-small
174
+ TROVE_EMBEDDINGS_DIM=1536
175
+ ```
176
+
177
+ > **S3 CORS:** for browser-direct presigned uploads/downloads, allow `PUT`/`GET`
178
+ > and expose the `ETag` header on your bucket's CORS policy.
179
+
180
+ ## Deploy
181
+
182
+ The server is one function — `handle(Request) -> Promise<Response>` — so an adapter is
183
+ thin and there is no runtime-specific code below it. Pick a row:
184
+
185
+ | runtime | storage | metadata + search | notes |
186
+ | --- | --- | --- | --- |
187
+ | **Bun** | filesystem / S3 | SQLite file | recommended for self-hosting |
188
+ | **Node** | filesystem / S3 | SQLite file | identical behaviour, a little slower |
189
+ | **Workers** | R2 (S3 API) | D1 + Vectorize | no local disk, so both must be bound |
190
+
191
+ ### Scaffold one
192
+
193
+ ```sh
194
+ npm create @3sln/trove my-drive
195
+ ```
196
+
197
+ Asks where the drive will run — Bun, Node or Cloudflare Workers — and writes a project
198
+ configured for it: storage, metadata, search, identity, access control, and on Workers
199
+ the whole binding set (D1, Vectorize, R2, the `TroveTasks` Durable Object) plus the
200
+ `wrangler` commands that create each of them. Any section can be declined, and declining
201
+ still writes the keys, commented, with a line saying what they are for.
202
+
203
+ Credentials never land in a committed file: on Workers they become `wrangler secret put`
204
+ steps and a gitignored `.dev.vars`; everywhere else a gitignored `.env`.
205
+
206
+ ### From npm
207
+
208
+ Trove publishes as one package with the web app already built inside it, so there is no
209
+ build step here — the server and the workbench it serves are the same release by
210
+ construction.
211
+
212
+ ```sh
213
+ npm install @3sln/trove
214
+ TROVE_STORAGE=filesystem TROVE_FS_ROOT=./data/objects \
215
+ TROVE_METADATA=sqlite TROVE_DB_PATH=./data/trove.db \
216
+ node node_modules/@3sln/trove/packages/server/src/adapters/node.js
217
+ ```
218
+
219
+ Bun works the same way — swap `node` for `bun` and `node.js` for `bun.js`. Building is
220
+ only for working *on* Trove, which is what the rest of this section covers.
221
+
222
+ ### Bun (recommended)
223
+
224
+ ```sh
225
+ bun install
226
+ bun run build:web # builds packages/web/dist
227
+ TROVE_STORAGE=filesystem TROVE_FS_ROOT=./data/objects \
228
+ TROVE_METADATA=sqlite TROVE_DB_PATH=./data/trove.db \
229
+ bun packages/server/src/adapters/bun.js # :8787, API + web app
230
+ ```
231
+
232
+ That is the whole thing: object bytes under `$TROVE_FS_ROOT/objects/` (the backend
233
+ creates that subdirectory, sharded two levels deep), everything else in one SQLite file. Back it up with `npm run backup` (a `VACUUM INTO` snapshot, safe on a live
234
+ database) and copy the objects directory.
235
+
236
+ ### Node
237
+
238
+ Identical, with `node`:
239
+
240
+ ```sh
241
+ TROVE_STORAGE=filesystem TROVE_FS_ROOT=./data/objects \
242
+ TROVE_METADATA=sqlite TROVE_DB_PATH=./data/trove.db \
243
+ node packages/server/src/adapters/node.js
244
+ ```
245
+
246
+ Node 20+ for `node:sqlite`. Both adapters serve the built web app with SPA fallback and
247
+ trap `SIGTERM`/`SIGINT` to shut down cleanly — flush notifications, stop an in-flight
248
+ reindex, close SQLite — so a redeploy doesn't lose work. See [`Dockerfile`](./Dockerfile).
249
+
250
+ ### Cloudflare Workers
251
+
252
+ There is no disk, so the two things a self-hosted run keeps in a file need bindings:
253
+
254
+ ```toml
255
+ # wrangler.toml
256
+ main = "packages/server/src/adapters/worker.js"
257
+ compatibility_date = "2024-09-23"
258
+
259
+ [[d1_databases]] # metadata, KV, plugin installs, keyword search
260
+ binding = "DB"
261
+ database_name = "trove"
262
+ database_id = "..."
263
+
264
+ [[vectorize]] # semantic search (sqlite-vec cannot run here)
265
+ binding = "VECTORIZE"
266
+ index_name = "trove"
267
+
268
+ [ai] # optional: LLM query understanding
269
+ binding = "AI"
270
+
271
+ [assets] # the built web app
272
+ directory = "packages/web/dist"
273
+ binding = "ASSETS"
274
+
275
+ [[durable_objects.bindings]] # owns scans and reindexes — see below
276
+ name = "TASKS"
277
+ class_name = "TroveTasks"
278
+
279
+ [[migrations]]
280
+ tag = "v1"
281
+ new_sqlite_classes = ["TroveTasks"]
282
+
283
+ [vars]
284
+ TROVE_STORAGE = "s3" # R2 through the S3 API
285
+ TROVE_S3_BUCKET = "trove"
286
+ TROVE_S3_REGION = "auto"
287
+ TROVE_S3_ENDPOINT = "https://<account>.r2.cloudflarestorage.com"
288
+ TROVE_AUTH = "cloudflare-access"
289
+ TROVE_CF_ACCESS_TEAM = "acme"
290
+ TROVE_CF_ACCESS_AUD = "<aud-tag>"
291
+ TROVE_ADMINS = "you@example.com" # see "Making yourself an admin" below
292
+ TROVE_DEFAULT_OPEN = "false" # otherwise every Access user gets the default collection
293
+ ```
294
+
295
+ ```sh
296
+ wrangler secret put TROVE_S3_ACCESS_KEY_ID
297
+ wrangler secret put TROVE_S3_SECRET_ACCESS_KEY
298
+ wrangler d1 execute trove --command "SELECT 1" # create it first
299
+ wrangler deploy
300
+ ```
301
+
302
+ The adapter wires `DB` through `D1SqliteProvider` and `VECTORIZE` through
303
+ `VectorizeVectorStore` on its own. **Bind `DB` or the drive runs entirely in memory** —
304
+ which works right up until the isolate is recycled and everything is gone. R2 works
305
+ through the S3 API rather than the R2 binding because that is what makes presigned
306
+ uploads go straight to the bucket instead of through your Worker's CPU time.
307
+
308
+ Two Workers-specific limits worth knowing before you commit: `sqlite-vec` is a native
309
+ artifact and cannot load, so semantic search *needs* Vectorize; and plugin scopes each
310
+ want their own D1 database, since D1 cannot create one on demand and co-locating them
311
+ would put a plugin's tables next to the drive's metadata. Bind `PLUGIN_DB` if you use
312
+ server-side plugin storage — without it, that one feature reports a clear error and the
313
+ rest of the drive is unaffected. One D1 database holds every plugin scope: D1 cannot
314
+ create databases on demand and a scope key contains the user's id, so per-scope
315
+ databases are not expressible here. Their tables sit side by side, which is weaker
316
+ isolation than the file-per-scope a self-hosted run gets.
317
+
318
+ #### Work that outlives a request
319
+
320
+ A Worker isolate is not a server: it may be discarded as soon as the response resolves,
321
+ so a promise nobody declared is simply cancelled part-way through. That matters here
322
+ because the drive has work that intentionally outlives the request that started it — a
323
+ scan or a reindex takes minutes, and `POST /api/collections/:id/scan` returns a task
324
+ record immediately rather than holding the connection open for it. On Node and Bun the
325
+ process keeps that promise alive. On Workers, without help, you would get a scan that
326
+ did a third of the bucket and reported success.
327
+
328
+ Three mechanisms cover it. Two need a line in your config.
329
+
330
+ **`ctx.waitUntil`** — the adapter hands the runtime every task still running when the
331
+ response is ready, so the isolate stays alive until they finish. Automatic, no config.
332
+ It buys a bigger bite, not an unlimited one: CPU is still capped per invocation.
333
+
334
+ **A Durable Object** — this is the one that matters, and the reason is worth stating.
335
+ `waitUntil` can keep work alive, but it cannot let a *different* isolate see it. The
336
+ scan runs wherever the POST landed; the GET that polls it lands wherever the router
337
+ feels like; Cancel lands somewhere else again. Bind the object and all three reach the
338
+ same place:
339
+
340
+ ```toml
341
+ [[durable_objects.bindings]]
342
+ name = "TASKS"
343
+ class_name = "TroveTasks"
344
+
345
+ [[migrations]]
346
+ tag = "v1"
347
+ new_sqlite_classes = ["TroveTasks"]
348
+ ```
349
+
350
+ With `TASKS` bound, scans and reindexes run *inside* the object, and it becomes the one
351
+ place their tasks are listed. Progress polling shows real progress, Cancel reaches the
352
+ work it means to abort, and "is one already running?" is answered by a single instance
353
+ rather than by whichever isolate happened to be asked. It also keeps itself moving with
354
+ `setAlarm`, so a bucket too large for one slice continues in ~5 s rather than waiting
355
+ for the next cron tick.
356
+
357
+ Note what did **not** change: the task list is still in memory and still per-process.
358
+ Making it durable would be the wrong fix — a stored record saying `running` after the
359
+ isolate that owned it was evicted is a phantom nothing can ever correct, where a
360
+ restart clears an in-memory one. The registry didn't need to become durable; the work
361
+ needed a real process to live in. Failures are already durable, as Issues.
362
+
363
+ **Cron Triggers** — `setInterval` does not survive the request it was registered in, so
364
+ `TROVE_SCAN_INTERVAL_MS` and `TROVE_MAINTENANCE_INTERVAL_MS` do nothing here; they are
365
+ Node/Bun only. Periodic work runs from the `scheduled` handler instead:
366
+
367
+ ```toml
368
+ [triggers]
369
+ crons = ["*/5 * * * *"]
370
+
371
+ [vars]
372
+ TROVE_CRON_BUDGET_MS = "20000" # wall-clock a cron firing may spend; default 20s
373
+ TROVE_SLICE_MS = "20000" # wall-clock one Durable Object slice may spend
374
+ ```
375
+
376
+ Each firing sweeps abandoned uploads and unflushed sidecars, applies trash retention,
377
+ then scans — splitting whatever budget is left across your collections. A scan that
378
+ runs out of budget **stores the cursor it reached** and stops, so the next slice
379
+ continues from there rather than restarting. Keep the budget under your Worker's CPU
380
+ limit (30 s by default) with room to spare.
381
+
382
+ Without the `TASKS` binding the drive still works — background work runs in the request
383
+ isolate under `waitUntil`, exactly as it did before. What you lose is visibility: a
384
+ client polling `/api/tasks` may reach an isolate that never saw the scan and show
385
+ nothing running, and Cancel on such a task silently does nothing.
386
+
387
+ Independently of any of this, a scan **claims its collection** before it starts, through
388
+ a lease in the metadata database (`KeyValueStore.acquire`). Two scans of one collection
389
+ running at once would both write the resume cursor, last-writer-wins, and a slice of the
390
+ bucket would be silently skipped — so the guard has to live where every process can see
391
+ it, not in one process's memory. That applies to multi-instance Node and Bun deployments
392
+ too, not just Workers.
393
+
394
+ ### Making yourself an admin
395
+
396
+ An admin can install plugins that ship server code, create collections, and rebuild the
397
+ search index — the operations that are drive-wide rather than per-collection.
398
+
399
+ ```toml
400
+ [vars]
401
+ TROVE_ADMINS = "you@example.com,ops@example.com"
402
+ ```
403
+
404
+ The list is matched against each request's principal **by email or by id**, so the
405
+ address you sign in to Access with is the thing to write down. This matters more than it
406
+ sounds: Cloudflare Access puts an internal user UUID in the token's `sub` claim, which
407
+ is what becomes `principal.id` — so an admin list of ids would mean pasting
408
+ `8f2a1c04-6d3e-…` and having no way to find it short of decoding a JWT.
409
+
410
+ Check it worked. `admin` is the whole answer:
411
+
412
+ ```sh
413
+ curl -s https://drive.example.com/api/me | jq
414
+ # { "principal": { "id": "8f2a1c04-…", "email": "you@example.com", … },
415
+ # "authenticated": true, "admin": true }
416
+ ```
417
+
418
+ `authenticated: false` means Access isn't reaching the origin — the Worker is being
419
+ called directly, or the Access application doesn't cover this hostname. `admin: false`
420
+ with the right email means the address in `TROVE_ADMINS` doesn't match the one in the
421
+ token; `/api/me` shows you both.
422
+
423
+ Two things worth doing at the same time:
424
+
425
+ - **`TROVE_DEFAULT_OPEN=false`.** The default collection is open to anyone who reaches
426
+ it, which is right for a single-user drive and wrong the moment Access lets a team in.
427
+ With it off, access comes only from an explicit grant — and the admin list is one.
428
+ - **Share by email too.** A per-collection grant (`{ type: "user", subject: … }`) matches
429
+ the same way, so an ACL written by a human names people the way humans do.
430
+
431
+ The whole Access setup, including what to put in the Zero Trust dashboard, is in
432
+ [Cloudflare Access (Zero Trust)](#cloudflare-access-zero-trust).
433
+
434
+ ### Every setting
435
+
436
+ Defaults are what you get with the variable unset. Everything here is read once at
437
+ startup by `configFromEnv` (`packages/server/src/index.js`), so a library caller can pass
438
+ the same values as config fields instead.
439
+
440
+ | variable | default | what it does |
441
+ | --- | --- | --- |
442
+ | **storage** | | |
443
+ | `TROVE_STORAGE` | `memory` | `memory` · `filesystem` · `s3` |
444
+ | `TROVE_FS_ROOT` | `./data/objects` | filesystem root; bytes go in `<root>/objects/` |
445
+ | `TROVE_S3_BUCKET` / `_REGION` / `_ENDPOINT` | — | S3/R2/MinIO; endpoint for non-AWS |
446
+ | `TROVE_S3_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` | — | credentials (use secrets, not env files) |
447
+ | `TROVE_S3_PATH_STYLE` | `false` | MinIO and most S3-compatibles need `true` |
448
+ | `TROVE_MAX_UPLOAD_BYTES` | unlimited | per-file ceiling; over it is `413`, not `507` |
449
+ | **metadata + search** | | |
450
+ | `TROVE_METADATA` | `sqlite` unless storage is memory | `memory` · `sqlite` |
451
+ | `TROVE_DB_PATH` | `./data/trove.db` | the one file holding metadata, KV, and installs |
452
+ | `TROVE_VECTOR` | follows durability | `sqlite` · `memory` · `qdrant` · `vectorize` |
453
+ | `TROVE_KEYWORD` | follows durability | `sqlite` · `memory` |
454
+ | `TROVE_EMBEDDINGS_URL` / `_KEY` / `_MODEL` / `_DIM` | offline hash model | any OpenAI-compatible endpoint |
455
+ | `TROVE_SEARCH_TRANSFORMER` | `parse` | `parse` · `workers-ai` (query understanding) |
456
+ | `TROVE_REBUILD_INDEX_ON_START` | `true` | rebuild when the index is empty and the drive isn't |
457
+ | **identity** | | |
458
+ | `TROVE_AUTH` | `anonymous` | `anonymous` · `cloudflare-access` · `jwt` · `header` |
459
+ | `TROVE_AUTH_REQUIRED` | `false` | **set this** — else unauthenticated is anonymous |
460
+ | `TROVE_CF_ACCESS_TEAM` / `_AUD` | — | Cloudflare Access, derives everything else |
461
+ | `TROVE_JWKS_URL` / `TROVE_JWT_JWKS` / `_JWKS_FILE` | — | keys to trust: fetched, inline, or a file |
462
+ | `TROVE_JWT_ISSUER` / `_AUDIENCE` / `_ALGS` | — | claim checks after the signature passes |
463
+ | `TROVE_AUTH_SERVER` | the JWT issuer | where refused clients are sent to sign in |
464
+ | `TROVE_ADMINS` | — | comma-separated ids with whole-drive rights |
465
+ | **collections** | | |
466
+ | `TROVE_COLLECTIONS` | on | `false` for one open store with no ACLs |
467
+ | `TROVE_DEFAULT_OPEN` | `true` | **set `false`** before exposing it |
468
+ | `TROVE_COLLECTION_CREATOR_ROLES` | — | roles allowed to create collections |
469
+ | **agents** | | |
470
+ | `TROVE_MCP` | on | `off` disables the endpoint |
471
+ | `TROVE_MCP_PATH` / `_RESOURCE` / `_REQUIRE_AUTH` | `/mcp` | see [Connecting an AI agent](#connecting-an-ai-agent-mcp) |
472
+ | **housekeeping** | | |
473
+ | `TROVE_TRASH_DAYS` | `30` | `0` keeps the trash forever |
474
+ | `TROVE_SCAN_INTERVAL_MS` | off | reconcile with the store on a timer (not Workers) |
475
+ | `TROVE_MAINTENANCE_INTERVAL_MS` | `300000` | sweep stale uploads and sidecars (not Workers) |
476
+ | `TROVE_CRON_BUDGET_MS` | `20000` | Workers only: wall-clock one cron firing may spend |
477
+ | `TROVE_SLICE_MS` | `20000` | Workers only: wall-clock one Durable Object slice may spend |
478
+ | `TROVE_MENTION_FLUSH_MS` | — | how often mention notifications batch out |
479
+ | `TROVE_VAPID_PUBLIC_KEY` / `_PRIVATE_KEY` / `_SUBJECT` | — | Web Push for @mentions |
480
+ | **limits + serving** | | |
481
+ | `TROVE_MAX_JSON_BYTES` | `4 MiB` | JSON body cap (uploads stream, so bound those at the proxy) |
482
+ | `TROVE_MAX_PAGE` | `1000` | ceiling on any client-supplied `limit` |
483
+ | `TROVE_PORT` / `TROVE_HOST` | `8787` / `0.0.0.0` | |
484
+ | `TROVE_WEB_DIST` | resolved from the package | built web app to serve; unset serves API only |
485
+ | `TROVE_CORS_ORIGIN` | off | `*` or an allowlist; the app is same-origin (MCP follows it too) |
486
+ | `TROVE_PUBLIC_URL` | detected | the drive's public origin, for sign-in discovery |
487
+ | `TROVE_TRUST_PROXY` | `false` | honour `X-Forwarded-Proto/Host` — only behind a real proxy |
488
+ | `TROVE_CSP` | off | opt-in shell CSP (see `SAMPLE_CSP`) |
489
+ | **installed app** | | served at `/manifest.webmanifest`, generated from these |
490
+ | `TROVE_APP_NAME` | `Trove` | what the installed app is called |
491
+ | `TROVE_APP_SHORT_NAME` | the app name | home-screen label |
492
+ | `TROVE_APP_DESCRIPTION` | the stock one | |
493
+ | `TROVE_APP_THEME_COLOR` | `#181a1f` | |
494
+ | `TROVE_APP_BACKGROUND_COLOR` | the theme colour | splash background |
495
+ | `TROVE_APP_DISPLAY` | `standalone` | `fullscreen`, `minimal-ui`, `browser` |
496
+ | `TROVE_APP_START_URL` | `/` | |
497
+ | `TROVE_APP_ICON` | `/icon.svg` | any URL the browser can reach |
498
+ | `TROVE_APP_ICON_SIZES` | `any` | state the real size for a raster icon |
499
+ | `TROVE_APP_ICONS` | — | the full icon array, for maskable or multi-size sets |
500
+ | **plugins** | | |
501
+ | `TROVE_SERVER_INDEXERS` | on | `false` disables server-side plugin indexers |
502
+ | `TROVE_ENFORCE_PLUGIN_CAPS` | `false` | strict capability enforcement |
503
+ | `TROVE_PACKAGE_STORE` / `TROVE_PACKAGE_FS_ROOT` | primary storage | where plugin zips live |
504
+
505
+ ### Before you expose it
506
+
507
+ Trove ships **no login**, and a zero-config run is **open to anyone who can reach
508
+ the port** (anonymous auth + an open default collection — you'll see a startup
509
+ warning). Before putting it on a network:
510
+
511
+ - **Authenticate.** Set `TROVE_AUTH=jwt` (verify a JWT via `TROVE_JWKS_URL`, e.g.
512
+ Cloudflare Access) or `TROVE_AUTH=header` (trust a header a verifying proxy
513
+ set), plus `TROVE_AUTH_REQUIRED=true` so unauthenticated requests are rejected
514
+ rather than treated as anonymous. Consider `TROVE_DEFAULT_OPEN=false` and
515
+ `TROVE_ADMINS=…`.
516
+ - **Terminate TLS at a reverse proxy** (Caddy, nginx, Traefik, Cloudflare) — the
517
+ server itself speaks plaintext on `0.0.0.0:8787`. Front it with the proxy and
518
+ don't publish the port directly. Also cap the proxy's max request body size —
519
+ the server caps JSON bodies (`TROVE_MAX_JSON_BYTES`) but streams file-upload
520
+ parts straight to storage, so bound raw upload size at the proxy (and/or set
521
+ disk/bucket quotas) to prevent a write-capable user from filling the store.
522
+ - **Set `TROVE_PUBLIC_URL`** to the address people actually reach the drive at. It is
523
+ what the sign-in challenge and the agent discovery document advertise. Trove will
524
+ otherwise read it off the request, and `X-Forwarded-Host` is set by whoever is talking
525
+ to it — so that header is honoured only with `TROVE_TRUST_PROXY=true`, which is safe
526
+ exactly when a proxy is guaranteed to be in front.
527
+ - **CORS stays off** unless you set `TROVE_CORS_ORIGIN` (the app is same-origin).
528
+ A shell **CSP** is opt-in via `TROVE_CSP` (see `SAMPLE_CSP`); it's off by
529
+ default because sandboxed plugin iframes can't satisfy a strict one. The API
530
+ still forces attachment downloads + `nosniff` to neutralize uploaded HTML/SVG.
531
+ - **Cross-site writes are refused**, independently of CORS. An allowlist only decides
532
+ who may *read* a reply, and only for requests a browser preflights — a `POST` with
533
+ `content-type: text/plain` is a CORS *simple* request, so it is sent without one and
534
+ the deletion happens whether or not anyone can read the answer. So every
535
+ state-changing request (JSON API and MCP alike) is checked against `Sec-Fetch-Site`
536
+ and `Origin`, and a cross-site one gets a 403. Non-browser clients — curl, an agent,
537
+ a script — send neither header and are unaffected; they carry no ambient credential
538
+ for another site to borrow, which is the whole basis of the attack. Setting
539
+ `TROVE_CORS_ORIGIN` to a specific origin permits that origin to write, too.
540
+
541
+ ### Naming the keys you trust
542
+
543
+ Three ways to say who a request is from, in the order most deployments reach for
544
+ them:
545
+
546
+ ```sh
547
+ # 1. A proxy already authenticated the user and set a header (Cloudflare Access,
548
+ # oauth2-proxy). The browser sends nothing; Trove trusts the header.
549
+ TROVE_AUTH=header TROVE_AUTH_ID_HEADER=cf-access-authenticated-user-email
550
+
551
+ # 2. Cloudflare Access / Zero Trust — the team name is the only thing that isn't
552
+ # derivable. TROVE_CF_ACCESS_AUD is the Access *application's* AUD tag: without it,
553
+ # a token minted for any other app in the same Access account verifies here too.
554
+ TROVE_AUTH=cloudflare-access TROVE_CF_ACCESS_TEAM=acme TROVE_CF_ACCESS_AUD=<aud-tag>
555
+
556
+ # 3. Any other IdP that publishes a JWKS you fetch.
557
+ TROVE_AUTH=jwt TROVE_JWKS_URL=https://issuer.example.com/.well-known/jwks.json \
558
+ TROVE_JWT_ISSUER=https://issuer.example.com TROVE_JWT_AUDIENCE=trove
559
+
560
+ # 4. You mint your own tokens, so there is no JWKS endpoint to point at — name the
561
+ # keys directly. Inline JSON, or a file (which keeps a multi-line document out of
562
+ # the environment and out of `docker inspect`).
563
+ TROVE_AUTH=jwt TROVE_JWT_JWKS_FILE=/run/secrets/trove-jwks.json \
564
+ TROVE_JWT_ISSUER=https://you.example TROVE_JWT_AUDIENCE=trove
565
+ ```
566
+
567
+ **What is trusting what.** The signature is verified against the key material, and
568
+ nothing else: `TROVE_JWT_JWKS` is a key set you hold (nothing is fetched, so there is
569
+ nothing to spoof), `TROVE_JWKS_URL` is one Trove fetches over HTTPS (so the authenticity
570
+ of those keys rests on that host's TLS certificate), and `TROVE_JWT_SECRET` is a shared
571
+ HS256 secret. The key is chosen by the token's `kid`.
572
+
573
+ `TROVE_JWT_ISSUER` and `TROVE_JWT_AUDIENCE` are **claim checks**, not trust anchors — a
574
+ string comparison against `iss` and `aud` after the signature has already passed. They
575
+ cost nothing and are worth setting: they stop a token that is validly signed by a key you
576
+ trust but was minted for a different issuer or a different application, which is a real
577
+ case when a JWKS serves several or the IdP is multi-tenant. On their own they secure
578
+ nothing, since anyone forging a token also sets those claims.
579
+
580
+ Always add `TROVE_AUTH_REQUIRED=true` so an unauthenticated request is rejected
581
+ rather than treated as anonymous. `TROVE_JWT_ALGS` narrows the accepted algorithms
582
+ (the default is inferred from the key material: `HS256` for a secret, `RS256`/`ES256`
583
+ for a key set). A key set with more than one key requires a `kid` on the token —
584
+ trying each key until one verifies would turn key rotation into key confusion.
585
+
586
+ The **web client** presents a bearer token from `localStorage['trove.token']` when
587
+ one is present. It isn't needed for the proxy-authenticated case (the browser's
588
+ existing session covers it), and note that with a bearer token, downloads are
589
+ fetched and handed to the browser as a blob rather than streamed — an `<a href>`
590
+ can't carry an Authorization header, and putting the token in the URL would leak it
591
+ into logs and history.
592
+
593
+ ### Running out of room
594
+
595
+ A filesystem or NAS collection reports how much space is left — a gauge in the status
596
+ bar, amber under 10% free and red under 5%. An S3 collection shows nothing at all,
597
+ because an object store has no such number and a made-up meter is worse than none.
598
+
599
+ When the disk does fill, the failure is specific rather than generic: **507
600
+ Insufficient Storage**, not retryable, with a message that says what happened. (429
601
+ would tell the client to back off and try again — which against a full disk is an
602
+ infinite loop, since only a human can clear it.) The condition is also recorded as a
603
+ standing issue, so the person who needs to fix it hears about it even if they weren't
604
+ the one whose upload failed. Reads, search and downloads keep working throughout.
605
+
606
+ ### Deleting
607
+
608
+ Deleting moves an item to the **trash**: it leaves the drive — gone from listings,
609
+ search, name lookups and backlinks — but the bytes stay exactly where they are and
610
+ the record keeps its id. A confirm dialog is not a safety net; it is a thing people
611
+ click through, and on a drive holding your only copy of something that matters.
612
+
613
+ ```
614
+ POST /api/items/delete → trash it (recoverable)
615
+ GET /api/trash → what's in there
616
+ POST /api/trash/restore → put it back, re-indexed
617
+ POST /api/trash/purge → destroy one item, or empty the trash
618
+ ```
619
+
620
+ Restoring re-indexes the item, so it is findable again rather than merely visible.
621
+ If its name was taken while it was away, it comes back under a free one — someone
622
+ restoring a file wants the file, not an error about a name.
623
+
624
+ `TROVE_TRASH_DAYS` (default 30) is how long an item stays recoverable; `0` keeps
625
+ the trash forever. That timer is the only thing in Trove that destroys data
626
+ without someone asking, which is why it is a number you set rather than a default
627
+ buried in code.
628
+
629
+ ### Data & backups
630
+
631
+ State lives in two places, both configurable and mounted as a volume in the
632
+ Dockerfile (`/data`):
633
+
634
+ - **Objects** — `TROVE_FS_ROOT` (filesystem) or your S3/R2 bucket.
635
+ - **Metadata + KV** — the SQLite file at `TROVE_DB_PATH` (WAL mode).
636
+ - **The search index** — the same SQLite file: vectors via `sqlite-vec`, keywords
637
+ via FTS5. It is derived state, so it is not something you *have* to back up —
638
+ if it is missing on startup and the drive is not, Trove rebuilds it in the
639
+ background and says so in the log.
640
+
641
+ To back up the database safely while Trove is running:
642
+
643
+ ```sh
644
+ bun scripts/backup.mjs ./data/trove.db ./backups/trove-$(date +%F).db
645
+ ```
646
+
647
+ **Do not just copy the file.** In WAL mode the database is three files (`.db`,
648
+ `-wal`, `-shm`) and your most recent writes live in the `-wal`; copying the `.db`
649
+ alone produces a backup that opens cleanly and is silently missing them — the
650
+ worst kind, because it looks like a backup. The script uses SQLite's `VACUUM
651
+ INTO`, which is an online backup that takes a read lock rather than blocking
652
+ writers, and it refuses to overwrite an existing file.
653
+
654
+ (The usual advice, `sqlite3 db ".backup out.db"`, works too — but the `sqlite3`
655
+ CLI is not in the image Trove ships, so inside the container it just fails.)
656
+
657
+ Back up the object store separately with your storage's native tooling (`rsync`
658
+ the filesystem root, or bucket replication/versioning for S3/R2) — the database
659
+ holds metadata and the search index, not your bytes.
660
+
661
+ Restoring is putting both back and starting up: verified end to end on a
662
+ 3,005-item drive — same item count, same byte total, search intact, files
663
+ downloadable. And if the search index is ever lost on its own, Trove notices at
664
+ startup and rebuilds it. `/api/health` is a liveness check; `/api/ready` probes
665
+ the store for readiness gating.
666
+
667
+ ### Caching, and why a deploy doesn't strand a browser
668
+
669
+ Two kinds of URL, and only one of them is safe to keep:
670
+
671
+ | | | |
672
+ | --- | --- | --- |
673
+ | `/assets/*` | `public, max-age=31536000, immutable` | content-addressed — the filename changes whenever the bytes do, so nothing ever needs invalidating |
674
+ | everything else | `no-cache` | `index.html`, `sw.js`, the manifest, the icon: stable names whose contents change |
675
+
676
+ Getting that backwards is the classic way to ship a blank page. `index.html` is the
677
+ entry point, so a browser holding a cached copy goes on importing hashed modules from a
678
+ build that no longer exists — a link-time failure, which kills the whole graph before a
679
+ line runs. `no-cache` means "revalidate", not "don't cache": responses carry an `ETag`,
680
+ so a revalidation that finds nothing changed is a 304 rather than a re-download.
681
+
682
+ A miss under `/assets/` is a **404**, deliberately, rather than the SPA fallback. Any
683
+ other answer is a stale reference to a retired build, and answering it with `index.html`
684
+ at status 200 is what let a service worker cache HTML under a `.js` URL — permanently,
685
+ since a cache hit never asks the network again. The worker refuses a response whose type
686
+ contradicts the request as well, and its shell cache is named for the build
687
+ (`trove-shell-<hash>`), so activating a new worker retires the old shell instead of
688
+ inheriting it. The caches holding API responses and files pinned for offline use are
689
+ *not* named per build — rotating those would throw away someone's offline library on
690
+ every deploy.
691
+
692
+ If you put a CDN or reverse proxy in front, let these headers through rather than
693
+ replacing them.
694
+
695
+ ## Background work and standing problems
696
+
697
+ Two registries, split by **lifetime** — the distinction is the design, not an
698
+ implementation detail:
699
+
700
+ | | Tasks | Issues |
701
+ |---|---|---|
702
+ | What | work in flight | a problem that outlived the work |
703
+ | Where | in memory, per process | the KV store, durable |
704
+ | Ends when | the work ends | the underlying thing actually succeeds |
705
+ | API | `GET /api/tasks` | `GET /api/issues` |
706
+
707
+ A task that was running when the server stopped is *not* running — forgetting it is
708
+ correct. But a file that failed to index is still unindexed tomorrow, so that has to
709
+ survive a restart. They meet at the retry: **a failure raises an issue, retrying it
710
+ starts a task, and the task succeeding clears the issue.** Nothing is cleared by being
711
+ acknowledged.
712
+
713
+ The client shows one list covering both sides of the wire — an upload running in the
714
+ browser and a reindex running on the server appear together, because a user doesn't
715
+ care which machine is busy. Server tasks are a read-only mirror; the browser never
716
+ drives them. Progress is determinate (`done`/`total`/`unit`) or explicitly
717
+ indeterminate: a caller that doesn't know the total leaves it `null` and gets a
718
+ spinner, rather than a progress bar that guesses.
719
+
720
+ Transport is adaptive polling — 1 s while something is running, a minute when idle.
721
+ There's no streaming transport in the server yet, and SSE through three runtime
722
+ adapters isn't worth it to move a progress bar; only the poll would change if one
723
+ ever exists.
724
+
725
+ ### Picking up changes made outside Trove
726
+
727
+ Trove is not the only thing that can write to your bucket. Another tool, a teammate
728
+ with the S3 console, a sync client, a lifecycle rule — any of them leaves the drive
729
+ describing a world that no longer exists, and with no folders, "it isn't in the list"
730
+ is indistinguishable from "it was never there".
731
+
732
+ A **collection scan** reconciles the two, naming the four things an object can be:
733
+
734
+ | | |
735
+ |---|---|
736
+ | known & unchanged | nothing to do |
737
+ | in the store only | **adopted** — an item is created, named from its key |
738
+ | changed in place | **refreshed** — re-read and re-indexed |
739
+ | in metadata only | **orphaned** — reported, *never* deleted automatically |
740
+
741
+ That last asymmetry is deliberate. Adopting a file is additive and reversible;
742
+ removing an item because a LIST call didn't mention it is neither — and listing is
743
+ exactly the operation that fails in interesting ways (a wrong prefix, a stale
744
+ replica, a credential scoped elsewhere). Trove will invent an item from bytes it can
745
+ see. It will not destroy a record because it briefly couldn't see any.
746
+
747
+ ```sh
748
+ curl -X POST http://localhost:8787/api/collections/default/scan
749
+ # or "Scan Collection for Outside Changes" in the palette
750
+ ```
751
+
752
+ Set `TROVE_SCAN_INTERVAL_MS` to scan on a timer. Off by default: a scan lists every
753
+ object in the bucket, which costs API calls on S3 and load on a NAS. Turn it on when
754
+ something other than Trove writes to the same bucket. On Workers a timer cannot outlive
755
+ a request — use a Cron Trigger instead, see
756
+ [Work that outlives a request](#work-that-outlives-a-request).
757
+
758
+ ### Reindexing
759
+
760
+ Indexing runs when an item is written (`writeFile`) or when an upload completes
761
+ (`POST /api/uploads/:id/complete`) — including uploads that went straight to S3, since
762
+ the *complete* call is the trigger. Objects written directly into the bucket behind
763
+ Trove's back are invisible: Trove owns its key namespace, and there's no bucket scan.
764
+
765
+ A full rebuild happens automatically when the index is empty and the drive is not, and
766
+ on demand:
767
+
768
+ ```sh
769
+ curl -X POST http://localhost:8787/api/reindex # or: "Rebuild Search Index" in the palette
770
+ ```
771
+
772
+ It returns a task rather than blocking. Drive-wide, so it requires either a
773
+ `TROVE_ADMINS` admin or someone who can already read and write every collection —
774
+ which the default single-user self-host is.
775
+
776
+ ## Where clients sign in
777
+
778
+ Trove does not run a login system. It verifies tokens somebody else issued, which leaves
779
+ one question every refused request has to answer: *where do I go and get one?* A bare 401
780
+ is a dead end for a browser and an absolute dead end for an agent, which has no human to
781
+ ask.
782
+
783
+ So the drive publishes it, once, for everything:
784
+
785
+ ```sh
786
+ TROVE_AUTH_SERVER=https://auth.example.com
787
+ ```
788
+
789
+ Every 401 — from the JSON API, from the MCP endpoint — then carries a pointer:
790
+
791
+ ```
792
+ WWW-Authenticate: Bearer realm="Trove", error="invalid_token",
793
+ error_description="...",
794
+ resource_metadata="https://drive.example.com/.well-known/oauth-protected-resource"
795
+ ```
796
+
797
+ and that document ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html)) names the
798
+ authorization server. It is the same mechanism the MCP authorization spec is built on,
799
+ which is why implementing it once serves both a browser and an agent.
800
+
801
+ **You often don't have to set it.** For an OIDC provider the issuer identifier *is* the
802
+ authorization server — that is what RFC 8414 locates its metadata relative to — so when
803
+ `TROVE_JWT_ISSUER` is a URL, Trove uses it. Set `TROVE_AUTH_SERVER` when they genuinely
804
+ differ. Asking for the same URL under two names is a good way to end up with two
805
+ different answers.
806
+
807
+ The inference only fires when the issuer is an `https` URL (or `http` on loopback). A
808
+ JWT `iss` is `StringOrURI`, so a deployment minting its own tokens may well have set it
809
+ to `my-gateway` or a URN — publishing one of those as an authorization server would send
810
+ clients off to fetch `.well-known` from a string, which fails less usefully than
811
+ publishing nothing. In that case Trove publishes nothing and says why, at boot.
812
+
813
+ Not setting either is the failure that looks like success: auth is required, and there is
814
+ nowhere to send anyone. Trove says so in the challenge itself and in Settings, rather
815
+ than leaving you to infer it from an empty field.
816
+
817
+ It is deployment configuration, not a preference — pointing the drive at a different
818
+ authorization server changes who can reach every file in it — so it comes from the
819
+ environment, or from the library caller:
820
+
821
+ ```js
822
+ const { handle } = await createServer({
823
+ authServer: 'https://auth.example.com',
824
+ identity: { driver: 'jwt', jwt: { jwksUrl: '...', audience: '...' } },
825
+ });
826
+ ```
827
+
828
+ ### Cloudflare Access (Zero Trust)
829
+
830
+ Two env vars, and both the browser and agents are covered:
831
+
832
+ ```sh
833
+ TROVE_AUTH=cloudflare-access
834
+ TROVE_CF_ACCESS_TEAM=acme # or acme.cloudflareaccess.com
835
+ TROVE_CF_ACCESS_AUD=<aud-tag> # the Access application's AUD
836
+ ```
837
+
838
+ That derives the JWKS URL, the issuer, and the authorization server — they are all the
839
+ same team domain, and writing it into three settings is three chances to have them
840
+ disagree. A domain that isn't `*.cloudflareaccess.com` is refused rather than accepted,
841
+ since it would send both token verification and agent sign-in somewhere unintended.
842
+
843
+ **For agents, turn on [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/)
844
+ on the Access application.** Access then acts as the OAuth authorization server itself —
845
+ including dynamic client registration, which the MCP spec expects and which a plain
846
+ Access SaaS/OIDC app does not do. Without it, an agent hitting `/mcp` gets Access's HTML
847
+ login page, which it cannot do anything with.
848
+
849
+ With managed OAuth on, Access answers the 401 at the edge, so **Trove's own challenge is
850
+ never reached** — that is fine and intended, and it is why Trove doesn't need to be
851
+ configured differently for it. What arrives at the origin is the resolved
852
+ `Cf-Access-Jwt-Assertion`, which Trove verifies exactly as it does a browser's.
853
+
854
+ One subtlety worth knowing, because it is invisible when it goes wrong: the agent's token
855
+ under managed OAuth is **opaque**, not a JWT, and it travels in `Authorization: Bearer`.
856
+ The real signed JWT is the assertion header. Trove reads the assertion header *first* for
857
+ that reason — and because it is the one the edge vouched for, rather than the one the
858
+ caller typed.
859
+
860
+ #### Who your users are, to Trove
861
+
862
+ Access mints a token whose `sub` is an **internal user UUID**, not an address. That UUID
863
+ becomes `principal.id`; the address lands in the `email` claim. So anywhere you write a
864
+ person down — `TROVE_ADMINS`, a collection's `user` grant — **either works**, and the
865
+ email is the one to reach for.
866
+
867
+ ```sh
868
+ curl -s https://drive.example.com/api/me | jq
869
+ # { "principal": { "id": "8f2a1c04-…", # Access user UUID (the `sub` claim)
870
+ # "email": "you@example.com",
871
+ # "roles": [] },
872
+ # "authenticated": true, "admin": true }
873
+ ```
874
+
875
+ `roles` comes from a `roles` or `groups` claim. Access does not send one by default —
876
+ add it to the application's OIDC claims if you want `TROVE_COLLECTION_CREATOR_ROLES` or
877
+ role-based grants to have anything to match.
878
+
879
+ ## Connecting an AI agent (MCP)
880
+
881
+ Trove speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `/mcp`.
882
+ Point an assistant at that URL and it can search the drive, read and write files, and
883
+ follow the `trove:` links between them. The tools go through the same permission checks
884
+ the web app does, so an agent sees exactly what the person whose token it holds sees —
885
+ there is no service account and no MCP-shaped path around the collection ACL.
886
+
887
+ ```sh
888
+ # What to paste into an assistant, and whether it needs a token:
889
+ curl http://localhost:8787/api/capabilities | jq '.mcp, .auth'
890
+ ```
891
+
892
+ On an open drive (the zero-config default) an agent just connects. Demanding a bearer
893
+ token from an agent for a drive that demands none from a browser would protect nothing.
894
+
895
+ ### Authentication
896
+
897
+ When the drive has a real identity provider, MCP requires the **same JWT** the browser
898
+ presents. An agent that hasn't got one discovers where to get it, per
899
+ [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html), which is what the MCP
900
+ authorization spec builds on:
901
+
902
+ ```
903
+ $ curl -i -X POST http://localhost:8787/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
904
+ HTTP/1.1 401 Unauthorized
905
+ WWW-Authenticate: Bearer realm="Trove", error="invalid_token",
906
+ error_description="...",
907
+ resource_metadata="http://localhost:8787/.well-known/oauth-protected-resource/mcp"
908
+
909
+ $ curl http://localhost:8787/.well-known/oauth-protected-resource/mcp
910
+ {"resource":"http://localhost:8787/mcp",
911
+ "authorization_servers":["https://auth.example.com"],
912
+ "scopes_supported":["trove:read","trove:write"],
913
+ "bearer_methods_supported":["header"]}
914
+ ```
915
+
916
+ The client reads that, runs the OAuth flow at your authorization server, and comes back
917
+ with a token. Trove never runs the login itself — it verifies what your IdP issued.
918
+
919
+ **This is the drive's authorization server, not MCP's.** "Where do I sign in" is a
920
+ property of the deployment, so a 401 from `/api/items` carries the same challenge, and
921
+ `/.well-known/oauth-protected-resource` describes the drive itself. One setting, two
922
+ surfaces, no way for them to disagree — see [Where clients sign in](#where-clients-sign-in).
923
+
924
+ | variable | what it does |
925
+ | --- | --- |
926
+ | `TROVE_MCP` | `off` to disable the endpoint entirely |
927
+ | `TROVE_MCP_RESOURCE` | the canonical public URL, when a proxy rewrites the Host |
928
+ | `TROVE_MCP_PATH` | serve it somewhere other than `/mcp` |
929
+ | `TROVE_MCP_REQUIRE_AUTH` | force auth on or off, rather than following the drive |
930
+
931
+ Note what is *not* in that table: the authorization server. Those four are all about
932
+ *this endpoint* — where it lives and whether it demands a token. Where the token comes
933
+ from belongs to the drive.
934
+
935
+ ### Tools
936
+
937
+ | tool | |
938
+ | --- | --- |
939
+ | `search_files` | semantic + keyword search, `#tag` filters, across everything readable |
940
+ | `list_files` | page through a collection |
941
+ | `read_file` | text by id, name, or `trove:` URI |
942
+ | `write_file` | create or replace (needs write) |
943
+ | `delete_file` | to the trash, recoverable (needs delete) |
944
+ | `list_collections` | what you can see, and what you may do in each |
945
+ | `get_file_info` | type, size, tags, and backlinks |
946
+
947
+ Files are also exposed as MCP **resources** under their `trove:` URIs, for clients that
948
+ attach context rather than calling tools.
949
+
950
+ The server tells the model up front that this drive has no folders — otherwise every
951
+ assistant spends its first few turns constructing paths that don't exist.
952
+
953
+ ## Using the core as a library
954
+
955
+ Every backend is a provider you inject into the server (or `createVfs`) — pass a
956
+ class instance, or a `{ driver, ... }` config the server builds for you:
957
+
958
+ ```js
959
+ import { createServer } from '@3sln/trove';
960
+ import { S3Storage, SqliteStore, HttpEmbedding, QdrantVectorStore } from '@3sln/trove/core';
961
+
962
+ const { handle } = await createServer({
963
+ storage: new S3Storage({ bucket, region, accessKeyId, secretAccessKey }),
964
+ metadata: new SqliteStore({ path: 'trove.db' }),
965
+ embeddings: new HttpEmbedding({ url, apiKey, model, dimensions: 1536 }),
966
+ vectorStore: new QdrantVectorStore({ url, collection: 'trove', dimensions: 1536 }),
967
+ });
968
+ ```
969
+
970
+ ### Writing a custom driver
971
+
972
+ Every seam is a small async class. Subclass it, pass the instance in, and the server
973
+ uses it — there is no registration step and no factory to teach about it, because
974
+ `resolve()` takes either an instance or a `{ driver }` config and an instance always
975
+ wins.
976
+
977
+ | you want to change | implement | the methods that matter |
978
+ | --- | --- | --- |
979
+ | where bytes live | `StorageBackend` | `put` `get` `delete` `list` `head` (+ `presign*`, `usage` if you can) |
980
+ | where records live | `MetadataStore` | `create` `getById` `listItems` `rename` `remove` `findByTags` … |
981
+ | the vector index | `VectorStore` | `add` `query` `remove{,ByNode,ByIndexer,ByNodeIndexer}` |
982
+ | the keyword index | `KeywordStore` | `add` `search` `remove*` `count` |
983
+ | how text becomes vectors | `EmbeddingProvider` | `embed(texts) -> number[][]` |
984
+ | SQL (D1, Turso, Postgres…) | `SqliteProvider` + `SqliteDatabase` | `obtain` / `exec` `run` `get` `all` `batch` |
985
+ | who the caller is | `IdentityProvider` | `authenticate(request) -> Principal \| null` |
986
+ | what a search query means | `SearchTransformer` | `transform(raw, ctx)` and `describe()` |
987
+ | shared small state | `KeyValueStore` | `get` `set` `delete` `list` |
988
+
989
+ ```js
990
+ import { createServer } from '@3sln/trove';
991
+ import { VectorStore } from '@3sln/trove/core';
992
+
993
+ class PgVectorStore extends VectorStore {
994
+ constructor(pool, { dimensions }) { super(); this.pool = pool; this.dimensions = dimensions; }
995
+ async add(docs) { /* upsert (id, nodeId, indexerId, vector) */ }
996
+ async query(vector, { limit = 20, collectionIds } = {}) {
997
+ // return [{ id, nodeId, indexerId, score }] — score higher-is-better
998
+ }
999
+ async removeByNode(nodeId) { /* … */ }
1000
+ // removeByIndexer / removeByNodeIndexer / remove likewise
1001
+ }
1002
+
1003
+ const { handle } = await createServer({
1004
+ vectorStore: new PgVectorStore(pool, { dimensions: 1536 }),
1005
+ });
1006
+ ```
1007
+
1008
+ Two conventions the interfaces rely on, both of which will bite quietly if ignored:
1009
+
1010
+ - **Say what you can't do rather than pretending.** `StorageBackend.capabilities`
1011
+ advertises `presignDownload`, `list`, `usage` and friends, and callers branch on it —
1012
+ an S3 deployment uploads straight to the bucket while a filesystem one proxies,
1013
+ from the same client code. A backend that can't report free space returns `null` from
1014
+ `usage()` and the UI shows no gauge, rather than a meter built from a guess.
1015
+ - **`durable` is a claim, not an inference.** A `SqliteProvider` that says `false` gets
1016
+ the in-memory search stores, because an index in an ephemeral database is worse than
1017
+ one in memory: it looks persistent right until the restart that proves it isn't.
1018
+
1019
+ `D1SqliteProvider` is worth reading as a worked example — it is the whole
1020
+ `SqliteProvider` + `SqliteDatabase` pair against a database with a slightly different
1021
+ dialect, in about a hundred lines, and its tests run the real `SqliteStore` and
1022
+ `SqliteKV` against a D1-shaped shim.
1023
+
1024
+ Or drive the built-in drivers from env:
1025
+
1026
+ ```sh
1027
+ TROVE_VECTOR=qdrant TROVE_QDRANT_URL=http://localhost:6333 \
1028
+ TROVE_QDRANT_COLLECTION=trove node packages/server/src/adapters/node.js
1029
+ ```
1030
+
1031
+ ### Where the search index lives
1032
+
1033
+ `TROVE_VECTOR` (`sqlite` | `memory` | `qdrant` | `vectorize`) and `TROVE_KEYWORD`
1034
+ (`sqlite` | `memory`) pick the stores. You normally set neither: a deployment with
1035
+ a SQLite database gets the durable SQLite stores, and one with nothing to persist
1036
+ to gets the in-memory ones — an index in an ephemeral database is worse than one
1037
+ in memory, because it looks persistent until the restart that proves it isn't.
1038
+ `GET /api/capabilities` reports which stores are in use and whether they're
1039
+ `durable`.
1040
+
1041
+ FTS5 (keywords) is compiled into both `bun:sqlite` and `node:sqlite`, so there is
1042
+ nothing to install. `sqlite-vec` (vectors) is a prebuilt native artifact and
1043
+ therefore an **optional** dependency: if it can't load on your platform, Trove
1044
+ logs a warning, keeps keyword search durable, and falls back to an in-memory
1045
+ vector index rather than refusing to start.
1046
+
1047
+ The lower-level `createVfs` helper does the same wiring for library use:
1048
+
1049
+ ```js
1050
+ import { createVfs } from '@3sln/trove/core';
1051
+ const vfs = await createVfs({ storage, metadata, embeddings, vectorStore });
1052
+ await vfs.writeFile('root', 'note.txt', 'hello');
1053
+ const hits = await vfs.searchQuery('greeting');
1054
+ ```
1055
+
1056
+ ## Writing a plugin
1057
+
1058
+ A plugin is a **ZIP** containing a `manifest.json`, an entry script, and any
1059
+ assets. The manifest declares the plugin's id, the capabilities it wants, its
1060
+ contributions, and its settings:
1061
+
1062
+ Capabilities are declared as an object — each key is a capability, each value is
1063
+ that capability's **options**. A capability that takes no options uses `true` (an
1064
+ empty object works too); the `network` capability carries its allowed endpoint
1065
+ prefixes:
1066
+
1067
+ ```json
1068
+ {
1069
+ "id": "com.example.hello",
1070
+ "name": "Hello",
1071
+ "version": "1.0.0",
1072
+ "entry": "plugin.js",
1073
+ "domain": "plugins.example.com",
1074
+ "capabilities": {
1075
+ "ui": true,
1076
+ "commands": true,
1077
+ "storage": { "plugin": true, "domain": false },
1078
+ "indexer": true,
1079
+ "network": { "endpoints": ["https://api.example.com/v1/"] }
1080
+ },
1081
+ "settings": [{ "key": "apiKey", "type": "string", "title": "API key", "secret": true }]
1082
+ }
1083
+ ```
1084
+
1085
+ A package can be a single entry script (`entry: "plugin.js"`) or **multiple ES
1086
+ modules**: put your code under `src/` and use ordinary relative imports — no
1087
+ bundler required. The host loads every `src/*.js` file as a `blob:` module inside
1088
+ the sandbox and wires them with an import map, so `import './lib/util.js'` and
1089
+ `import { activate } from 'trove'` both resolve; everything outside `src/` is an
1090
+ opaque asset you read via `ctx.resources`.
1091
+
1092
+ ```
1093
+ my-plugin.zip
1094
+ ├─ manifest.json # "entry": "src/index.js"
1095
+ ├─ src/index.js # imports ./lib/http.js, 'trove'
1096
+ ├─ src/lib/http.js
1097
+ └─ assets/banner.png # read via ctx.resources, not importable
1098
+ ```
1099
+
1100
+ The host injects `@3sln/trove/plugin-sdk` into the sandboxed frame; the entry script
1101
+ calls `trove.activate` (or `import { activate } from 'trove'`):
1102
+
1103
+ ```js
1104
+ trove.activate(async (ctx) => {
1105
+ // Contribute a command, a status item, an opener, or an indexer.
1106
+ ctx.commands.register('hello.world', () => ctx.ui.toast('Hi from a plugin!'));
1107
+
1108
+ // Read a packaged asset via an opaque handle (no URLs leak out of the frame).
1109
+ const banner = await ctx.resources.text('banner.txt');
1110
+
1111
+ // Persist state in the plugin's own SQLite db (declare "storage"). Each scope
1112
+ // has a `.server` (online) and `.client` (on-device, offline) handle.
1113
+ const db = ctx.storage.plugin.server;
1114
+ await db.exec('CREATE TABLE IF NOT EXISTS state (k TEXT PRIMARY KEY, v TEXT)');
1115
+ const row = await db.get('SELECT v FROM state WHERE k = ?', 'count');
1116
+ await db.run('INSERT OR REPLACE INTO state VALUES (?, ?)', 'count', String(Number(row?.v || 0) + 1));
1117
+
1118
+ // Read a secret the user entered in settings (never stored in plaintext prefs).
1119
+ const key = await ctx.settings.getSecret('apiKey');
1120
+
1121
+ // Reach the web only through the host, and only to declared endpoints ("network").
1122
+ const res = await ctx.net.fetch('https://api.example.com/v1/status', {
1123
+ headers: { Authorization: `Bearer ${key}` },
1124
+ });
1125
+ const data = await res.json();
1126
+
1127
+ // Push search documents under this plugin's namespace (declare "indexer").
1128
+ ctx.contributes.indexer({ id: 'labels', title: 'Image labels' });
1129
+ await ctx.files.index('labels', nodeId, [{ text: 'golden retriever, park' }], { tags: [...] });
1130
+ });
1131
+ ```
1132
+
1133
+ Capabilities the manifest doesn't request (or the user doesn't grant) are simply
1134
+ absent from `ctx`. To ship a **domain-verified** plugin, sign the package and
1135
+ publish the key's fingerprint at `https://<domain>/.well-known/trove-assetlinks.json`.
1136
+ See `packages/web/test/pluginFixture.mjs` for a complete, self-contained example
1137
+ package.
1138
+
1139
+ Inside the sandbox the host injects the SDK and exposes it as the global `trove`.
1140
+ When you build or bundle your plugin outside the sandbox, `import { activate } from
1141
+ '@3sln/trove/plugin-sdk'` resolves to the **same implementation** — the package entry is
1142
+ a thin re-export of the injected build, so there's no drift between what you import
1143
+ and what actually runs.
1144
+
1145
+ ## Layout
1146
+
1147
+ ```
1148
+ packages/
1149
+ core/ @3sln/trove/core — Vfs, storage/metadata/search backends, uploads (runtime-agnostic)
1150
+ server/ @3sln/trove/server — Request→Response API + Bun / Node / Worker adapters
1151
+ web/ @3sln/trove/web — the workbench (dodo + ngin)
1152
+ plugin-sdk/ @3sln/trove/plugin-sdk — the iframe-side plugin API + RPC
1153
+ create-trove/ @3sln/create-trove — the scaffolder; the one directory that is
1154
+ its own published package rather than part of @3sln/trove
1155
+ ```
1156
+
1157
+ ## Releasing
1158
+
1159
+ Two packages, one version:
1160
+
1161
+ ```sh
1162
+ npm version patch # or minor / major — bumps BOTH manifests in one commit
1163
+ git push --follow-tags
1164
+ ```
1165
+
1166
+ `@3sln/trove` and `@3sln/create-trove` are released together and carry the same number,
1167
+ which is what lets the scaffolder pin the exact drive it shipped alongside by reading its
1168
+ own version. npm has no way to share a version between manifests, so the root is the
1169
+ source of truth and the `version` lifecycle script copies it down
1170
+ (`scripts/sync-version.mjs`) and stages the result — there is no second edit to remember.
1171
+ `npm run sync-version` does it on demand after a hand-edit; a unit test catches drift on
1172
+ every pull request, and `publish.yml` checks again before the registry.
1173
+
1174
+ Pushing the bump to `main` opens a **draft** release. Publishing that draft creates the
1175
+ tag and runs `publish.yml`, which tests, builds the web app, verifies both tarballs
1176
+ actually contain what makes them work, and publishes each package — skipping either one
1177
+ that is already on the registry, so a half-failed release can simply be published again.
1178
+
1179
+ ## Tests
1180
+
1181
+ ```sh
1182
+ bun test # node-level units: core, server, plugin-sdk, mp4, plugin packages/signing
1183
+ npm run test:browser --prefix packages/web # web units in real Chromium (@web/test-runner): signing, module graph, zip
1184
+ node packages/web/test/e2e.mjs # full workbench in headless Chromium
1185
+ node packages/web/test/plugins.e2e.mjs # sandboxed plugin install, brokered network, offline availability
1186
+ node packages/web/test/offline.e2e.mjs # service worker, pinning, offline queue + sync
1187
+ node packages/web/test/multiuser.e2e.mjs # access boundaries across 4 users & 2 collections, API + UI
1188
+ node packages/web/test/probe/run-all.mjs # error-path probes: broken openers, server faults, retry, uninstall failure, opener choice, activity/issues
1189
+ node packages/web/test/probe/walkthrough.mjs # full in-browser user journey + screenshots (test/screens/)
1190
+ ```
1191
+
1192
+ The web unit suites run under **both** `bun test` (fast, via `test/testkit.js`) and
1193
+ `@web/test-runner` (a real browser, the platform plugins ship to) — the same files,
1194
+ no duplication.
1195
+
1196
+ **Probes** (`test/probe/`) cover the error paths a happy-path e2e can't: an opener
1197
+ whose bytes are missing, a server that 500s at startup, a failed search, a plugin whose
1198
+ server-side uninstall fails. They deliberately trigger 404s and faults, so they live
1199
+ outside the strict e2e suites. The **walkthrough** drives the whole app end to end and
1200
+ screenshots each step — the "does it all still work together" check.
1201
+
1202
+ ### Testing against S3
1203
+
1204
+ `bun test` runs the S3 backend against **s3rver**, an in-process S3 server (a
1205
+ devDependency — no daemon, no container). That covers the wire protocol: paths,
1206
+ Range, ETags, XML, and the multipart create/put/complete dance. It does **not**
1207
+ cover authentication — s3rver doesn't implement SigV4 verification and will
1208
+ accept any signature. Signing is covered separately in
1209
+ `packages/core/test/s3sigv4.test.js`, which diffs our signer against `aws4` (an
1210
+ independent implementation) and against AWS's own published example, so nothing
1211
+ in the signing path is checked only by code we wrote.
1212
+
1213
+ For real-server fidelity — signature verification, the 5 MiB minimum part size,
1214
+ real error codes — point the same tests at MinIO, Garage, R2, or S3:
1215
+
1216
+ ```sh
1217
+ docker run -p 9000:9000 -e MINIO_ROOT_USER=minioadmin \
1218
+ -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data
1219
+ # create the bucket, then:
1220
+ TROVE_S3_TEST_ENDPOINT=http://127.0.0.1:9000 TROVE_S3_TEST_BUCKET=trove-test \
1221
+ TROVE_S3_TEST_KEY=minioadmin TROVE_S3_TEST_SECRET=minioadmin \
1222
+ bun test packages/core/test/s3-e2e.test.js
1223
+ ```
1224
+
1225
+ ## License
1226
+
1227
+ MIT © Ray Stubbs