@3sln/trove 0.0.11 → 0.0.13

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 (90) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/access/externalEvaluation.js +17 -0
  27. package/packages/server/src/adapters/bun.js +1 -2
  28. package/packages/server/src/adapters/node.js +1 -2
  29. package/packages/server/src/engine/index.js +1 -1
  30. package/packages/server/src/engine/providers/access.js +17 -34
  31. package/packages/server/src/engine/providers/core.js +112 -14
  32. package/packages/server/src/index.js +160 -105
  33. package/packages/server/src/mcp/index.js +7 -12
  34. package/packages/server/src/mcp/tools.js +24 -29
  35. package/packages/server/src/router.js +31 -8
  36. package/packages/server/src/routes.js +74 -113
  37. package/packages/server/src/scope.js +78 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  39. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  40. package/packages/web/dist/index.html +1 -1
  41. package/packages/web/dist/sw.js +1 -1
  42. package/packages/web/src/bl/actions.js +226 -104
  43. package/packages/web/src/bl/activity.js +32 -82
  44. package/packages/web/src/bl/index.js +23 -11
  45. package/packages/web/src/bl/launcher.js +2 -9
  46. package/packages/web/src/bl/links.js +31 -18
  47. package/packages/web/src/bl/offline.js +24 -10
  48. package/packages/web/src/bl/pluginInstall.js +29 -26
  49. package/packages/web/src/bl/queries.js +76 -68
  50. package/packages/web/src/bl/services.js +22 -14
  51. package/packages/web/src/bl/social.js +39 -25
  52. package/packages/web/src/bl/state.js +22 -4
  53. package/packages/web/src/bl/viewState.js +9 -0
  54. package/packages/web/src/bl/watchQuery.js +1 -8
  55. package/packages/web/src/dispatch.js +26 -0
  56. package/packages/web/src/platform/api.js +32 -0
  57. package/packages/web/src/platform/commands.js +14 -15
  58. package/packages/web/src/platform/context.js +3 -58
  59. package/packages/web/src/platform/contributions.js +1 -35
  60. package/packages/web/src/platform/fileChunks.js +281 -0
  61. package/packages/web/src/platform/index.js +8 -9
  62. package/packages/web/src/platform/mediaUrls.js +10 -0
  63. package/packages/web/src/platform/navigation.js +35 -21
  64. package/packages/web/src/platform/pickers.js +39 -19
  65. package/packages/web/src/platform/pluginHost.js +4 -1
  66. package/packages/web/src/platform/pluginRpc.js +47 -2
  67. package/packages/web/src/platform/spatialNav.js +1 -1
  68. package/packages/web/src/platform/viewport.js +14 -6
  69. package/packages/web/src/platform/voiceSearch.js +17 -9
  70. package/packages/web/src/platform/whenclause.js +9 -19
  71. package/packages/web/src/runtime.js +2 -37
  72. package/packages/web/src/ui/activate.js +18 -2
  73. package/packages/web/src/ui/components/activityPanel.js +1 -1
  74. package/packages/web/src/ui/components/adminView.js +4 -7
  75. package/packages/web/src/ui/components/editorArea.js +10 -1
  76. package/packages/web/src/ui/components/launcher.js +29 -13
  77. package/packages/web/src/ui/components/openers/index.js +17 -22
  78. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  79. package/packages/web/src/ui/components/overlays.js +31 -5
  80. package/packages/web/src/ui/components/pluginReview.js +7 -4
  81. package/packages/web/src/ui/components/pluginsView.js +1 -1
  82. package/packages/web/src/ui/components/settingsView.js +30 -27
  83. package/packages/web/src/ui/components/statusBar.js +8 -6
  84. package/packages/web/src/ui/components/views/grid.js +2 -2
  85. package/packages/web/src/ui/components/views/parts.js +1 -1
  86. package/packages/web/src/ui/compositions/workbench.js +20 -6
  87. package/packages/web/src/ui/sanitize.js +0 -5
  88. package/packages/web/src/workbench.js +37 -19
  89. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  90. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -17,6 +17,19 @@
17
17
  // plugin, so its status item, its register and its command can't shadow each other,
18
18
  // and `trove+contrib:acme.com/docs/status` names exactly one thing in the world.
19
19
  //
20
+ //
21
+ // TWO SENSES OF "CONTRIBUTION", and this is one of them. Here it is an EXTENSION POINT a
22
+ // package declares in its manifest and the host addresses by URI —
23
+ // `trove+contrib:acme.com/docs/player`. The other sense lives in core/src/indexers/
24
+ // contribution.js: per-node ENRICHMENT produced at runtime and addressed by contributorId,
25
+ // which is what an `indexer` declared here goes on to emit. So a plugin declares a
26
+ // contribution (this sense) which produces contributions (that one).
27
+ //
28
+ // The rule for telling them apart: this sense is DECLARED, static, and named by URI; that
29
+ // sense is PRODUCED, per node, and named by contributor. Renaming the second was weighed
30
+ // and declined — it is a wire rename on `node.contributions` and `/api/index/:indexerId`,
31
+ // needing a dual-read window for a stored field, which is a lot of churn for a distinction
32
+ // two sentences can carry.
20
33
  // Each contribution's `entry` (openers, indexers) points into the plugin's ONE module
21
34
  // tree — they are not nested sub-packages, so everything in a plugin shares modules
22
35
  // and code. What gets opened or indexed depends only on which entry module runs.
@@ -0,0 +1,253 @@
1
+ // What one caller may cost.
2
+ //
3
+ // There was no limit anywhere: a key, a signed-in person, or on an open drive a stranger,
4
+ // could issue requests as fast as they could open sockets and every one was served. That is
5
+ // survivable while the only user is whoever deployed it, and stops being survivable the
6
+ // moment a key is handed to somebody else's script or the drive is put somewhere reachable.
7
+ //
8
+ // REQUESTS ARE NOT EQUAL, so neither are the limits. A limit that treats a range request
9
+ // and a semantic search as the same thing is either useless against the expensive one or
10
+ // absurd against the cheap one. Work is named by CLASS, and a class is a statement about
11
+ // what one call can cost someone:
12
+ //
13
+ // search a paid third-party call per query where TROVE_EMBEDDINGS_URL is set. The one
14
+ // where an attacker spends the operator's money rather than their CPU.
15
+ // upload bytes through the drive — and both directions for an encrypted collection,
16
+ // since the drive seals. `maxUploadBytes` caps one file and nothing capped the
17
+ // rate, so a thousand small uploads cost what the limit was meant to prevent.
18
+ // download bytes out. Generous, because a media player range-requests one file many
19
+ // times and that is one file's worth of bandwidth, not many.
20
+ // job scan, reindex, rotate. Each schedules real work over a whole collection.
21
+ // install unzip, verify, store.
22
+ // evaluate the deliberately unauthenticated access endpoint: a JWKS fetch or cache read
23
+ // and an RSA verify, reachable with no credential at all.
24
+ // write ordinary mutations. A backstop, not a real cost model.
25
+ //
26
+ // Reads are not limited. They are cheap, the shell issues many, and a limit low enough to
27
+ // matter would break normal use — which is the definition of theatre.
28
+ //
29
+ // WHERE THE COUNTERS LIVE is the hard part, and the honest answer differs by runtime. A
30
+ // per-process counter is exact on a long-lived Bun or Node instance and a lie on Workers,
31
+ // where each isolate has its own memory and "60 a minute" becomes 60 per isolate per
32
+ // minute. So there are two stores, the choice is configuration, and `describeRateLimits`
33
+ // exists so a deployment that cannot enforce them SAYS SO rather than appearing to.
34
+
35
+ import { TroveError } from './errors.js';
36
+
37
+ /**
38
+ * `count/window` for each class of work.
39
+ *
40
+ * Generous enough that someone using the drive normally never sees a 429, tight enough
41
+ * that a loop does. Windows are all a minute because a limit you have to reason about in
42
+ * two units is one nobody reasons about.
43
+ */
44
+ export const DEFAULT_RATE_LIMITS = {
45
+ search: { limit: 60, windowMs: 60_000 },
46
+ upload: { limit: 240, windowMs: 60_000 },
47
+ download: { limit: 1200, windowMs: 60_000 },
48
+ job: { limit: 10, windowMs: 60_000 },
49
+ install: { limit: 10, windowMs: 60_000 },
50
+ evaluate: { limit: 60, windowMs: 60_000 },
51
+ write: { limit: 600, windowMs: 60_000 },
52
+ };
53
+
54
+ export const RATE_CLASSES = Object.keys(DEFAULT_RATE_LIMITS);
55
+
56
+ /**
57
+ * Counters in this process's memory.
58
+ *
59
+ * EXACT on a runtime where one process serves every request, which is what a self-hosted
60
+ * Bun or Node drive is. Wrong on Workers — see the module header, and `describeRateLimits`,
61
+ * which is how a deployment finds out rather than assuming.
62
+ *
63
+ * Buckets expire, and expired ones are swept when the map grows rather than on a timer:
64
+ * there is no timer to hang it on inside a request, and a sweep proportional to how much
65
+ * traffic there was is the right shape anyway.
66
+ */
67
+ export class MemoryRateStore {
68
+ constructor({ maxEntries = 10_000 } = {}) {
69
+ this.buckets = new Map(); // key -> { count, expiresAt }
70
+ this.maxEntries = maxEntries;
71
+ }
72
+
73
+ async bump(key, windowMs, now) {
74
+ // A cheap backstop between sweeps, for a burst that outruns the maintenance tick.
75
+ if (this.buckets.size > this.maxEntries) await this.sweep(now);
76
+ const held = this.buckets.get(key);
77
+ if (held && held.expiresAt > now) {
78
+ held.count += 1;
79
+ return held.count;
80
+ }
81
+ this.buckets.set(key, { count: 1, expiresAt: now + windowMs });
82
+ return 1;
83
+ }
84
+
85
+ /**
86
+ * Drop buckets whose window has passed. The same method the KV store has, and the same
87
+ * caller — periodic maintenance.
88
+ *
89
+ * It exists on BOTH stores so the caller does not have to ask which one it got. A
90
+ * `store.sweep?.()` there would be the optional-call shape this codebase records as
91
+ * having turned a sweep into a permanent no-op once already; and without a periodic
92
+ * sweep a long-lived process holds an expired bucket for every subject it has ever seen
93
+ * until the size backstop fires, which on a quiet drive is never.
94
+ *
95
+ * @returns {Promise<number>} how many went
96
+ */
97
+ async sweep(now = Date.now()) {
98
+ let dropped = 0;
99
+ for (const [k, b] of this.buckets) if (b.expiresAt <= now) { this.buckets.delete(k); dropped++; }
100
+ return dropped;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Counters in the shared KeyValueStore, so every instance sees one budget.
106
+ *
107
+ * The cost is a read and a write per LIMITED request — which is why only the expensive
108
+ * classes are limited at all. And it is read-modify-write rather than an atomic increment,
109
+ * because the store has no atomic increment: requests that overlap exactly can each read
110
+ * the same count and each write count+1, so a burst can slip a few through. That is a
111
+ * limiter that is occasionally generous, which is a different thing from no limiter, and
112
+ * it is stated here rather than discovered.
113
+ *
114
+ * Expired buckets are removed by `sweep`, called from periodic maintenance. Without it the
115
+ * namespace grows one key per subject per class per window, forever.
116
+ */
117
+ export class KvRateStore {
118
+ static NS = 'ratelimit';
119
+
120
+ constructor({ kv }) {
121
+ if (!kv) throw TroveError.invalid('KvRateStore needs a KeyValueStore');
122
+ this.kv = kv;
123
+ }
124
+
125
+ async bump(key, windowMs, now) {
126
+ const held = await this.kv.get(KvRateStore.NS, key).catch(() => null);
127
+ const next = held && held.expiresAt > now
128
+ ? { count: held.count + 1, expiresAt: held.expiresAt }
129
+ : { count: 1, expiresAt: now + windowMs };
130
+ await this.kv.set(KvRateStore.NS, key, next).catch(() => {});
131
+ return next.count;
132
+ }
133
+
134
+ /** Drop buckets whose window has passed. @returns {Promise<number>} how many went */
135
+ async sweep(now = Date.now()) {
136
+ const rows = await this.kv.list(KvRateStore.NS).catch(() => []);
137
+ let dropped = 0;
138
+ for (const { key, value } of rows) {
139
+ if (!value || value.expiresAt <= now) {
140
+ await this.kv.delete(KvRateStore.NS, key).catch(() => {});
141
+ dropped++;
142
+ }
143
+ }
144
+ return dropped;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Decide whether this subject may do another unit of this class of work.
150
+ *
151
+ * FIXED WINDOWS, not a sliding log. A sliding window is fairer at the boundary and costs a
152
+ * list per request to be so; a fixed window's worst case is twice the limit across two
153
+ * adjacent windows, which for limits chosen to bound cost rather than to be precise is a
154
+ * trade worth taking. It also gives an exact `Retry-After`: the window's own end.
155
+ */
156
+ export class RateLimiter {
157
+ /**
158
+ * @param {object} deps
159
+ * @param {{bump: (key: string, windowMs: number, now: number) => Promise<number>}} deps.store
160
+ * @param {Record<string, {limit: number, windowMs: number}>} [deps.limits]
161
+ * @param {() => number} [deps.now] injected clock, for tests
162
+ */
163
+ constructor({ store, limits = DEFAULT_RATE_LIMITS, now = () => Date.now() } = {}) {
164
+ if (!store) throw TroveError.invalid('RateLimiter needs a store');
165
+ this.store = store;
166
+ this.limits = limits;
167
+ this.now = now;
168
+ }
169
+
170
+ /**
171
+ * @returns {Promise<{ok: boolean, limit?: number, remaining?: number, retryAfterMs?: number}>}
172
+ * `{ok: true}` for a class with no limit configured — an unnamed class is not an
173
+ * error, it is work nobody decided to meter.
174
+ */
175
+ async check(subject, className) {
176
+ const rule = this.limits[className];
177
+ if (!rule || !rule.limit) return { ok: true };
178
+ const now = this.now();
179
+ // The bucket is part of the key, so a new window is a new counter and there is nothing
180
+ // to reset — which is also what makes this safe across processes that never talk.
181
+ const bucket = Math.floor(now / rule.windowMs);
182
+ const endsAt = (bucket + 1) * rule.windowMs;
183
+ const count = await this.store.bump(`${className}:${subject}:${bucket}`, rule.windowMs, now);
184
+ if (count > rule.limit) {
185
+ return { ok: false, limit: rule.limit, remaining: 0, retryAfterMs: Math.max(0, endsAt - now) };
186
+ }
187
+ return { ok: true, limit: rule.limit, remaining: rule.limit - count };
188
+ }
189
+
190
+ /**
191
+ * The same, as a refusal.
192
+ *
193
+ * QUOTA and retryable, which errors.js already maps to 429 — "A rate limit is 429: back
194
+ * off and try again", as distinct from being out of disk, which is 507 because retrying
195
+ * changes nothing. `retryAfterMs` rides in `details` so the HTTP layer can turn it into
196
+ * a `Retry-After` header instead of every client guessing.
197
+ */
198
+ async enforce(subject, className) {
199
+ const verdict = await this.check(subject, className);
200
+ if (verdict.ok) return verdict;
201
+ throw TroveError.rateLimited(
202
+ `Too many ${className} requests — wait ${Math.ceil(verdict.retryAfterMs / 1000)}s and try again`,
203
+ { details: { limit: verdict.limit, retryAfterMs: verdict.retryAfterMs, kind: className } },
204
+ );
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Who is being limited.
210
+ *
211
+ * The two credentials are already resolved in one place, and a grant is resolved FIRST and
212
+ * an identity only if there is no grant — so this does not re-derive anything, it names
213
+ * what was decided.
214
+ *
215
+ * The third case is the one that needs a decision rather than a default. With no
216
+ * credential there is nothing stable to key on but the address, and an address behind a
217
+ * proxy is whatever the proxy says — trusting a header a client can set is worse than not
218
+ * limiting at all, because it hands every caller their own budget for the asking. So the
219
+ * forwarded address is used only where the operator has said the proxy is trustworthy
220
+ * (TROVE_TRUST_PROXY), and otherwise every anonymous caller shares ONE budget.
221
+ *
222
+ * Sharing one budget is a real limit — it bounds what the drive spends, which is the point
223
+ * — and it has a real cost: on a drive open to the internet, one stranger can exhaust the
224
+ * anonymous allowance for the rest. That is the trade, stated. An operator who does not
225
+ * want it configures a proxy and says so, or requires authentication.
226
+ */
227
+ export function rateSubject({ grant, principal, req, trustProxy = false } = {}) {
228
+ if (grant?.keyId) return `key:${grant.keyId}`;
229
+ if (principal?.id) return `user:${principal.id}`;
230
+ if (trustProxy && req) {
231
+ const forwarded = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim();
232
+ if (forwarded) return `ip:${forwarded}`;
233
+ }
234
+ return 'anon';
235
+ }
236
+
237
+ /**
238
+ * What this deployment will actually enforce, in its own words.
239
+ *
240
+ * Exists because the ticket's requirement was that a runtime which cannot enforce a limit
241
+ * says so rather than pretending. `scope: 'isolate'` is that admission: in-memory counters
242
+ * on a runtime with no long-lived process count per isolate, so the effective limit is the
243
+ * configured one times however many isolates the platform decided to run.
244
+ */
245
+ export function describeRateLimits({ enabled, store, limits, perProcess }) {
246
+ if (!enabled) return { enabled: false, scope: 'none', limits: {} };
247
+ return {
248
+ enabled: true,
249
+ store,
250
+ scope: store === 'kv' ? 'drive' : (perProcess ? 'process' : 'isolate'),
251
+ limits,
252
+ };
253
+ }
@@ -96,6 +96,9 @@ export class CollectionScanner {
96
96
  // outside: the scan adopts it AGAIN, resurrecting the deleted file under a new id
97
97
  // that shares the original's storage key. Emptying the trash then deletes the live
98
98
  // copy's bytes, leaving an item that lists, opens, and 404s forever.
99
+ // The one optional call left on a store method, and the interface says why: soft
100
+ // delete is documented as optional — a store that makes deletes permanent has no trash
101
+ // to enumerate. Every other declared method is called unguarded.
99
102
  const trashedKeys = await this.vfs.metadata.trashedStorageKeys?.(collectionId) ?? new Set();
100
103
 
101
104
  const result = {
@@ -215,28 +218,52 @@ export class CollectionScanner {
215
218
  * excluded: one of those with no metadata row is a leftover from a failed write, not
216
219
  * a file someone put there, and adopting it would surface `obj_9fc0…` as a document.
217
220
  */
221
+ /**
222
+ * What a record should say about an object, read from the OBJECT.
223
+ *
224
+ * Is this an encrypted object somebody copied in? The envelope says so, and says which
225
+ * key it wants, WITHOUT the key — which is the entire reason the header is readable.
226
+ * Without this the object is recorded as plaintext, and every read of it hands back raw
227
+ * ciphertext with no error: the drive shows a file, and opening it gives you an
228
+ * unreadable blob. Sideloading is a named use case here, so it has to be the case that
229
+ * works.
230
+ *
231
+ * ONE rule, and both callers ask it. They disagreed before: adoption read the envelope
232
+ * while refresh took `object.size`, so an encrypted item replaced in place — which is
233
+ * precisely the scenario this scanner exists for — recorded the size of the ENVELOPE
234
+ * (plaintext + 44 header + 16 tag per chunk) as the size of the file. Wrong from then on
235
+ * in listings, quotas and collectionStats, and it feeds rotation, where `node.size`
236
+ * bounds the read loop and is written into the new header.
237
+ *
238
+ * `size` is left undefined when the object reports none, so each caller can apply the
239
+ * fallback that makes sense for it. Costs one 44-byte read per object.
240
+ */
241
+ async #factsOf(collectionId, object) {
242
+ const envelope = await this.#envelopeOf(collectionId, object.key);
243
+ return {
244
+ // The size the file has, not the size the envelope occupies.
245
+ size: envelope ? envelope.plaintextSize : object.size,
246
+ etag: object.etag ?? null,
247
+ // Null when there is no envelope, deliberately: an encrypted object replaced by a
248
+ // plaintext one must stop claiming a key, or every read of it fails looking for a
249
+ // header that is not there.
250
+ encryption: envelope
251
+ ? { fingerprint: toHex(envelope.fingerprint), chunkSize: envelope.chunkSize }
252
+ : null,
253
+ };
254
+ }
255
+
218
256
  async #adopt(collectionId, object) {
219
257
  if (TROVE_KEY.test(object.key)) return null; // orphaned blob from an interrupted upload
220
258
  const name = await this.#uniqueName(collectionId, object.key);
221
- // Is this an encrypted object somebody copied in?
222
- //
223
- // The envelope says so, and says which key it wants, WITHOUT the key — which is the
224
- // entire reason the header is readable. Without this an adopted object is recorded as
225
- // plaintext, and every read of it hands back raw ciphertext with no error: the drive
226
- // shows a file, and opening it gives you an unreadable blob. Sideloading is a named
227
- // use case here, so it has to be the case that works.
228
- const envelope = await this.#envelopeOf(collectionId, object.key);
259
+ const facts = await this.#factsOf(collectionId, object);
229
260
  const node = await this.vfs.metadata.create({
230
261
  collectionId,
231
262
  name,
232
263
  storageKey: object.key,
233
- // The size the file has, not the size the envelope occupies.
234
- size: envelope ? envelope.plaintextSize : (object.size ?? 0),
235
- etag: object.etag ?? null,
264
+ ...facts,
265
+ size: facts.size ?? 0,
236
266
  contentType: this.vfs.guessContentType(name),
237
- encryption: envelope
238
- ? { fingerprint: toHex(envelope.fingerprint), chunkSize: envelope.chunkSize }
239
- : null,
240
267
  meta: { adopted: true, adoptedAt: Date.now() },
241
268
  });
242
269
  // Adopted files are indexed like any other, so they are findable immediately —
@@ -247,9 +274,10 @@ export class CollectionScanner {
247
274
 
248
275
  /** Re-read an item whose bytes were replaced in place. */
249
276
  async #refresh(node, object) {
277
+ const facts = await this.#factsOf(node.collectionId, object);
250
278
  const updated = await this.vfs.metadata.update(node.id, {
251
- size: object.size ?? node.size,
252
- etag: object.etag ?? null,
279
+ ...facts,
280
+ size: facts.size ?? node.size,
253
281
  });
254
282
  await this.vfs.indexing.indexNode(updated).catch(() => {});
255
283
  return updated;
@@ -1,13 +1,18 @@
1
1
  // The per-file sidecar document — a small CRDT that holds everything mutable and
2
2
  // social about a file WITHOUT touching the file bytes: its conversation
3
- // (threaded comments + reactions), its tags, its per-indexer facet data (scoped
4
- // to the indexer that produced it), and who's subscribed to the thread.
3
+ // (threaded comments + reactions), its tags, and who's subscribed to the thread.
4
+ //
5
+ // It does NOT hold indexer output. It advertised a per-indexer `facets` register for a
6
+ // long time with no writer and no reader anywhere in core, server, web or the plugin SDK,
7
+ // and there is no facet verb on the plugin RPC surface either — so the header was the
8
+ // documentation someone extending an indexer would find first, and following it would land
9
+ // data nothing queries. Indexer contributions live in the queryable metadata store, which
10
+ // is what makes them show up in list/stat and drive tag filtering (see indexing.js).
5
11
  //
6
12
  // It's designed to live as cold JSON in object storage and be merged whenever
7
13
  // it's read-before-write, so two servers (or a stale hot copy vs the cold one)
8
14
  // converge without a lock or a conflict. Every field is a CRDT register:
9
15
  // • tags — LWW-Element-Set (add/remove wins by Lamport stamp)
10
- // • facets — per-indexer LWW register
11
16
  // • comments — grow-only map; body edit & deletion are LWW registers; reactions
12
17
  // are an OR-map (per user, per emoji, LWW on/off)
13
18
  // • subscribers — LWW register (subscribed / muted)
@@ -17,7 +22,7 @@
17
22
  export const SIDECAR_VERSION = 1;
18
23
 
19
24
  export function emptyDoc(nodeId) {
20
- return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, facets: {}, comments: {}, subscribers: {} };
25
+ return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, comments: {}, subscribers: {} };
21
26
  }
22
27
 
23
28
  // A stamp orders and tie-breaks a write. Higher clock wins; equal clock → higher
@@ -96,12 +101,6 @@ export function removeTag(doc, name, { actor, at } = {}) {
96
101
  if (newer(s, cur)) doc.tags[name] = { present: false, value: cur?.value, ...s };
97
102
  }
98
103
 
99
- export function setFacet(doc, indexerId, data, { actor, at } = {}) {
100
- const s = stamp(doc, actor ?? indexerId, at);
101
- const cur = doc.facets[indexerId];
102
- if (newer(s, cur)) doc.facets[indexerId] = { data, ...s };
103
- }
104
-
105
104
  export function subscribe(doc, userId, { muted = false, actor, at } = {}) {
106
105
  if (!userId) return;
107
106
  const s = stamp(doc, actor ?? userId, at);
@@ -120,10 +119,16 @@ export function unsubscribe(doc, userId, { actor, at } = {}) {
120
119
  export function mergeDoc(a, b) {
121
120
  if (!a) return structuredCloneSafe(b);
122
121
  if (!b) return structuredCloneSafe(a);
123
- const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, facets: {}, comments: {}, subscribers: {} };
122
+ const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, comments: {}, subscribers: {} };
124
123
 
125
124
  for (const key of union(a.tags, b.tags)) out.tags[key] = pick(a.tags[key], b.tags[key]);
126
- for (const key of union(a.facets, b.facets)) out.facets[key] = pick(a.facets[key], b.facets[key]);
125
+ // Documents written before the register was removed still carry one, and a merge that
126
+ // dropped half of a stored document would not be a merge. Guarded on presence so it
127
+ // costs nothing on the documents every writer produces now.
128
+ if (a.facets || b.facets) {
129
+ out.facets = {};
130
+ for (const key of union(a.facets, b.facets)) out.facets[key] = pick(a.facets[key], b.facets[key]);
131
+ }
127
132
  for (const key of union(a.subscribers, b.subscribers)) out.subscribers[key] = pick(a.subscribers[key], b.subscribers[key]);
128
133
 
129
134
  for (const id of union(a.comments, b.comments)) {
@@ -183,9 +188,8 @@ export function viewDoc(doc) {
183
188
  if (c.parentId && byId.has(c.parentId)) byId.get(c.parentId).replies.push(c);
184
189
  else roots.push(c);
185
190
  }
186
- const facets = Object.fromEntries(Object.entries(doc.facets || {}).map(([k, v]) => [k, v.data]));
187
191
  const subscribers = Object.entries(doc.subscribers || {}).filter(([, s]) => s.subscribed).map(([id]) => id).sort();
188
- return { nodeId: doc.nodeId, tags, comments: roots, commentCount: all.filter((c) => !c.deleted).length, facets, subscribers };
192
+ return { nodeId: doc.nodeId, tags, comments: roots, commentCount: all.filter((c) => !c.deleted).length, subscribers };
189
193
  }
190
194
 
191
195
  function summariseReactions(reactions = {}) {
@@ -1,5 +1,5 @@
1
1
  // SidecarService — the API surface over sidecar documents: conversations
2
- // (threaded comments, edits, reactions), tags, per-indexer facets, and thread
2
+ // (threaded comments, edits, reactions), tags, and thread
3
3
  // subscriptions. It resolves the acting Principal into comment authorship,
4
4
  // extracts @mentions, auto-subscribes participants, and emits mention events to
5
5
  // a callback (wired to the notification batcher). Routes call these methods.
@@ -7,7 +7,7 @@
7
7
  import { SidecarStore } from './store.js';
8
8
  import { SidecarManager } from './manager.js';
9
9
  import {
10
- addComment, editComment, deleteComment, react, setTag, removeTag, setFacet,
10
+ addComment, editComment, deleteComment, react, setTag, removeTag,
11
11
  subscribe, unsubscribe, viewDoc, extractMentions,
12
12
  } from './document.js';
13
13
  import { newId } from '../util.js';
@@ -47,6 +47,13 @@ export class SidecarService {
47
47
  flushAll() {
48
48
  return this.manager.flushAll();
49
49
  }
50
+ /**
51
+ * Try again to save what could not be saved — what the `sidecar-flush` issue's Retry
52
+ * button runs. Clears the issue for each document that lands.
53
+ */
54
+ retryPending() {
55
+ return this.manager.retryPending();
56
+ }
50
57
 
51
58
  // --- conversation ----------------------------------------------------------
52
59
 
@@ -108,14 +115,6 @@ export class SidecarService {
108
115
  return this.view(nodeId);
109
116
  }
110
117
 
111
- // --- facets (indexer-scoped) ----------------------------------------------
112
-
113
- /** Write an indexer's facet into the sidecar, namespaced under indexerId. */
114
- async setFacet(nodeId, indexerId, data) {
115
- await this.manager.mutate(nodeId, (doc) => setFacet(doc, indexerId, data, { actor: indexerId }));
116
- return { ok: true };
117
- }
118
-
119
118
  // --- subscriptions ---------------------------------------------------------
120
119
 
121
120
  async subscribe(nodeId, principal, muted = false) {
@@ -27,10 +27,10 @@ export class SidecarManager {
27
27
  let e = this.hot.get(nodeId);
28
28
  if (e) {
29
29
  if (e.loading) await e.loading;
30
- e.lastAccess = now();
30
+ e.lastAccess = Date.now();
31
31
  return e;
32
32
  }
33
- e = { doc: null, dirty: false, timer: null, lastAccess: now() };
33
+ e = { doc: null, dirty: false, timer: null, lastAccess: Date.now() };
34
34
  this.hot.set(nodeId, e);
35
35
  e.loading = (async () => {
36
36
  e.doc = (await this.store.load(nodeId)) || this.store.emptyDoc(nodeId);
@@ -99,15 +99,18 @@ export class SidecarManager {
99
99
  // Out of retries. The comment is still in memory and still served, but it will
100
100
  // not survive a restart, and the person who wrote it has been told it saved.
101
101
  // That is a standing problem, which is exactly what the issue registry is for.
102
- this.issues?.raise?.({
102
+ this.issues?.raise({
103
103
  kind: 'sidecar-flush',
104
104
  subject: nodeId,
105
105
  severity: 'error',
106
106
  title: 'A comment or tag could not be saved',
107
107
  detail: `Changes to this item's conversation are held in memory only — ${err?.message || err}`,
108
- // The op the server registers via issues.handle('sidecar-flush', …), which
109
- // is what makes the Retry button appear and do the right thing.
110
- retry: 'sidecar-flush',
108
+ // `{ op, nodeId }`, not a bare string: `canRetry` reads `issue.retry.op`, so
109
+ // the string form answered false and the Retry button never rendered and if
110
+ // it had, `IssueRegistry.retry` would have thrown. Both halves were broken
111
+ // independently, on the one retry that matters most: the user has been told
112
+ // their comment saved and it exists only in memory.
113
+ retry: { op: 'sidecar-flush', nodeId },
111
114
  }).catch(() => {});
112
115
  }
113
116
  });
@@ -189,7 +192,7 @@ export class SidecarManager {
189
192
  * transient storage blip into silent data loss a minute later.
190
193
  */
191
194
  async sweep() {
192
- const cutoff = now() - this.idleEvictMs;
195
+ const cutoff = Date.now() - this.idleEvictMs;
193
196
  for (const [id, e] of this.hot) {
194
197
  if (e.lastAccess >= cutoff || e.timer) continue;
195
198
  if (e.dirty) {
@@ -229,11 +232,3 @@ export class SidecarManager {
229
232
  return result;
230
233
  }
231
234
  }
232
-
233
- function now() {
234
- try {
235
- return Date.now();
236
- } catch {
237
- return 0;
238
- }
239
- }
@@ -19,7 +19,6 @@ import {
19
19
  HEADER_BYTES as ENVELOPE_HEAD,
20
20
  } from './encryption/envelope.js';
21
21
  import { fromHex, toHex } from './encryption/keys.js';
22
- import { shouldEncrypt } from './encryption/policy.js';
23
22
 
24
23
  export const DEFAULT_PART_SIZE = 8 * 1024 * 1024; // 8 MiB
25
24
  const MIN_MULTIPART_PART = 5 * 1024 * 1024; // S3 floor (except final part)
@@ -147,14 +146,28 @@ export class UploadManager {
147
146
  * @param {object} [deps.sessions] session store (defaults in-memory)
148
147
  * @param {number} [deps.partSize]
149
148
  */
150
- constructor({ storage, storageFor, sessions, encryptionFor, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
149
+ constructor({ storage, storageFor, sessions, sealingFor, keyFor, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
151
150
  // Either a single backend, or a resolver keyed by collectionId (collections).
152
151
  this.storageFor = storageFor ?? (async () => storage);
153
152
  this.sessions = sessions ?? new MemorySessionStore();
154
- // What a collection encrypts, and the key to do it with:
155
- // `(collectionId) => { encryption, dataKey } | null`. Absent means nothing is
156
- // encrypted, which is what every existing deployment is.
157
- this.encryptionFor = encryptionFor ?? (async () => null);
153
+ // Whether this item gets sealed and with what:
154
+ // `(collectionId, name, contentType) => { key, fingerprint, chunkSize } | null`.
155
+ //
156
+ // INJECTED rather than decided here, because there was a second implementation of the
157
+ // same question and the two disagreed in the direction that matters. This one used to
158
+ // ask for a collection-wide policy and apply `shouldEncrypt` itself, and a miss on the
159
+ // key answered `null` — which made the upload session PLAINTEXT in a collection set up
160
+ // to encrypt. `Vfs.#sealingFor`, the other implementation, throws. `#assertSealed` is
161
+ // the guard built to stop exactly this and it is gated on `s.encrypted`, so it never
162
+ // ran on the fail-open path. There is one implementation now and it fails closed.
163
+ //
164
+ // Absent means nothing is encrypted, which is what a Vfs-less deployment is.
165
+ this.sealingFor = sealingFor ?? (async () => null);
166
+ // The key a FINGERPRINT names, for sealing the remaining parts of an upload that has
167
+ // already decided. Not `sealingFor` again: that answers with whatever key is current,
168
+ // and a rotation starting mid-upload makes the current key a different one — parts
169
+ // sealed under it while the header names the old fingerprint decrypt as corruption.
170
+ this.keyFor = keyFor ?? (async () => null);
158
171
  this.partSize = partSize;
159
172
  this.maxBytes = maxBytes || null; // per-file quota (null = unbounded)
160
173
  }
@@ -197,9 +210,9 @@ export class UploadManager {
197
210
  // plus an authentication tag per chunk. Planning multipart boundaries against the
198
211
  // plaintext size is short by exactly that, which is the difference between a final part
199
212
  // that exists and one that does not.
200
- const policy = await this.encryptionFor(collectionId);
201
- const encrypting = !!policy && shouldEncrypt(policy.encryption, { name: req.name, contentType });
202
- const chunkSize = policy?.encryption?.chunkSize || DEFAULT_CHUNK_SIZE;
213
+ const sealing = await this.sealingFor(collectionId, req.name, contentType);
214
+ const encrypting = !!sealing;
215
+ const chunkSize = sealing?.chunkSize || DEFAULT_CHUNK_SIZE;
203
216
  const storedSize = encrypting ? cipherSize(req.size, chunkSize) : req.size;
204
217
 
205
218
  // The per-file limit is checked against what will be STORED, and checked here rather
@@ -245,7 +258,7 @@ export class UploadManager {
245
258
  // may arrive in any order — see `#sealPart`. Generating it per part would reuse
246
259
  // nonces across the object, which AES-GCM does not survive.
247
260
  noncePrefix: encrypting ? toHex(crypto.getRandomValues(new Uint8Array(8))) : null,
248
- keyFingerprint: encrypting ? policy.encryption.fingerprint : null,
261
+ keyFingerprint: encrypting ? toHex(sealing.fingerprint) : null,
249
262
  contentType,
250
263
  createdAt: Date.now(),
251
264
  strategy: null,
@@ -279,7 +292,9 @@ export class UploadManager {
279
292
  const wireSize = encrypting ? req.size : storedSize;
280
293
  const partCount = Math.max(1, Math.ceil(wireSize / this.partSize));
281
294
  // A part must hold a whole number of chunks, or the chunk a part boundary lands
282
- // inside would be sealed twice under two different indices.
295
+ // inside would be sealed twice under two different indices. Reachable, unlike a
296
+ // per-collection chunk size: `partSize` really is injectable (`uploadPartSize`), and
297
+ // the chunk size is fixed, so this checks the half a deployment can get wrong.
283
298
  if (encrypting && this.partSize % chunkSize !== 0) {
284
299
  throw TroveError.internal(
285
300
  `Part size ${this.partSize} is not a multiple of the ${chunkSize}-byte chunk size`,
@@ -466,10 +481,16 @@ export class UploadManager {
466
481
  return prependBytes(header, sealed);
467
482
  }
468
483
 
469
- /** The collection's key, for sealing. Never leaves this process. */
484
+ /**
485
+ * The key this SESSION decided on, for sealing. Never leaves this process.
486
+ *
487
+ * By fingerprint, not "whatever the collection encrypts with now": the fingerprint was
488
+ * pinned at negotiation because the collection's rules and its current key can both
489
+ * change while an upload is in flight, and half an object sealed under each is
490
+ * unreadable in a way nothing reports.
491
+ */
470
492
  async #dataKeyFor(s) {
471
- const policy = await this.encryptionFor(s.collectionId);
472
- const key = policy?.dataKeyHex ? fromHex(policy.dataKeyHex) : null;
493
+ const key = await this.keyFor(s.collectionId, s.keyFingerprint);
473
494
  if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
474
495
  return key;
475
496
  }
@@ -598,7 +619,11 @@ export class UploadManager {
598
619
  * @returns {Promise<{aborted: number, failed: number}>}
599
620
  */
600
621
  async sweepExpired(now = Date.now()) {
601
- const ids = (await this.sessions.expired?.(now)) || [];
622
+ // Not `expired?.()`. Both session stores define it and it is part of the store
623
+ // interface — the optional call is the shape server/src/index.js records as having
624
+ // turned "evict idle documents" into a permanent no-op, because a name that stopped
625
+ // existing looked exactly like a name that was never required.
626
+ const ids = (await this.sessions.expired(now)) || [];
602
627
  let aborted = 0;
603
628
  let failed = 0;
604
629
  for (const id of ids) {