@3sln/trove 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
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
|