@3sln/trove 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,1066 @@
1
+ // The Trove HTTP API, mapped onto Vfs methods. JSON in/out except downloads
2
+ // (bytes, range-aware) and direct part uploads (raw body). Downloads redirect to
3
+ // a presigned URL when the backend supports it, otherwise stream through here.
4
+
5
+ import { Router, json, parseRange } from './router.js';
6
+ import {
7
+ TroveError, assertSafePluginSql, concatBytes, metadataUrl, publicOrigin,
8
+ } from '@3sln/trove/core';
9
+ import { parseContribUri, CORE_DOMAIN } from '@3sln/trove/core/plugins/identity.js';
10
+
11
+ const ENV = typeof process !== 'undefined' ? (process.env || {}) : {};
12
+ // Cap JSON request bodies so a giant payload can't exhaust server memory. Uploads
13
+ // don't use body() (their bytes stream straight to storage), so this is safe to keep small.
14
+ const MAX_JSON_BYTES = Number(ENV.TROVE_MAX_JSON_BYTES || 4 * 1024 * 1024);
15
+ // Clamp any client-supplied result limit to a sane ceiling (DoS via huge scans).
16
+ const MAX_PAGE = Number(ENV.TROVE_MAX_PAGE || 1000);
17
+ // How many signed URLs one request may mint. A gallery asks for what it is about to
18
+ // draw, not for the whole drive — and each one costs an authorization check.
19
+ const URL_MINT_BATCH = Number(ENV.TROVE_URL_BATCH || 200);
20
+
21
+ async function body(req) {
22
+ const text = await readCapped(req, MAX_JSON_BYTES);
23
+ if (!text) return {};
24
+ try {
25
+ return JSON.parse(text);
26
+ } catch {
27
+ throw TroveError.invalid('Body must be valid JSON');
28
+ }
29
+ }
30
+
31
+ // Read the body as text, aborting if it exceeds `max` bytes (checks Content-Length
32
+ // first, then enforces while streaming in case the header lies or is absent).
33
+ async function readCapped(req, max) {
34
+ const declared = Number(req.headers.get('content-length') || 0);
35
+ if (declared && declared > max) throw TroveError.invalid('Request body too large');
36
+ const reader = req.body?.getReader?.();
37
+ if (!reader) {
38
+ const text = await req.text();
39
+ if (text.length > max) throw TroveError.invalid('Request body too large');
40
+ return text;
41
+ }
42
+ const chunks = [];
43
+ let total = 0;
44
+ for (;;) {
45
+ const { done, value } = await reader.read();
46
+ if (done) break;
47
+ total += value.byteLength;
48
+ if (total > max) { await reader.cancel().catch(() => {}); throw TroveError.invalid('Request body too large'); }
49
+ chunks.push(value);
50
+ }
51
+ return new TextDecoder().decode(concatBytes(chunks));
52
+ }
53
+ // Read a raw binary body (e.g. an uploaded plugin package), capped like readCapped —
54
+ // which means enforcing WHILE streaming, not after. Checking `.byteLength` on the result
55
+ // of `arrayBuffer()` is a check that happens once the whole body is already resident, so
56
+ // a chunked upload with no Content-Length could park 400 MB in the heap and only then be
57
+ // told it was too large.
58
+ async function readBytesCapped(req, max) {
59
+ const declared = Number(req.headers.get('content-length') || 0);
60
+ if (declared && declared > max) throw TroveError.invalid('Request body too large');
61
+ const reader = req.body?.getReader?.();
62
+ if (!reader) {
63
+ const buf = new Uint8Array(await req.arrayBuffer());
64
+ if (buf.byteLength > max) throw TroveError.invalid('Request body too large');
65
+ return buf;
66
+ }
67
+ const chunks = [];
68
+ let total = 0;
69
+ for (;;) {
70
+ const { done, value } = await reader.read();
71
+ if (done) break;
72
+ total += value.byteLength;
73
+ if (total > max) { await reader.cancel().catch(() => {}); throw TroveError.invalid('Request body too large'); }
74
+ chunks.push(value);
75
+ }
76
+ return concatBytes(chunks);
77
+ }
78
+ function clampLimit(value, dflt) {
79
+ const n = Number(value);
80
+ if (!Number.isFinite(n) || n <= 0) return dflt;
81
+ return Math.min(Math.floor(n), MAX_PAGE);
82
+ }
83
+
84
+ // Content types safe to render inline in the app's own origin. Anything else
85
+ // (HTML, SVG, XML, scripts…) is forced to download so it can't execute as
86
+ // same-origin script when opened directly.
87
+ function inlineSafe(ct) {
88
+ const t = String(ct || '').toLowerCase().split(';')[0].trim();
89
+ if (t === 'image/svg+xml') return false;
90
+ return /^image\//.test(t) || /^audio\//.test(t) || /^video\//.test(t) || t === 'application/pdf' || t === 'text/plain';
91
+ }
92
+
93
+ // Build a Content-Disposition header (RFC 6266).
94
+ //
95
+ // `filename` is a quoted-string, so a browser takes the bytes literally — percent-
96
+ // encoding it, which is what this used to do, is not a decoding any client performs.
97
+ // "Q3 report, final.pdf" arrived as "Q3%20report%2C%20final.pdf" and that is the name
98
+ // that landed on disk, for essentially every real filename. So: an ASCII fallback in
99
+ // `filename` (with the two characters that would break the quoting removed) and the
100
+ // real name in `filename*`, which IS percent-encoded by specification.
101
+ function contentDisposition(type, name) {
102
+ const clean = String(name || 'download').replace(/[\\"]/g, '').replace(/[\x00-\x1f\x7f]/g, '');
103
+ const ascii = clean.replace(/[^\x20-\x7e]/g, '_') || 'download';
104
+ return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean)}`;
105
+ }
106
+
107
+ // Reject SSRF-prone hosts for the server-side assetlinks fetch: IP literals,
108
+ // loopback, link-local (cloud metadata), and internal TLDs. DNS names that resolve
109
+ // to private IPs are a residual (rebinding) risk, documented in the README.
110
+ function assertPublicHost(hostname) {
111
+ // Normalise the way `fetch` will. This tested the RAW string against a dotted-quad
112
+ // regex, but the WHATWG URL parser accepts `127.1`, `0x7f.0.0.1`, `0177.0.0.1` and
113
+ // `2130706433` and turns them all into 127.0.0.1 — so anything that wasn't already
114
+ // dotted-quad walked straight past the private-address check.
115
+ let h;
116
+ try {
117
+ h = new URL(`https://${hostname}`).hostname.toLowerCase();
118
+ } catch {
119
+ throw TroveError.invalid('That is not a valid host');
120
+ }
121
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) {
122
+ throw TroveError.invalid('Refusing to fetch from an internal host');
123
+ }
124
+ // IPv4 literal → block private/loopback/link-local ranges; block IPv6 literals wholesale.
125
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
126
+ const [a, b] = h.split('.').map(Number);
127
+ if (a === 10 || a === 127 || a === 0 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) || a >= 224) {
128
+ throw TroveError.invalid('Refusing to fetch from a private address');
129
+ }
130
+ }
131
+ if (h.includes(':') || h.startsWith('[')) throw TroveError.invalid('Refusing to fetch from an IP literal');
132
+ return h;
133
+ }
134
+
135
+ // Which collection a request targets. There is no folder to infer one from any more,
136
+ // so it's named explicitly or it's the default.
137
+ function collectionOf(src) {
138
+ return src?.collection || src?.collectionId || 'default';
139
+ }
140
+
141
+ export function createRouter() {
142
+ const r = new Router();
143
+
144
+ // Liveness: the process is up and serving.
145
+ r.get('/api/health', [], () => ({ ok: true, service: 'trove', time: Date.now() }));
146
+
147
+ // Readiness: the backing store actually answers — for load-balancer / k8s gating.
148
+ r.get('/api/ready', ['sqlite'], async ({ sqlite }) => {
149
+ try {
150
+ if (sqlite) { const db = await sqlite.obtain({ key: 'metadata' }); await db.get('SELECT 1'); }
151
+ return { ok: true };
152
+ } catch (err) {
153
+ throw TroveError.transient('Storage not ready', { cause: err });
154
+ }
155
+ });
156
+
157
+ r.get('/api/capabilities', ['auth', 'collections', 'notifications', 'sidecar', 'vfs'], async (ctx) => {
158
+ const { vfs, config, sidecar, notifications, principal, query, auth, mcp } = ctx;
159
+ // Storage is per-collection, so report the backend for the requested collection
160
+ // (else the client picks the wrong upload strategy on a non-default collection).
161
+ let storage = vfs.storage;
162
+ if (query.collection) {
163
+ storage = await (await ctx.access.collection(query.collection, 'read')).storage();
164
+ }
165
+ return {
166
+ collection: query.collection || 'default',
167
+ storage: storage.capabilities,
168
+ indexers: vfs.indexers.list(),
169
+ partSize: vfs.uploads.partSize,
170
+ features: {
171
+ semanticSearch: !!vfs.search,
172
+ conversations: !!sidecar,
173
+ notifications: !!notifications,
174
+ webPush: !!notifications?.vapidPublicKey(),
175
+ auth: !!principal && !principal.anonymous,
176
+ },
177
+ principal: principal || null,
178
+ search: vfs.search ? vfs.search.describe() : null,
179
+ // What this deployment's search box actually accepts. The transformer owns the
180
+ // grammar, so it owns the prompt — a client that hardcodes "# filter by tag"
181
+ // tells people the wrong thing the moment a different transformer is configured.
182
+ searchPrompt: vfs.searchTransformer?.describe?.() || null,
183
+
184
+ // Where a refused client is sent, and where an agent connects. Both are DEPLOYMENT
185
+ // facts — env, or a field the library caller passed — so they are reported here
186
+ // rather than made editable: pointing the drive at a different authorization
187
+ // server changes who can reach every file in it, which is a deploy-time decision,
188
+ // not a preference.
189
+ auth: {
190
+ authorizationServers: auth?.authorizationServers || [],
191
+ // Which of TROVE_AUTH_SERVER / TROVE_JWT_ISSUER the value came from, or 'none'.
192
+ // "We inferred this from your issuer" is a different fact from "you set this",
193
+ // and someone debugging a mismatch needs to know which.
194
+ source: auth?.source || 'none',
195
+ metadataUrl: metadataUrl(publicOrigin(ctx.req, config)),
196
+ },
197
+ mcp: mcp ? {
198
+ enabled: true,
199
+ endpoint: mcp.endpoint(ctx.req),
200
+ metadataUrl: metadataUrl(mcp.endpoint(ctx.req)),
201
+ requiresAuth: mcp.requiresAuth(),
202
+ // The one state that makes the endpoint unusable while looking configured: a
203
+ // token is required and there is nowhere to go and get one. Named as a problem
204
+ // rather than left to be inferred from an empty array.
205
+ needsAuthorizationServer: mcp.requiresAuth() && !(auth?.authorizationServers || []).length,
206
+ } : { enabled: false },
207
+
208
+ ...(config?.clientConfig || {}),
209
+ };
210
+ });
211
+
212
+ // --- collections -----------------------------------------------------------
213
+
214
+ r.get('/api/collections', ['collections'], async (ctx) => {
215
+ const { collections, principal } = ctx;
216
+ if (!collectionsEnabled(ctx)) return { collections: [{ id: 'default', name: 'My Drive', capabilities: ['read', 'write', 'delete', 'admin'] }] };
217
+ return { collections: await collections.list(principal), canCreate: collections.canCreate(principal) };
218
+ });
219
+
220
+ r.get('/api/collections/:id', ['collections'], async (ctx) => {
221
+ const { collections, principal, params } = ctx;
222
+ if (!collectionsEnabled(ctx)) return { collection: { id: 'default', name: 'My Drive' } };
223
+ const c = await collections.assert(principal, params.id, 'read');
224
+ return { collection: collections.describe(c, principal) };
225
+ });
226
+
227
+ r.post('/api/collections', ['collections'], async (ctx) => {
228
+ requireCollections(ctx);
229
+ return { collection: await ctx.collections.create(await body(ctx.req), ctx.principal) };
230
+ });
231
+
232
+ r.post('/api/collections/:id', ['collections'], async (ctx) => {
233
+ requireCollections(ctx);
234
+ return { collection: await ctx.collections.update(ctx.params.id, await body(ctx.req), ctx.principal) };
235
+ });
236
+
237
+ r.delete('/api/collections/:id', ['collections', 'vfs'], async (ctx) => {
238
+ requireCollections(ctx);
239
+ const { collections, vfs, principal, params } = ctx;
240
+ // A collection record is the only thing that knows where its items' BYTES live, so
241
+ // deleting it while items still reference it stranded every one of them: `storageFor`
242
+ // throws "Collection not found", and since reindex walks the whole metadata store,
243
+ // every rebuild — including the one at boot — failed on them forever, raising a
244
+ // retryable issue whose Retry re-ran the same failure. Refuse, and say what to do.
245
+ const n = await vfs.metadata.countItems?.(params.id);
246
+ if (n) {
247
+ throw TroveError.conflict(
248
+ `“${params.id}” still holds ${n.toLocaleString()} item${n === 1 ? '' : 's'}. `
249
+ + 'Move or delete them first — removing the collection would leave them with no '
250
+ + 'store to read their bytes from.',
251
+ { details: { collectionId: params.id, items: n } },
252
+ );
253
+ }
254
+ return collections.remove(params.id, principal);
255
+ });
256
+
257
+ r.post('/api/collections/:id/grants', ['collections'], async (ctx) => {
258
+ requireCollections(ctx);
259
+ return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };
260
+ });
261
+
262
+ // --- browse ----------------------------------------------------------------
263
+
264
+ // --- items -----------------------------------------------------------------
265
+ // One noun: there is no filesystem and no folders, so everything addressable is an
266
+ // item in a collection. Collection-level verbs sit at `/api/items/<verb>` and
267
+ // item-scoped ones at `/api/items/:id/…`. The router matches in registration order,
268
+ // so these literal routes must stay ABOVE any same-length `:id` route added later.
269
+
270
+ // Every item in a collection. There is nothing to descend into — a drive is browsed
271
+ // by search and by following links, and this is the "show me everything" fallback.
272
+ r.get('/api/items', ['vfs'], async (ctx) => {
273
+ const { vfs, query } = ctx;
274
+ const collectionId = collectionOf(query);
275
+ const collection = await ctx.access.collection(collectionId, 'read');
276
+ const { items, nextCursor } = await collection.list({
277
+ sort: query.sort, order: query.order,
278
+ limit: clampLimit(query.limit, 500),
279
+ cursor: query.cursor,
280
+ });
281
+ // `stats` describes the COLLECTION; `items` is one page of it. Without this the
282
+ // client can only report the page it happens to be holding, which on a drive with
283
+ // more items than fit in a page is simply a wrong number on screen.
284
+ const stats = await vfs.metadata.collectionStats?.(collectionId).catch(() => null) ?? null;
285
+ // Space left on the backing store, when it can say. Null for object stores, which
286
+ // have no such number — and a UI that showed a made-up gauge for S3 would be worse
287
+ // than one that shows nothing.
288
+ const usage = await collection.usage().catch(() => null);
289
+ return { items, nextCursor, collectionId, stats, usage };
290
+ });
291
+
292
+ // Resolve an item: by id, by `?name=` within a collection, or by a `trove:` URI.
293
+ r.get('/api/items/resolve', [], async (ctx) => {
294
+ const ref = ctx.query.id || ctx.query.uri || ctx.query.name;
295
+ if (!ref) throw TroveError.invalid('id, name or uri is required');
296
+ // A name is only unique within a collection, so the hint goes to `stat`; the
297
+ // capability is asserted on the collection the node turns out to be in.
298
+ const handle = await ctx.access.node(ref, 'read', { collectionId: collectionOf(ctx.query) });
299
+ return { node: handle.node };
300
+ });
301
+
302
+ // What links to this item — the inverse of the links its own content declares, and
303
+ // what replaces "which folder is it in?".
304
+ r.get('/api/items/backlinks', ['collections'], async (ctx) => {
305
+ const node = await ctx.access.node(ctx.query.id, 'read');
306
+ // Scoped in the query, not filtered after: backlinks cross collections, so a limit
307
+ // spent on unreadable rows would report "nothing links here" while something the
308
+ // caller can see sits just past the cut.
309
+ const items = await node.backlinks({
310
+ limit: clampLimit(ctx.query.limit, 100),
311
+ collectionIds: await readableCollectionIds(ctx),
312
+ });
313
+ return { items };
314
+ });
315
+
316
+ r.post('/api/items/rename', [], async (ctx) => {
317
+ const b = await body(ctx.req);
318
+ if (!b.id || !b.newName) throw TroveError.invalid('id and newName are required');
319
+ const node = await ctx.access.node(b.id, 'write');
320
+ return { node: await node.rename(b.newName) };
321
+ });
322
+
323
+ r.post('/api/items/delete', [], async (ctx) => {
324
+ const b = await body(ctx.req);
325
+ if (!b.id) throw TroveError.invalid('id is required');
326
+ return (await ctx.access.node(b.id, 'delete')).remove();
327
+ });
328
+
329
+ // --- download (presign redirect or range-aware proxy) ----------------------
330
+
331
+ r.get('/api/items/download', [], async (ctx) => {
332
+ const { query, req } = ctx;
333
+ if (!query.id) throw TroveError.invalid('id is required');
334
+ // A signed URL brings its own grant, because the things that need one — an <img
335
+ // src>, a <video src>, cache.add(), an external service an indexer handed a URL to
336
+ // — cannot send an Authorization header at all. It grants `read` on exactly the node
337
+ // it names; see engine/providers/access.js and docs/design/signed-urls.md.
338
+ const signature = query.sig ? { op: query.op, exp: query.exp, sig: query.sig } : null;
339
+ const node = await ctx.access.node(query.id, 'read', { signature });
340
+ const ct = node.contentType || 'application/octet-stream';
341
+ // Force a download for anything not safe to render inline in our own origin
342
+ // (HTML/SVG/etc. would otherwise be same-origin XSS when opened directly).
343
+ const attach = query.disposition === 'attachment' || !inlineSafe(ct);
344
+ const range = parseRange(req.headers.get('range'));
345
+
346
+ // Ranged requests must proxy (we can't add Range to a bare redirect safely
347
+ // for all clients), so only redirect for full-file GETs.
348
+ if (!range) {
349
+ const d = await node.download({ download: attach });
350
+ if (d.mode === 'redirect') return Response.redirect(d.url, 302);
351
+ }
352
+
353
+ const { stream, size, contentType, etag, range: served } = await node.read({ range });
354
+ const headers = {
355
+ 'content-type': contentType || ct,
356
+ 'accept-ranges': 'bytes',
357
+ 'content-length': String(size),
358
+ 'x-content-type-options': 'nosniff',
359
+ ...(etag ? { etag } : {}),
360
+ 'content-disposition': contentDisposition(attach ? 'attachment' : 'inline', node.name),
361
+ 'cache-control': 'private, max-age=0',
362
+ };
363
+ if (served) {
364
+ headers['content-range'] = `bytes ${served.start}-${served.end}/${served.total}`;
365
+ return new Response(stream, { status: 206, headers });
366
+ }
367
+ return new Response(stream, { status: 200, headers });
368
+ });
369
+
370
+ // Mint URLs that carry their own authorization, for the things that cannot send a
371
+ // header. Batched on purpose: a gallery draws hundreds of tiles, and per-object
372
+ // signing is right for scoping but hopeless as one round trip each.
373
+ //
374
+ // Every id is authorized individually — the batch is a transport convenience, never a
375
+ // widening. An id the caller may not read is simply absent from the answer, with its
376
+ // reason alongside, so a partly-visible batch still returns the visible part.
377
+ r.post('/api/items/urls', [], async (ctx) => {
378
+ const b = await body(ctx.req);
379
+ const ids = Array.isArray(b.ids) ? b.ids.filter((i) => typeof i === 'string' && i) : [];
380
+ if (!ids.length) throw TroveError.invalid('ids is required');
381
+ if (ids.length > URL_MINT_BATCH) throw TroveError.invalid(`At most ${URL_MINT_BATCH} ids at a time`);
382
+ const op = b.op === 'media' || b.op === 'download' ? b.op : 'download';
383
+ const urls = {};
384
+ const failed = {};
385
+ for (const id of new Set(ids)) {
386
+ try {
387
+ const node = await ctx.access.node(id, 'read');
388
+ const { url, expiresAt } = await node.mintUrl({ op, download: op === 'download' });
389
+ urls[id] = { url, expiresAt };
390
+ } catch (err) {
391
+ failed[id] = err.code || 'internal';
392
+ }
393
+ }
394
+ return { urls, failed, op };
395
+ });
396
+
397
+ // --- uploads ---------------------------------------------------------------
398
+
399
+ r.post('/api/uploads', [], async (ctx) => {
400
+ const b = await body(ctx.req);
401
+ if (!b.name) throw TroveError.invalid('name is required');
402
+ const collection = await ctx.access.collection(collectionOf(b), 'write');
403
+ return uploadDescriptor(await collection.createUpload({
404
+ name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
405
+ overwrite: b.overwrite === true,
406
+ }));
407
+ });
408
+
409
+ // An upload spans several requests keyed only by an unguessable id, so each one
410
+ // re-obtains the handle — which re-asserts `write` on the session's collection. A
411
+ // grant revoked mid-upload stops the next part, which is the point.
412
+
413
+ r.get('/api/uploads/:id/status', [], async (ctx) => (await ctx.access.upload(ctx.params.id)).status());
414
+
415
+ r.post('/api/uploads/:id/parts/:n/sign', [], async (ctx) => {
416
+ const upload = await ctx.access.upload(ctx.params.id);
417
+ return { url: await upload.signPart(Number(ctx.params.n)) };
418
+ });
419
+
420
+ r.post('/api/uploads/:id/parts/:n/report', [], async (ctx) => {
421
+ const upload = await ctx.access.upload(ctx.params.id);
422
+ const b = await body(ctx.req);
423
+ return upload.reportPart(Number(ctx.params.n), b.etag);
424
+ });
425
+
426
+ // Direct part upload — raw body streamed to storage.
427
+ r.put('/api/uploads/:id/parts/:n', [], async (ctx) => {
428
+ const upload = await ctx.access.upload(ctx.params.id);
429
+ return json(await upload.uploadPart(Number(ctx.params.n), ctx.req.body ?? new Uint8Array(0)));
430
+ });
431
+
432
+ r.post('/api/uploads/:id/complete', [], async (ctx) => {
433
+ const upload = await ctx.access.upload(ctx.params.id);
434
+ const b = await body(ctx.req);
435
+ return { node: await upload.complete(b.parts) };
436
+ });
437
+
438
+ r.delete('/api/uploads/:id', [], async (ctx) => {
439
+ await (await ctx.access.upload(ctx.params.id)).abort();
440
+ return { ok: true };
441
+ });
442
+
443
+ // --- search & indexing -----------------------------------------------------
444
+
445
+ r.get('/api/search', ['collections', 'vfs'], async (ctx) => {
446
+ const { vfs, query } = ctx;
447
+ if (!query.q) throw TroveError.invalid('q is required');
448
+ const collectionIds = await readableCollectionIds(ctx, query.collection);
449
+ const results = await vfs.searchQuery(query.q, {
450
+ mode: query.mode, limit: clampLimit(query.limit, 40),
451
+ indexers: query.indexers ? query.indexers.split(',') : undefined,
452
+ collectionIds,
453
+ });
454
+ return { query: query.q, results };
455
+ });
456
+
457
+ // Unified query: a raw user string is run through the search transformer (default
458
+ // parses `#tag` syntax; a plugged-in one may use an LLM), then dispatched. Returns
459
+ // the results AND the `resolved` query (what was actually searched) so the client
460
+ // can honestly show it.
461
+ r.post('/api/query', ['collections', 'vfs'], async (ctx) => {
462
+ const b = await body(ctx.req);
463
+ if (typeof b.q !== 'string' || !b.q.trim()) throw TroveError.invalid('q is required');
464
+ const collectionIds = await readableCollectionIds(ctx, b.collection);
465
+ const { results, resolved } = await ctx.vfs.query(b.q, {
466
+ mode: b.mode, limit: clampLimit(b.limit, 40), collectionIds,
467
+ // Which views this client can draw with, so the transformer can suggest one of
468
+ // them. Passed through as-is — the transformer bounds it before use, and a client
469
+ // that sends nonsense only fails to get a suggestion.
470
+ views: Array.isArray(b.views) ? b.views : undefined,
471
+ });
472
+ return { query: b.q, results, resolved };
473
+ });
474
+
475
+ // Drive-wide tag/property filter (the launcher's `#tag` / `#key:op:value`).
476
+ r.post('/api/tags/search', ['collections', 'vfs'], async (ctx) => {
477
+ const b = await body(ctx.req);
478
+ const filters = Array.isArray(b.filters) ? b.filters : [];
479
+ const collectionIds = await readableCollectionIds(ctx, b.collection);
480
+ const items = await ctx.vfs.findByTags(filters, {
481
+ q: b.q, collectionIds, limit: clampLimit(b.limit, 100),
482
+ });
483
+ return { items };
484
+ });
485
+
486
+ r.get('/api/indexers', ['vfs'], ({ vfs }) => ({ indexers: vfs.indexers.list() }));
487
+
488
+ // Plugin indexers push a namespaced contribution here (semanticTexts / tags /
489
+ // metadata; legacy documents/facet accepted). The namespace is the path param,
490
+ // so a plugin can only ever write under its own id.
491
+ // Push a contribution under a contributor namespace. TWO gates, because they answer
492
+ // different questions: `write` on the collection says you may change this item at
493
+ // all, and namespace ownership says you may speak AS this contributor. Without the
494
+ // second, anyone who can write anywhere could overwrite `core.links` and quietly
495
+ // break every backlink, or impersonate another plugin's index.
496
+ r.post('/api/index/:indexerId', ['plugins'], async (ctx) => {
497
+ const b = await body(ctx.req);
498
+ if (!b.nodeId) throw TroveError.invalid('nodeId is required');
499
+ await assertContributorOwned(ctx, ctx.params.indexerId);
500
+ const node = await ctx.access.node(b.nodeId, 'write');
501
+ return node.contribute(ctx.params.indexerId, {
502
+ semanticTexts: b.semanticTexts, tags: b.tags, metadata: b.metadata,
503
+ documents: b.documents, facet: b.facet, // legacy
504
+ });
505
+ });
506
+
507
+ // --- background work, and problems that outlived it ------------------------
508
+ //
509
+ // Two endpoints that look similar and mean different things. `/api/tasks` is work
510
+ // happening NOW — it is in memory, it is gone when the server restarts, and that is
511
+ // correct, because the work is gone too. `/api/issues` is what a failure LEFT BEHIND:
512
+ // durable, because a file that failed to index is still unindexed tomorrow.
513
+ //
514
+ // Both are polled rather than streamed. There is no streaming transport anywhere in
515
+ // this server yet, and adding one for a progress bar would mean SSE plumbing through
516
+ // three adapters; the client polls fast only while something is running and goes
517
+ // silent otherwise, which costs nothing at rest.
518
+
519
+ // Every call through `ctx.tasks` is awaited. In a single long-lived process the
520
+ // registry is a local object and these resolve immediately; where the work runs
521
+ // somewhere else — a Durable Object, because a Worker isolate cannot own work that
522
+ // outlives a request — the same calls cross a boundary. Awaiting costs nothing in
523
+ // the first case and is the difference between working and not in the second.
524
+
525
+ r.get('/api/tasks', ['collections', 'tasks'], async (ctx) => {
526
+ const collectionIds = await readableCollectionIds(ctx);
527
+ return {
528
+ tasks: await ctx.tasks.list({
529
+ collectionIds,
530
+ // A drive-wide task (a full reindex) names no collection, so scoping can't
531
+ // place it — only someone who can act on the whole drive is shown one.
532
+ includeGlobal: await canWholeDrive(ctx),
533
+ }),
534
+ };
535
+ });
536
+
537
+ r.post('/api/tasks/:id/cancel', ['collections', 'tasks'], async (ctx) => {
538
+ await assertTaskAccess(ctx, await ctx.tasks.get(ctx.params.id), 'cancel');
539
+ return { cancelled: await ctx.tasks.cancel(ctx.params.id) };
540
+ });
541
+
542
+ r.delete('/api/tasks/:id', ['collections', 'tasks'], async (ctx) => {
543
+ await assertTaskAccess(ctx, await ctx.tasks.get(ctx.params.id), 'dismiss');
544
+ await ctx.tasks.dismiss(ctx.params.id);
545
+ return { ok: true };
546
+ });
547
+
548
+ r.get('/api/issues', ['collections', 'issues'], async (ctx) => {
549
+ const collectionIds = await readableCollectionIds(ctx);
550
+ const admin = await canWholeDrive(ctx);
551
+ const issues = await ctx.issues.list({ collectionIds, includeGlobal: admin });
552
+ // `retryable` is computed here, not stored: whether a fix can be attempted depends
553
+ // on which handlers this deployment registered, and the client should offer a Retry
554
+ // button only when pressing it will do something.
555
+ return { issues: issues.map((i) => ({ ...i, retryable: ctx.issues.canRetry(i) })) };
556
+ });
557
+
558
+ // Retrying starts a task and returns it immediately — the fix may take minutes, and
559
+ // holding the request open for it would just time out. The issue is NOT cleared here;
560
+ // it clears when the work actually succeeds.
561
+ r.post('/api/issues/:id/retry', ['collections', 'issues', 'tasks'], async (ctx) => {
562
+ const issue = await ctx.issues.get(ctx.params.id);
563
+ if (!issue) throw TroveError.notFound('Issue');
564
+ await assertIssueAccess(ctx, issue, 'write');
565
+ const started = ctx.issues.retry(ctx.params.id);
566
+ started.catch(() => {}); // the task record carries the failure; don't reject globally
567
+ // Hand back the task list so the client can adopt the new task without a round trip.
568
+ // Scoped exactly like GET /api/tasks — a task title names the file it is working on,
569
+ // so being allowed to retry one issue must not hand back the drive-wide list.
570
+ return {
571
+ ok: true,
572
+ // Awaited, like every other call through `ctx.tasks`. Where the registry is a
573
+ // Durable Object this returns a promise, and an unawaited one serialises to `{}`
574
+ // — a client that adopted "the new task list" would replace it with nothing.
575
+ tasks: await ctx.tasks.list({
576
+ collectionIds: await readableCollectionIds(ctx),
577
+ includeGlobal: await canWholeDrive(ctx),
578
+ }),
579
+ };
580
+ });
581
+
582
+ // Dismissing is not fixing. Allowed because a problem can become irrelevant (the file
583
+ // was deleted, the plugin uninstalled) and a list you can't clear stops being read —
584
+ // but if the underlying failure recurs, it comes straight back.
585
+ r.delete('/api/issues/:id', ['collections', 'issues'], async (ctx) => {
586
+ const issue = await ctx.issues.get(ctx.params.id);
587
+ if (!issue) return { ok: true };
588
+ await assertIssueAccess(ctx, issue, 'write');
589
+ await ctx.issues.remove(ctx.params.id);
590
+ return { ok: true };
591
+ });
592
+
593
+ // Rebuild the search index on demand. Admin-only: it re-reads every object in the
594
+ // drive, so it is a real load, and it is drive-wide rather than scoped to anything
595
+ // the caller owns. Returns the task, which is how the caller watches it.
596
+ r.post('/api/reindex', ['backgroundWork', 'collections', 'tasks'], async (ctx) => {
597
+ await requireWholeDrive(ctx, 'rebuild the search index');
598
+ if (!ctx.backgroundWork) throw TroveError.unsupported('Reindexing is not available on this deployment');
599
+ // Two concurrent full rebuilds would double the work to reach the same place, so
600
+ // `beginReindex` claims the drive first and says whether it got it. The claim is
601
+ // shared state rather than this process's task list — the other rebuild may be in
602
+ // another isolate, and a check that can only see local memory would not find it.
603
+ const { task, alreadyRunning } = await ctx.backgroundWork.beginReindex({ reason: 'Started manually' });
604
+ if (alreadyRunning) {
605
+ const local = (await ctx.tasks.list()).find((t) => t.kind === 'index' && t.status === 'running');
606
+ return { task: local || null, alreadyRunning: true };
607
+ }
608
+ return { task };
609
+ });
610
+
611
+ // --- trash -----------------------------------------------------------------
612
+ // Deleting moves an item here rather than destroying it. Everything below needs
613
+ // `delete` on the collection, the same capability the delete itself needed — seeing
614
+ // what you deleted, and undoing it, are not lesser rights than deleting.
615
+
616
+ r.get('/api/trash', [], async (ctx) => {
617
+ const collectionId = collectionOf(ctx.query);
618
+ const collection = await ctx.access.collection(collectionId, 'delete');
619
+ return { items: await collection.listTrash({ limit: clampLimit(ctx.query.limit, 200) }), collectionId };
620
+ });
621
+
622
+ r.post('/api/trash/restore', [], async (ctx) => {
623
+ const b = await body(ctx.req);
624
+ if (!b.id) throw TroveError.invalid('id is required');
625
+ // `trashed` — the item is out of the drive, which is the only reason to restore it.
626
+ const node = await ctx.access.node(b.id, 'delete', { trashed: true });
627
+ return { node: await node.restore() };
628
+ });
629
+
630
+ // Destroy for real. Separate from DELETE /api/items so that emptying the trash can
631
+ // never be something you reach by accident from the ordinary delete path.
632
+ r.post('/api/trash/purge', [], async (ctx) => {
633
+ const b = await body(ctx.req);
634
+ if (b.id) {
635
+ const node = await ctx.access.node(b.id, 'delete', { trashed: true });
636
+ await node.remove({ permanent: true });
637
+ return { purged: 1 };
638
+ }
639
+ const collection = await ctx.access.collection(collectionOf(b), 'delete');
640
+ return collection.purgeTrash({ limit: MAX_PAGE });
641
+ });
642
+
643
+ // Reconcile a collection against the bytes actually in its store — how files added,
644
+ // replaced, or removed by anything other than Trove get noticed. Needs `write` on the
645
+ // collection, because a scan can create items in it.
646
+ r.post('/api/collections/:id/scan', ['backgroundWork', 'tasks'], async (ctx) => {
647
+ await ctx.access.collection(ctx.params.id, 'write');
648
+ if (!ctx.backgroundWork) throw TroveError.unsupported('Scanning is not available on this deployment');
649
+ const { task, alreadyRunning } = await ctx.backgroundWork.beginScan(ctx.params.id, { reason: 'Started manually' });
650
+ if (alreadyRunning) {
651
+ const local = (await ctx.tasks.list())
652
+ .find((t) => t.kind === 'scan' && t.collectionId === ctx.params.id && t.status === 'running');
653
+ return { task: local || null, alreadyRunning: true };
654
+ }
655
+ return { task };
656
+ });
657
+
658
+ // --- identity --------------------------------------------------------------
659
+
660
+ r.get('/api/me', ['collections'], (ctx) => ({
661
+ principal: ctx.principal || null,
662
+ // Authenticated means SOMEONE signed in — not merely that a principal object
663
+ // exists. The shared anonymous user is a stand-in for "no identity configured", and
664
+ // reporting it as authenticated would have the client show a profile for nobody.
665
+ authenticated: !!ctx.principal && !ctx.principal.anonymous,
666
+ // From config like every other answer about the ACL layer. This one only decides
667
+ // which UI the client offers — the routes enforce regardless — but a `collections`
668
+ // that went missing would tell every visitor they were an administrator, which is
669
+ // a worse lie than an error.
670
+ admin: collectionsEnabled(ctx) ? ctx.collections.isAdmin(ctx.principal) : !!ctx.principal,
671
+ }));
672
+
673
+ // --- conversations, tags, sidecar (per file) -------------------------------
674
+ // The :id is a file node id; the sidecar is that file's CRDT document.
675
+
676
+ r.get('/api/items/:id/sidecar', [], async (ctx) => {
677
+ // The handle is the sidecar for this node: obtaining it resolved the node
678
+ // (404 if gone) and asserted `read` on its collection.
679
+ return (await ctx.access.node(ctx.params.id, 'read')).view();
680
+ });
681
+
682
+ r.post('/api/items/:id/comments', [], async (ctx) => {
683
+ requirePrincipal(ctx.principal);
684
+ const node = await ctx.access.node(ctx.params.id, 'write');
685
+ const b = await body(ctx.req);
686
+ return { comment: await node.comment({ body: b.body, parentId: b.parentId, mentions: b.mentions }, ctx.principal) };
687
+ });
688
+
689
+ r.post('/api/items/:id/comments/:cid/edit', [], async (ctx) => {
690
+ requirePrincipal(ctx.principal);
691
+ const node = await ctx.access.node(ctx.params.id, 'write'); // + authorship checked in the service
692
+ const b = await body(ctx.req);
693
+ return { comment: await node.editComment(ctx.params.cid, b.body, ctx.principal) };
694
+ });
695
+
696
+ r.delete('/api/items/:id/comments/:cid', [], async (ctx) => {
697
+ requirePrincipal(ctx.principal);
698
+ const node = await ctx.access.node(ctx.params.id, 'write'); // + authorship checked in the service
699
+ return node.deleteComment(ctx.params.cid, ctx.principal);
700
+ });
701
+
702
+ r.post('/api/items/:id/comments/:cid/react', [], async (ctx) => {
703
+ requirePrincipal(ctx.principal);
704
+ const node = await ctx.access.node(ctx.params.id, 'write');
705
+ const b = await body(ctx.req);
706
+ if (!b.emoji) throw TroveError.invalid('emoji is required');
707
+ return { comment: await node.react(ctx.params.cid, b.emoji, b.on !== false, ctx.principal) };
708
+ });
709
+
710
+ r.post('/api/items/:id/tags', [], async (ctx) => {
711
+ const node = await ctx.access.node(ctx.params.id, 'write');
712
+ const b = await body(ctx.req);
713
+ if (!b.name) throw TroveError.invalid('name is required');
714
+ // The façade sets the CRDT tag AND its queryable mirror together (no swallow).
715
+ return node.setTag(b.name, b.value, ctx.principal);
716
+ });
717
+
718
+ r.delete('/api/items/:id/tags/:name', [], async (ctx) => {
719
+ // Removing a tag is a write — a read handle has no removeTag, so a read-only
720
+ // user cannot strip tags off files they cannot modify.
721
+ const node = await ctx.access.node(ctx.params.id, 'write');
722
+ return node.removeTag(ctx.params.name, ctx.principal);
723
+ });
724
+
725
+ r.post('/api/items/:id/subscribe', [], async (ctx) => {
726
+ requirePrincipal(ctx.principal);
727
+ const node = await ctx.access.node(ctx.params.id, 'read');
728
+ const b = await body(ctx.req);
729
+ return node.subscribe(ctx.principal, !!b.muted);
730
+ });
731
+ r.delete('/api/items/:id/subscribe', [], async (ctx) => {
732
+ requirePrincipal(ctx.principal);
733
+ const node = await ctx.access.node(ctx.params.id, 'read');
734
+ return node.unsubscribe(ctx.principal);
735
+ });
736
+
737
+ // --- notifications & web push ----------------------------------------------
738
+
739
+ r.get('/api/notifications', ['notifications'], async ({ notifications, principal }) => {
740
+ requireNotifications(notifications);
741
+ requirePrincipal(principal);
742
+ return notifications.inbox(principal.id);
743
+ });
744
+ r.post('/api/notifications/read', ['notifications'], async ({ notifications, principal, req }) => {
745
+ requireNotifications(notifications);
746
+ requirePrincipal(principal);
747
+ const b = await body(req);
748
+ return notifications.markRead(principal.id, b.ids);
749
+ });
750
+
751
+ r.get('/api/push/vapid', ['notifications'], ({ notifications }) => ({ publicKey: notifications?.vapidPublicKey() || null }));
752
+
753
+ r.post('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
754
+ requireNotifications(notifications);
755
+ requirePrincipal(principal);
756
+ const b = await body(req);
757
+ return notifications.subscribePush(principal.id, b.subscription);
758
+ });
759
+ r.delete('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
760
+ requireNotifications(notifications);
761
+ requirePrincipal(principal);
762
+ const b = await body(req);
763
+ return notifications.unsubscribePush(principal.id, b.endpoint);
764
+ });
765
+
766
+ // --- plugins: domain verification proxy + per-plugin server storage --------
767
+
768
+ // Fetch a plugin domain's assetlinks doc server-side (avoids browser CORS).
769
+ r.get('/api/plugins/assetlinks', [], async ({ query, principal }) => {
770
+ requirePrincipal(principal); // don't expose an open fetch proxy to the world
771
+ const domain = String(query.domain || '');
772
+ if (!/^[a-z0-9.-]+$/i.test(domain) || !domain.includes('.')) throw TroveError.invalid('Invalid domain');
773
+ assertPublicHost(domain); // block loopback / private / metadata targets
774
+ const url = `https://${domain}/.well-known/trove-assetlinks.json`;
775
+ try {
776
+ // No redirects: a public host must not bounce us onto an internal target.
777
+ const res = await fetch(url, { redirect: 'error' });
778
+ if (!res.ok) return { assetlinks: null };
779
+ const body = await readCapped(res, 256 * 1024); // small, well-known doc
780
+ return { assetlinks: JSON.parse(body) };
781
+ } catch {
782
+ return { assetlinks: null };
783
+ }
784
+ });
785
+
786
+ // --- server-installed plugins: package store + install records --------------
787
+ // Account-scoped plugins upload their full package to the server (blob → pluggable
788
+ // PackageStore, record → SQLite) so they sync across the user's devices, the server
789
+ // can enforce their capabilities, and removal cleans up. Device-only plugins never
790
+ // touch these routes.
791
+
792
+ // Install: upload the raw package zip; grants via ?grants=files,storage. The server
793
+ // re-parses + validates and gates on scope (admin for server indexers / shared
794
+ // resources), then stores the blob (deduped by digest) + the install record.
795
+ r.post('/api/plugins/install', ['plugins'], async ({ plugins, principal, req, query }) => {
796
+ requirePlugins(plugins);
797
+ requirePrincipal(principal);
798
+ const bytes = await readBytesCapped(req, plugins.maxPackageBytes || 32 * 1024 * 1024);
799
+ const grants = query.grants ? String(query.grants).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
800
+ return { install: await plugins.install({ principal, bytes, grants }) };
801
+ });
802
+
803
+ // List this account's server-installed plugins (for cross-device sync).
804
+ r.get('/api/plugins/installed', ['plugins'], async ({ plugins, principal }) => {
805
+ requirePlugins(plugins);
806
+ requirePrincipal(principal);
807
+ return { plugins: await plugins.list(principal) };
808
+ });
809
+
810
+ // Download a plugin's package blob so another device can enable it locally.
811
+ r.get('/api/plugins/:pluginId/package', ['plugins'], async ({ plugins, principal, params }) => {
812
+ requirePlugins(plugins);
813
+ requirePrincipal(principal);
814
+ const { stream, size } = await plugins.getPackage(principal, params.pluginId);
815
+ return new Response(stream, { status: 200, headers: {
816
+ 'content-type': 'application/zip', 'content-length': String(size),
817
+ 'content-disposition': `attachment; filename="${encodeURIComponent(params.pluginId)}.zip"`,
818
+ 'x-content-type-options': 'nosniff',
819
+ } });
820
+ });
821
+
822
+ // Account uninstall: drop the record + blob, then wipe the plugin-private store.
823
+ r.delete('/api/plugins/:pluginId/install', ['plugins', 'sqlite'], async ({ plugins, sqlite, principal, params }) => {
824
+ requirePlugins(plugins);
825
+ requirePrincipal(principal);
826
+ const res = await plugins.remove(principal, params.pluginId);
827
+ if (sqlite) await sqlite.drop({ key: `pstore:${principal.id}:plg:${params.pluginId}` }).catch(() => {});
828
+ return res;
829
+ });
830
+
831
+ // Server-backed plugin storage: an isolated SQLite database per scope, keyed by
832
+ // (user, plugin) for the private scope or (user, verified domain) for the shared
833
+ // scope, so ownership is tracked and it can be wiped on uninstall. The sandboxed
834
+ // plugin reaches this only through the host (which sets `scope`/`domain` from the
835
+ // install record); we scope by the authenticated principal for cross-user
836
+ // isolation, and only expose a fixed set of SQL ops against that one scoped db.
837
+ const PLUGIN_SQL_OPS = new Set(['exec', 'run', 'get', 'all', 'batch']);
838
+ const storeKey = (principal, pluginId, scope, domain) =>
839
+ scope === 'domain'
840
+ ? `pstore:${principal.id}:dom:${domain}`
841
+ : `pstore:${principal.id}:plg:${pluginId}`;
842
+
843
+ /**
844
+ * A plugin may only open the DOMAIN store of its own domain.
845
+ *
846
+ * A plugin id is `<domain>/<name>`, so the domain it is entitled to is not something
847
+ * the caller needs to tell us — and letting them tell us meant naming any domain and
848
+ * reading another vendor's shared store outright.
849
+ */
850
+ const assertOwnDomain = (pluginId, domain) => {
851
+ const own = String(pluginId || '').split('/')[0];
852
+ if (!own || own !== domain) {
853
+ throw TroveError.forbidden(`"${pluginId}" may only use the domain store for "${own || '(none)'}"`);
854
+ }
855
+ };
856
+
857
+ r.post('/api/plugins/:pluginId/sql', ['plugins', 'sqlite'], async ({ sqlite, plugins, principal, params, req }) => {
858
+ requirePluginStore(sqlite, principal);
859
+ // Authoritative capability check when the plugin is server-installed (transitional:
860
+ // allowed if there's no install record — device plugins predate this).
861
+ await plugins.assertCapability(principal, params.pluginId, 'storage');
862
+ const { scope = 'plugin', op, sql, params: args = [], statements, domain } = await body(req);
863
+ if (!PLUGIN_SQL_OPS.has(op)) throw TroveError.invalid(`Unknown storage op "${op}"`);
864
+ if (scope !== 'plugin' && scope !== 'domain') throw TroveError.invalid(`Unknown storage scope "${scope}"`);
865
+ if (scope === 'domain' && !domain) throw TroveError.invalid('domain scope requires a domain');
866
+ // The domain store is SHARED across a vendor's plugins, so opening it is a claim to
867
+ // be one of them — and `pluginId` comes from the caller. `assertOwnDomain` ties the
868
+ // two together but both are the caller's words; the install record is what makes
869
+ // either mean anything. (The plugin-private scope needs no such check: its key is
870
+ // already scoped to this principal, so the worst it reaches is their own data.)
871
+ if (scope === 'domain') {
872
+ assertOwnDomain(params.pluginId, domain);
873
+ // Installed AND approved for the shared scope — the two are different questions,
874
+ // and only the second is the one an administrator was asked about.
875
+ await plugins.assertSharedStorage(principal, params.pluginId);
876
+ }
877
+ const db = await sqlite.obtain({ key: storeKey(principal, params.pluginId, scope, domain) });
878
+ return { result: await runPluginSql(db, op, { sql, args, statements }) };
879
+ });
880
+
881
+ // Uninstall cleanup: wipe the plugin-private scope. The domain scope is shared
882
+ // across a vendor's plugins and deliberately outlives any single uninstall.
883
+ r.delete('/api/plugins/:pluginId/data', ['plugins', 'sqlite'], async ({ sqlite, plugins, principal, params }) => {
884
+ requirePluginStore(sqlite, principal);
885
+ // Same gate as the /sql route. Without it this was destroy-any-plugin's-data for
886
+ // anyone who could reach the app origin — the one route in the pair that checked
887
+ // nothing at all.
888
+ await plugins.assertCapability(principal, params.pluginId, 'storage');
889
+ await sqlite.drop({ key: storeKey(principal, params.pluginId, 'plugin') });
890
+ return { ok: true };
891
+ });
892
+
893
+ return r;
894
+ }
895
+
896
+ async function runPluginSql(db, op, { sql, args, statements }) {
897
+ const params = Array.isArray(args) ? args : [];
898
+ switch (op) {
899
+ case 'exec': assertSafePluginSql(sql); await db.exec(sql); return { ok: true };
900
+ case 'run': assertSafePluginSql(sql); return db.run(sql, ...params);
901
+ case 'get': assertSafePluginSql(sql); return db.get(sql, ...params);
902
+ case 'all': assertSafePluginSql(sql); return db.all(sql, ...params);
903
+ case 'batch': {
904
+ const stmts = (Array.isArray(statements) ? statements : []).map((s) => {
905
+ assertSafePluginSql(s?.sql);
906
+ return { sql: s.sql, params: Array.isArray(s.params) ? s.params : [] };
907
+ });
908
+ await db.batch(stmts);
909
+ return { ok: true };
910
+ }
911
+ default: throw TroveError.invalid(`Unknown storage op "${op}"`);
912
+ }
913
+ }
914
+
915
+ function requirePluginStore(sqlite, principal) {
916
+ if (!sqlite) throw TroveError.unsupported('Server plugin storage is not enabled');
917
+ if (!principal) throw TroveError.unauthorized('Authentication required');
918
+ }
919
+ function requirePlugins(plugins) {
920
+ if (!plugins) throw TroveError.unsupported('Server plugin installs are not enabled');
921
+ }
922
+
923
+ // Turn the core upload plan into a fully self-describing descriptor: how to send
924
+ // the bytes (presigned straight to storage, or proxied through us), the limits/quota,
925
+ // the auth headers a proxied transfer needs, and every lifecycle endpoint (status,
926
+ // (re)sign, report, complete "finished" hook, abort). `{partNumber}` is a template.
927
+ function uploadDescriptor(plan) {
928
+ const base = `/api/uploads/${encodeURIComponent(plan.uploadId)}`;
929
+ const transfer = plan.presigned
930
+ ? {
931
+ mode: 'presigned', // client uploads directly to storage; we never see the bytes
932
+ // `single` returns one `url`; multipart `presign` returns `parts[{partNumber,url}]`.
933
+ ...(plan.url ? { url: plan.url } : {}),
934
+ ...(plan.parts ? { parts: plan.parts } : {}),
935
+ requiredHeaders: plan.strategy === 'single' && plan.contentType ? { 'content-type': plan.contentType } : {},
936
+ }
937
+ : {
938
+ mode: 'proxied', // client PUTs each part to us; we stream it to storage
939
+ partUrl: `${base}/parts/{partNumber}`,
940
+ // Proxied PUTs hit our own origin, so they carry the session's ambient auth
941
+ // (cookie/proxy header) automatically — no extra headers needed by default.
942
+ authHeaders: {},
943
+ };
944
+ const endpoints = {
945
+ status: `${base}/status`,
946
+ complete: `${base}/complete`, // the "upload finished" hook — POST reported parts here
947
+ abort: base, // DELETE
948
+ sign: plan.strategy === 'presign' ? `${base}/parts/{partNumber}/sign` : null,
949
+ report: plan.strategy === 'presign' ? `${base}/parts/{partNumber}/report` : null,
950
+ };
951
+ // Drop the now-internal raw transfer fields in favour of `transfer`.
952
+ const { presigned, url, parts, ...rest } = plan;
953
+ return { ...rest, transfer, endpoints };
954
+ }
955
+
956
+ /**
957
+ * A contributor namespace may only be written by whoever owns it.
958
+ *
959
+ * Only a plugin can push through the API, and only under its own contribution URI —
960
+ * which is unforgeable, since it is scoped to the plugin's verified domain and name.
961
+ * That rules out the two things a bare string would allow: writing a built-in's
962
+ * namespace (`core.*`, whose contributions the server produces and nobody else may
963
+ * touch), and writing another plugin's.
964
+ */
965
+ async function assertContributorOwned(ctx, contributorId) {
966
+ const parsed = parseContribUri(contributorId);
967
+ if (!parsed || parsed.domain === CORE_DOMAIN) {
968
+ throw TroveError.forbidden(`"${contributorId}" is not a namespace you can contribute to`);
969
+ }
970
+ // No presence check. `plugins` is always built — there is no configuration that
971
+ // switches it off — so `if (!ctx.plugins) return` guarded a condition that could
972
+ // not occur, and the only thing it ever did was fail OPEN when the wiring was
973
+ // wrong: this check silently became a no-op and any authenticated caller could
974
+ // contribute under any vendor's name. Enforcement decides from configuration,
975
+ // never from whether an object happens to be here; a missing service throws.
976
+ // The namespace is only unforgeable if we check that the plugin is actually installed.
977
+ // `assertCapability` alone allows when there is no install record (transitional, for
978
+ // device-installed plugins), which made "unforgeable" false in the shipped default:
979
+ // any authenticated caller could contribute under any vendor's name.
980
+ await ctx.plugins.assertInstalled(ctx.principal, parsed.pluginId);
981
+ await ctx.plugins.assertCapability(ctx.principal, parsed.pluginId, 'indexer');
982
+ }
983
+
984
+ /**
985
+ * The collections this caller may read, optionally narrowed to one they asked for.
986
+ * `undefined` when collections are disabled, which means "don't scope" downstream.
987
+ *
988
+ * Every drive-wide query needs this, and it has to be applied INSIDE the query rather
989
+ * than by filtering results: a LIMIT spent on rows the caller can't see would report
990
+ * "no matches" while matches they can see sit just past the cut.
991
+ */
992
+ async function readableCollectionIds(ctx, narrowTo) {
993
+ if (!collectionsEnabled(ctx)) return undefined;
994
+ const readable = (await ctx.collections.list(ctx.principal)).map((c) => c.id);
995
+ return narrowTo ? readable.filter((id) => id === narrowTo) : readable;
996
+ }
997
+
998
+ /**
999
+ * Whether this deployment has an ACL layer at all.
1000
+ *
1001
+ * Read from configuration, not from whether `ctx.collections` is truthy. The two
1002
+ * agree when everything is wired correctly, and diverge exactly when it is not —
1003
+ * and a security check that stands down because a service is missing is one that
1004
+ * stops enforcing at the worst possible moment. Configuration says whether to
1005
+ * enforce; the service does the enforcing, and if it is absent this throws.
1006
+ */
1007
+ const collectionsEnabled = (ctx) => ctx.config?.collections !== false;
1008
+
1009
+ /**
1010
+ * Managing collections is only meaningful where there is an ACL layer to manage.
1011
+ *
1012
+ * From config, not from `ctx.collections` being null — the two agree today only
1013
+ * because the provider derives one from the other, and a build failure would make
1014
+ * "Collections are not enabled" a lie about a drive that has them.
1015
+ */
1016
+ function requireCollections(ctx) {
1017
+ if (!collectionsEnabled(ctx)) throw TroveError.unsupported('Collections are not enabled');
1018
+ }
1019
+
1020
+ async function assertCap(ctx, collectionId, capability) {
1021
+ if (!collectionsEnabled(ctx)) return; // no ACL layer configured
1022
+ await ctx.collections.assert(ctx.principal, collectionId, capability);
1023
+ }
1024
+
1025
+ /**
1026
+ * Gate an operation that acts on the whole drive rather than on anything the caller
1027
+ * owns — rebuilding the index, cancelling someone else's task. See
1028
+ * CollectionService.hasWholeDrive for why this isn't plain `isAdmin`.
1029
+ */
1030
+ async function requireWholeDrive(ctx, what) {
1031
+ const allowed = collectionsEnabled(ctx)
1032
+ ? await ctx.collections.hasWholeDrive(ctx.principal)
1033
+ : !!ctx.principal;
1034
+ if (!allowed) throw TroveError.forbidden(`You do not have permission to ${what}`);
1035
+ }
1036
+ const canWholeDrive = (ctx) =>
1037
+ (collectionsEnabled(ctx)
1038
+ ? ctx.collections.hasWholeDrive(ctx.principal)
1039
+ : Promise.resolve(!!ctx.principal));
1040
+
1041
+ /**
1042
+ * Who may act on an issue: whoever may act on the thing it is about.
1043
+ *
1044
+ * An issue names a file ("welcome.md could not be indexed"), so it leaks that file's
1045
+ * existence and name — it has to be scoped exactly like the file is. A drive-wide issue
1046
+ * belongs to no collection, so it takes admin. This is the same reasoning as
1047
+ * readableCollectionIds, applied to a different surface, and it must not be skipped
1048
+ * just because an issue "is only an error message".
1049
+ */
1050
+ async function assertIssueAccess(ctx, issue, capability) {
1051
+ if (issue.collectionId == null) return requireWholeDrive(ctx, 'act on a drive-wide problem');
1052
+ await assertCap(ctx, issue.collectionId, capability);
1053
+ }
1054
+
1055
+ /** Same rule for tasks: a task about a collection follows that collection. */
1056
+ async function assertTaskAccess(ctx, task, what) {
1057
+ if (!task) throw TroveError.notFound('Task');
1058
+ if (task.collectionId == null) return requireWholeDrive(ctx, `${what} a drive-wide task`);
1059
+ await assertCap(ctx, task.collectionId, 'write');
1060
+ }
1061
+ function requirePrincipal(principal) {
1062
+ if (!principal) throw TroveError.unauthorized('Authentication required');
1063
+ }
1064
+ function requireNotifications(n) {
1065
+ if (!n) throw TroveError.unsupported('Notifications are not enabled on this server');
1066
+ }