@3sln/trove 0.0.7 → 0.0.9

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 (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -0,0 +1,432 @@
1
+ // Moving a collection's objects onto a new key, a slice at a time.
2
+ //
3
+ // Rotation is the one operation here that cannot be atomic. A collection can hold hundreds
4
+ // of thousands of objects and every one has to be read, decrypted, re-encrypted and
5
+ // written back — far more than fits in a request, a Worker invocation, or anyone's
6
+ // patience. So it is a long job that runs in pieces, and the design follows from what
7
+ // happens when a piece does not finish.
8
+ //
9
+ // TWO KEYS ARE LIVE THROUGHOUT. `beginRotation` mints the new key and makes it current
10
+ // immediately, so everything uploaded from that moment is already correct and the job only
11
+ // ever has to deal with a shrinking set. Everything not yet moved still opens with the old
12
+ // key, because its envelope names it. Nothing is unreadable at any point, including
13
+ // halfway through, including after a crash.
14
+ //
15
+ // THE CURSOR IS PERSISTED, NOT HELD. The process doing the work can vanish between slices
16
+ // — an evicted isolate, a redeploy, a laptop closing. Progress lives in the KeyValueStore
17
+ // with the rest of the drive's durable state, so the next slice picks up where the last
18
+ // one stopped rather than starting again. Re-running a slice that already ran is harmless:
19
+ // an object already on the current key is skipped, so the work is idempotent by
20
+ // construction rather than by bookkeeping.
21
+ //
22
+ // IT FINISHES BY OBSERVATION. The old key is retired when a full pass finds nothing left
23
+ // on it — not when a counter says the job is done. A count can be wrong; a pass that finds
24
+ // nothing cannot be. That also handles the awkward case of an object uploaded onto the old
25
+ // key by a request that was in flight when the rotation started.
26
+
27
+ import { TroveError } from '../errors.js';
28
+ import { encryptStream, decodeHeader } from './envelope.js';
29
+ import { fromHex, fingerprint, toHex } from './keys.js';
30
+
31
+ const NS = 'rotations';
32
+
33
+ /** How long one slice may run before yielding, so a cron firing stays inside its budget. */
34
+ const DEFAULT_BUDGET_MS = 15_000;
35
+
36
+ /**
37
+ * How much sealed output to gather before sending a part.
38
+ *
39
+ * Above S3's 5 MiB floor for non-final parts, and small enough that peak memory during a
40
+ * rotation is a few megabytes rather than a function of the file.
41
+ */
42
+ const PART_TARGET_BYTES = 8 * 1024 * 1024;
43
+
44
+ /**
45
+ * @typedef {object} RotationState
46
+ * @property {string} collectionId
47
+ * @property {string} to fingerprint of the key being moved onto
48
+ * @property {string[]} from fingerprints being moved off
49
+ * @property {string|null} cursor where the last slice stopped
50
+ * @property {number} moved
51
+ * @property {number} failed
52
+ * @property {number} startedAt
53
+ * @property {'running'|'done'} status
54
+ */
55
+
56
+ /**
57
+ * The storage key a rotation writes to.
58
+ *
59
+ * Based on the key with any previous rotation suffix STRIPPED. Appending to
60
+ * `node.storageKey` instead compounds: a collection rotated twice held
61
+ * `obj_x.rotms9gq8j2.rotms9grnhf`, and a key grew by ~11 characters on every rotation for
62
+ * the life of the drive. S3 stops accepting a key at 1024 bytes, so a drive rotated on a
63
+ * schedule would eventually fail to rotate at all — years out, silently, and only for the
64
+ * objects with the longest names.
65
+ */
66
+ export function rotatedKey(storageKey) {
67
+ return `${storageKey.replace(/(\.rot[0-9a-z]+)+$/, '')}.rot${Date.now().toString(36)}`;
68
+ }
69
+
70
+ export class RotationService {
71
+ /**
72
+ * @param {object} deps
73
+ * @param {import('../kv.js').KeyValueStore} deps.kv where progress survives
74
+ * @param {import('../vfs.js').Vfs} deps.vfs
75
+ * @param {import('../collections/index.js').CollectionService} deps.collections
76
+ */
77
+ constructor({ kv, vfs, collections }) {
78
+ if (!kv || !vfs || !collections) throw TroveError.invalid('RotationService needs kv, vfs and collections');
79
+ this.kv = kv;
80
+ this.vfs = vfs;
81
+ this.collections = collections;
82
+ }
83
+
84
+ async state(collectionId) {
85
+ return this.kv.get(NS, collectionId);
86
+ }
87
+
88
+ /**
89
+ * Start moving a collection onto a fresh key.
90
+ *
91
+ * The new key becomes current here, before any object moves, so every upload from now on
92
+ * is already on it. Refuses to start a second rotation over an unfinished one — two
93
+ * walkers on one collection would fight over the cursor and neither would know what the
94
+ * other had done.
95
+ */
96
+ async begin(collectionId, principal) {
97
+ const running = await this.state(collectionId);
98
+ if (running && running.status === 'running') {
99
+ throw TroveError.invalid('A key rotation is already running on this collection');
100
+ }
101
+ // A finished-but-abandoned rotation can still be holding an open multipart. Starting a
102
+ // new one is the last moment anything knows it exists.
103
+ if (running?.inflight) await this.#discard(collectionId, running.inflight);
104
+ const { fingerprint: to, previous } = await this.collections.beginRotation(collectionId, principal);
105
+ const state = {
106
+ collectionId,
107
+ to,
108
+ // Everything currently live except the new key. Usually one, but a rotation started
109
+ // over an unfinished one would leave more, and dropping any of them would strand
110
+ // whatever is still sealed with it.
111
+ from: (await this.collections.keyRingFor(collectionId))
112
+ .map((k) => k.fingerprint)
113
+ .filter((fp) => fp !== to),
114
+ cursor: null,
115
+ // The object part-way through, when a slice ran out of budget inside one. See
116
+ // #moveSlice — this is what lets an object larger than a slice be rotated at all.
117
+ inflight: null,
118
+ moved: 0,
119
+ failed: 0,
120
+ startedAt: Date.now(),
121
+ status: 'running',
122
+ previous,
123
+ };
124
+ await this.kv.set(NS, collectionId, state);
125
+ return state;
126
+ }
127
+
128
+ /**
129
+ * Do a slice of the work.
130
+ *
131
+ * Bounded by time rather than by count, because objects vary from bytes to gigabytes and
132
+ * a count is not a budget. Returns the state, so a caller can loop until `done`.
133
+ */
134
+ async step(collectionId, { budgetMs = DEFAULT_BUDGET_MS, now = () => Date.now() } = {}) {
135
+ const state = await this.state(collectionId);
136
+ if (!state || state.status !== 'running') return state;
137
+
138
+ // One walker at a time, claimed the way a scan claims its collection.
139
+ //
140
+ // Two slices running together — a cron overlapping a manual run, or two cron firings on
141
+ // a slow collection — can both pick up the same object. Each writes a new object and
142
+ // points the item at it, and then each deletes the object IT replaced: the second
143
+ // delete removes the object the item is now pointing at. With the old key still in the
144
+ // ring nothing reports an error, and the file is simply gone.
145
+ //
146
+ // A lease rather than a flag, because the holder can die mid-slice and a lock that
147
+ // outlives its holder stops the rotation permanently with nobody left to notice.
148
+ const claim = await this.kv.acquire('rotation', collectionId, Math.max(30_000, budgetMs * 3));
149
+ if (!claim) return state;
150
+ try {
151
+ return await this.#slice(collectionId, state, { budgetMs, now });
152
+ } finally {
153
+ await this.kv.release('rotation', collectionId, claim).catch(() => {});
154
+ }
155
+ }
156
+
157
+ async #slice(collectionId, state, { budgetMs, now }) {
158
+
159
+ const deadline = now() + budgetMs;
160
+ const key = await this.collections.dataKeyFor(collectionId, state.to);
161
+ if (!key) throw TroveError.invalid('The key this rotation is moving onto is gone');
162
+ const fp = await fingerprint(key);
163
+
164
+ let cursor = state.cursor;
165
+ let moved = state.moved;
166
+ let failed = state.failed;
167
+ let sawStragglers = false;
168
+ let inflight = state.inflight || null;
169
+
170
+ // Finish what the last slice started before looking for more. An object part-way
171
+ // through is the one thing that MUST be dealt with first: its multipart is open and
172
+ // billed, and the walk would otherwise pick it up again from the beginning.
173
+ if (inflight) {
174
+ const node = await this.vfs.metadata.getById(inflight.nodeId).catch(() => null);
175
+ // Deleted, or already moved by something else, while we were away. Spend the upload
176
+ // rather than leaving it open forever.
177
+ if (!node || !node.encryption || node.encryption.fingerprint === state.to) {
178
+ await this.#discard(collectionId, inflight);
179
+ inflight = null;
180
+ } else {
181
+ sawStragglers = true;
182
+ try {
183
+ const r = await this.#moveSlice(node, key, fp, inflight, { deadline, now });
184
+ inflight = r.inflight;
185
+ if (r.done) moved++;
186
+ } catch (err) {
187
+ failed++;
188
+ await this.#discard(collectionId, inflight);
189
+ inflight = null;
190
+ console.error(`[trove] resuming ${node.name} failed:`, err?.message || err);
191
+ }
192
+ // Still not finished, or out of time: persist and let the next slice continue.
193
+ if (inflight || now() >= deadline) {
194
+ return this.#save(collectionId, { ...state, cursor, moved, failed, inflight });
195
+ }
196
+ }
197
+ }
198
+
199
+ for (;;) {
200
+ const page = await this.vfs.metadata.listItems(collectionId, { cursor, limit: 50 });
201
+ const items = page.items || [];
202
+ for (const node of items) {
203
+ // Only encrypted items, and only ones not already on the current key. This is what
204
+ // makes re-running a slice free rather than destructive.
205
+ if (!node.encryption || node.encryption.fingerprint === state.to) continue;
206
+ sawStragglers = true;
207
+ try {
208
+ const r = await this.#moveSlice(node, key, fp, null, { deadline, now });
209
+ if (r.done) moved++;
210
+ else {
211
+ // Bigger than the remaining budget. Its parts and its place in the envelope are
212
+ // recorded, and the next slice picks it up where this one stopped instead of
213
+ // starting the object again — which is what used to make a large object
214
+ // unrotatable at all rather than merely slow.
215
+ return this.#save(collectionId, { ...state, cursor, moved, failed, inflight: r.inflight });
216
+ }
217
+ } catch (err) {
218
+ // One unreadable object must not stop the rotation: the rest of the collection
219
+ // still needs to move, and the old key cannot be retired while anything is left
220
+ // on it — which is exactly the signal a failure should produce.
221
+ failed++;
222
+ console.error(`[trove] rotating ${node.name} failed:`, err?.message || err);
223
+ }
224
+ if (now() >= deadline) break;
225
+ }
226
+ cursor = page.nextCursor || null;
227
+ if (!cursor || now() >= deadline) break;
228
+ }
229
+
230
+ const finished = !cursor && !sawStragglers;
231
+ const next = {
232
+ ...state,
233
+ inflight: null,
234
+ // A finished pass starts the next one from the beginning, because items added during
235
+ // it may still be behind. Two clean passes in a row is what actually ends the job.
236
+ cursor: finished ? null : cursor,
237
+ moved,
238
+ failed,
239
+ status: finished && failed === 0 ? 'done' : 'running',
240
+ finishedAt: finished && failed === 0 ? now() : undefined,
241
+ };
242
+ await this.kv.set(NS, collectionId, next);
243
+
244
+ // Retire by observation: a pass that found nothing left on the old keys is proof, in a
245
+ // way that a counter never is.
246
+ if (next.status === 'done') {
247
+ for (const old of state.from) {
248
+ // System work: it is finishing what an admin authorized when they started the
249
+ // rotation, and there is no user behind a cron firing. A failure here is logged
250
+ // rather than swallowed — the rotation itself succeeded, but a key that should
251
+ // have been retired and was not is worth knowing about.
252
+ await this.collections.retireKey(collectionId, old, null, { system: true })
253
+ .catch((err) => console.error(`[trove] retiring key ${old} failed:`, err?.message || err));
254
+ }
255
+ }
256
+ return next;
257
+ }
258
+
259
+ /**
260
+ * Move one object onto the new key, in as many slices as it takes.
261
+ *
262
+ * A rotation slice is bounded by wall-clock time, and until now an object could only be
263
+ * moved whole: one larger than the budget failed its slice, restarted from the beginning
264
+ * on the next, and failed again — forever, with the rotation never completing and the old
265
+ * key never retiring. A collection containing one big video could not be rotated at all.
266
+ *
267
+ * The checkpoint is the multipart part list, which the store is already keeping for us.
268
+ * What has to travel with it is the ENVELOPE's position: a nonce is derived from the
269
+ * chunk index, so a resumed encryption must continue the same sequence under the same
270
+ * prefix. Starting a fresh envelope halfway would reuse nonce/key pairs from the first
271
+ * half — the one thing AES-GCM cannot survive — so the prefix and the next chunk index
272
+ * are persisted alongside the parts.
273
+ *
274
+ * @returns {Promise<{done: boolean, inflight: object|null}>}
275
+ */
276
+ async #moveSlice(node, newKey, newFingerprint, inflight, { deadline, now }) {
277
+ const storage = await this.vfs.storageFor(node.collectionId);
278
+ const chunkSize = node.encryption.chunkSize;
279
+ const plaintextSize = node.size;
280
+
281
+ // A store without multipart is a local one — filesystem, memory — where the whole
282
+ // object is already within reach. There is nothing to check point against, and nothing
283
+ // that needs it: the budget that makes this necessary is a network one.
284
+ if (!storage.capabilities?.multipart) {
285
+ await this.#moveWhole(node, newKey, newFingerprint, storage, chunkSize);
286
+ return { done: true, inflight: null };
287
+ }
288
+
289
+ // Written to a NEW storage key rather than over the old one. AES-GCM cannot survive a
290
+ // nonce being reused with a key, and rewriting in place invites exactly that on a
291
+ // retry; a new object also means a failure halfway leaves the original intact and
292
+ // readable rather than a half-written file that is neither.
293
+ const nextKey = inflight?.storageKey ?? rotatedKey(node.storageKey);
294
+ const uploadId = inflight?.uploadId ?? await storage.createMultipart(nextKey, { contentType: node.contentType });
295
+ const parts = inflight?.parts ? [...inflight.parts] : [];
296
+ let chunkIndex = inflight?.chunkIndex ?? 0;
297
+ let noncePrefix = inflight?.noncePrefix ? fromHex(inflight.noncePrefix) : null;
298
+
299
+ // One part per pass, so the checkpoint lands on a part boundary — which is also a chunk
300
+ // boundary, since a part is a whole number of sealed chunks. Sized to clear S3's 5 MiB
301
+ // floor for every part but the last.
302
+ const chunksPerPart = Math.max(1, Math.ceil(PART_TARGET_BYTES / chunkSize));
303
+
304
+ try {
305
+ while (chunkIndex * chunkSize < plaintextSize) {
306
+ const start = chunkIndex * chunkSize;
307
+ const end = Math.min(start + chunksPerPart * chunkSize, plaintextSize) - 1;
308
+ const last = end + 1 >= plaintextSize;
309
+
310
+ const read = await this.vfs.readStream(node.id, { range: { start, end } });
311
+ const sealed = await encryptStream(newKey, read.stream, {
312
+ fingerprint: newFingerprint,
313
+ plaintextSize,
314
+ chunkSize,
315
+ // The first pass mints the prefix and writes the header; every later one continues
316
+ // the same envelope, which is what `resume` means.
317
+ resume: noncePrefix ? { noncePrefix, index: chunkIndex } : null,
318
+ // `partial` says another call will continue this object. Without it a mid-object
319
+ // pass would be sealed as a complete envelope and every later chunk index would be
320
+ // wrong.
321
+ partial: !last,
322
+ });
323
+
324
+ const body = new Uint8Array(await new Response(sealed).arrayBuffer());
325
+ // The prefix the first pass generated, read back out of the header it wrote. It is
326
+ // the only place it exists, and every later pass needs it.
327
+ if (!noncePrefix) noncePrefix = decodeHeader(body).noncePrefix;
328
+
329
+ const partNumber = parts.length + 1;
330
+ const etag = await storage.putPart(nextKey, uploadId, partNumber, body);
331
+ parts.push({ partNumber, etag: etag?.etag ?? etag });
332
+ chunkIndex += Math.ceil((end + 1 - start) / chunkSize);
333
+
334
+ // Out of time, but on a boundary: hand back what has been done so the next slice
335
+ // continues rather than starting over.
336
+ if (!last && now() >= deadline) {
337
+ return {
338
+ done: false,
339
+ inflight: {
340
+ nodeId: node.id,
341
+ storageKey: nextKey,
342
+ uploadId,
343
+ parts,
344
+ chunkIndex,
345
+ noncePrefix: toHex(noncePrefix),
346
+ },
347
+ };
348
+ }
349
+ }
350
+
351
+ await storage.completeMultipart(nextKey, uploadId, parts);
352
+ } catch (err) {
353
+ // The parts already sent stay in the bucket, billed, with nothing left able to
354
+ // reclaim them — so a failure aborts rather than leaving them.
355
+ await storage.abortMultipart(nextKey, uploadId).catch(() => {});
356
+ throw err;
357
+ }
358
+
359
+ // The item points at the new object before the old one is removed. In the window
360
+ // between, both exist and the item is readable; in the reverse order there is a window
361
+ // where it is readable through neither.
362
+ const oldKey = node.storageKey;
363
+ await this.vfs.metadata.update(node.id, {
364
+ storageKey: nextKey,
365
+ encryption: { fingerprint: toHex(newFingerprint), chunkSize },
366
+ });
367
+ await storage.delete(oldKey).catch(() => {});
368
+ return { done: true, inflight: null };
369
+ }
370
+
371
+ /** The whole object in one put, for a store that cannot do multipart. */
372
+ async #moveWhole(node, newKey, newFingerprint, storage, chunkSize) {
373
+ const nextKey = rotatedKey(node.storageKey);
374
+ const read = await this.vfs.readStream(node.id);
375
+ const sealed = await encryptStream(newKey, read.stream, {
376
+ fingerprint: newFingerprint,
377
+ plaintextSize: read.size ?? node.size,
378
+ chunkSize,
379
+ });
380
+ await storage.put(nextKey, new Uint8Array(await new Response(sealed).arrayBuffer()), {
381
+ contentType: node.contentType,
382
+ });
383
+ const oldKey = node.storageKey;
384
+ await this.vfs.metadata.update(node.id, {
385
+ storageKey: nextKey,
386
+ encryption: { fingerprint: toHex(newFingerprint), chunkSize },
387
+ });
388
+ await storage.delete(oldKey).catch(() => {});
389
+ }
390
+
391
+ /**
392
+ * Abandon a half-written object.
393
+ *
394
+ * A multipart left open is billed until it is completed or aborted, and nothing else can
395
+ * find it: the storage contract has no way to list them, deliberately, so the only record
396
+ * that it exists is the one we wrote. Cancelling or restarting a rotation has to spend it.
397
+ */
398
+ async #discard(collectionId, inflight) {
399
+ if (!inflight?.uploadId) return;
400
+ try {
401
+ const storage = await this.vfs.storageFor(collectionId);
402
+ await storage.abortMultipart(inflight.storageKey, inflight.uploadId);
403
+ } catch (err) {
404
+ console.error(`[trove] abandoning a rotation upload failed:`, err?.message || err);
405
+ }
406
+ }
407
+
408
+ /** Persist progress mid-pass, so the next slice continues from here. */
409
+ async #save(collectionId, next) {
410
+ await this.kv.set(NS, collectionId, next);
411
+ return next;
412
+ }
413
+
414
+ /** Abandon a rotation. The new key stays current; what has moved stays moved. */
415
+ async cancel(collectionId) {
416
+ const state = await this.state(collectionId);
417
+ if (!state) return null;
418
+ // The half-written object goes with it. Nothing else can find that multipart — the
419
+ // storage contract cannot list them — so if this does not spend it, nothing will.
420
+ await this.#discard(collectionId, state.inflight);
421
+ const next = { ...state, status: 'done', cancelled: true, inflight: null };
422
+ await this.kv.set(NS, collectionId, next);
423
+ return next;
424
+ }
425
+ }
426
+
427
+ /** Read the fingerprint an object actually carries, for checking rather than trusting. */
428
+ export async function fingerprintOf(bytes) {
429
+ return toHex(decodeHeader(bytes).fingerprint);
430
+ }
431
+
432
+ export { fromHex };
@@ -16,6 +16,7 @@ export { MemoryStorage } from './storage/memory.js';
16
16
  export { StorageDriverRegistry } from './storage/registry.js';
17
17
  export { portableDrivers } from './storage/drivers.js';
18
18
  export { diagnoseStorage, corsPolicy, STORAGE_ISSUE_CODES } from './storage/diagnose.js';
19
+ export { estimateRotationCost, recognizeProvider, RATES_AS_OF } from './storage/cost.js';
19
20
  export { S3Storage } from './storage/s3.js';
20
21
  export { PrefixedStorage } from './storage/prefixed.js';
21
22
 
@@ -36,7 +37,22 @@ export { SqliteVectorStore, SqliteKeywordStore, SEARCH_DB_KEY } from './search/s
36
37
 
37
38
  export { IndexerRegistry, textIndexer, chunkText } from './indexers/registry.js';
38
39
  export { PluginService, PackageStore, StoragePackageStore, PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore, parsePluginPackage, capabilityList, ALL_CAPABILITIES, IndexerRuntime, InProcessIndexerRuntime, PluginIndexers, matchFromSelector } from './plugins/index.js';
39
- export { UploadManager, DEFAULT_PART_SIZE } from './uploads.js';
40
+ export { UploadManager, KvSessionStore, DEFAULT_PART_SIZE } from './uploads.js';
41
+ // Encryption at rest: the bucket holds ciphertext, the drive holds the key. Protects
42
+ // against the STORAGE host (a leaked bucket credential, a storage vendor who is not the
43
+ // compute vendor) and deliberately not against the server, which must read plaintext to
44
+ // index it. See encryption/keys.js for why nothing here is a plain hash of a passphrase.
45
+ export {
46
+ encrypt, encryptStream, decrypt, decryptRange, decryptStream, encodeHeader, decodeHeader, isEnvelope,
47
+ cipherSize, plaintextSizeOf, cipherRangeFor,
48
+ HEADER_BYTES, TAG_BYTES, DEFAULT_CHUNK_SIZE,
49
+ } from './encryption/envelope.js';
50
+ export {
51
+ generateDataKey, newCollectionKey, fingerprint, fingerprintHex, toHex, fromHex,
52
+ } from './encryption/keys.js';
53
+ export { normalizeEncryption, shouldEncrypt, describeEncryption } from './encryption/policy.js';
54
+ export { RotationService } from './encryption/rotation.js';
55
+ export { describeExposure } from './encryption/exposure.js';
40
56
  export { Vfs, CONTENT_TYPES } from './vfs.js';
41
57
  export { IndexingCoordinator } from './indexing.js';
42
58
  // Work in flight (ephemeral) and standing problems (durable) — see the header of each.
@@ -139,3 +139,88 @@ export function troveUrisFor(node) {
139
139
  if (node?.collectionId && node?.id) out.push(troveUri(node, 'id'));
140
140
  return out;
141
141
  }
142
+
143
+ // --- shareable web links -------------------------------------------------------
144
+ //
145
+ // `trove:` addresses an item INSIDE the drive: it is what one document writes to link
146
+ // another, and it means nothing to a browser. A share link is the other half — a URL you
147
+ // can paste into a message so that someone else's browser opens the same item.
148
+ //
149
+ // They are deliberately the same addressing, not two competing schemes. A share link is a
150
+ // `trove:` URI wearing an http(s) coat: same collection, same explicit `name`-or-`id`
151
+ // selector, same refusal to infer which one you meant. So anything that can already
152
+ // resolve a `trove:` URI can resolve a share link by parsing it back, and a rename breaks
153
+ // both in the same visible way rather than one silently retargeting.
154
+ //
155
+ // The path form — /c/<collection>/i/<selector> — rather than the query form
156
+ // `?coll=&item=`. Both put the ids in the URL and therefore in server logs and browser
157
+ // history, so that is not the difference; the path reads as a location, survives being
158
+ // truncated in a chat client more gracefully, and leaves the query string free for the
159
+ // things that genuinely are parameters.
160
+ //
161
+ // Nothing secret ever rides in one. An encrypted collection's key is not in the link and
162
+ // must not be: a link is pasted into chats, logged by proxies, and kept in history
163
+ // forever. The recipient gets the item because they are allowed the collection, which is
164
+ // the same rule as everywhere else here.
165
+
166
+ /** The path a share link uses. Exported so a client router and the server agree. */
167
+ export const SHARE_PATH = '/c';
168
+
169
+ /**
170
+ * A URL that opens this item in a browser.
171
+ *
172
+ * @param {object} node
173
+ * @param {string} [origin] where the drive is served; omitted gives a root-relative link
174
+ * @param {'name'|'id'} [by] `name` reads better and breaks on rename; `id` is the reverse
175
+ */
176
+ export function shareUrl(node, origin = '', by = 'name') {
177
+ if (!node?.collectionId) throw TroveError.invalid('An item needs a collection to be linked to');
178
+ const selector = by === 'id'
179
+ ? `id:${node.id}`
180
+ : encodeURIComponent(node.name);
181
+ if (!selector || (by === 'id' && !node.id)) throw TroveError.invalid('Nothing to link to');
182
+ const path = `${SHARE_PATH}/${encodeURIComponent(node.collectionId)}/i/${selector}`;
183
+ return origin ? `${String(origin).replace(/\/$/, '')}${path}` : path;
184
+ }
185
+
186
+ /**
187
+ * Read a share link back, from a full URL or just a path.
188
+ *
189
+ * Returns the same shape `parseTroveUri` does, so a caller resolves either without caring
190
+ * which it was handed. Null rather than a throw: this parses whatever was in the address
191
+ * bar, and a URL that is not a share link is an ordinary page, not an error.
192
+ */
193
+ export function parseShareUrl(url) {
194
+ let path;
195
+ try {
196
+ path = url.startsWith('/') ? url : new URL(url).pathname;
197
+ } catch {
198
+ return null;
199
+ }
200
+ const m = /^\/c\/([^/]+)\/i\/(.+)$/.exec(path);
201
+ if (!m) return null;
202
+ let collection;
203
+ let raw;
204
+ try {
205
+ collection = decodeURIComponent(m[1]);
206
+ raw = decodeURIComponent(m[2]);
207
+ } catch {
208
+ return null; // a malformed escape is not a link to anything
209
+ }
210
+ if (!COLLECTION_RE.test(collection)) return null;
211
+ // `id:` is the explicit selector, mirroring `?id=` — a name is never guessed at just
212
+ // because it happens to look like an id.
213
+ const byId = raw.startsWith('id:');
214
+ const value = byId ? raw.slice(3) : raw;
215
+ if (!value || value.length > MAX_SELECTOR) return null;
216
+ return { collection, by: byId ? 'id' : 'name', value };
217
+ }
218
+
219
+ /** The `trove:` URI a share link denotes — the two are the same address. */
220
+ export function troveUriFromShareUrl(url) {
221
+ const parsed = parseShareUrl(url);
222
+ if (!parsed) return null;
223
+ return parsed.by === 'id'
224
+ ? `${TROVE_SCHEME}${parsed.collection}?id=${encodeURIComponent(parsed.value)}`
225
+ : `${TROVE_SCHEME}${parsed.collection}?name=${encodeURIComponent(parsed.value)}`;
226
+ }
@@ -90,6 +90,8 @@ export class MemoryStore extends MetadataStore {
90
90
  size: node.size ?? 0, contentType: node.contentType ?? null,
91
91
  storageKey: node.storageKey ?? null, etag: node.etag ?? null,
92
92
  createdAt: now, updatedAt: now, meta: node.meta ?? {}, facets: rawFacetsFromNode(node),
93
+ // Which key opens this object; null for anything stored in the clear.
94
+ encryption: node.encryption ?? null,
93
95
  };
94
96
  this.#index(full);
95
97
  return clone(full);
@@ -98,7 +100,7 @@ export class MemoryStore extends MetadataStore {
98
100
  async update(id, patch) {
99
101
  const node = this.nodes.get(id);
100
102
  if (!node) throw TroveError.notFound('Item');
101
- for (const k of ['size', 'contentType', 'storageKey', 'etag', 'meta']) {
103
+ for (const k of ['size', 'contentType', 'storageKey', 'etag', 'meta', 'encryption']) {
102
104
  if (k in patch) node[k] = patch[k];
103
105
  }
104
106
  node.updatedAt = Date.now();
@@ -46,7 +46,8 @@ export class SqliteStore extends MetadataStore {
46
46
  createdAt INTEGER NOT NULL,
47
47
  updatedAt INTEGER NOT NULL,
48
48
  meta TEXT NOT NULL DEFAULT '{}',
49
- facets TEXT NOT NULL DEFAULT '{}'
49
+ facets TEXT NOT NULL DEFAULT '{}',
50
+ encryption TEXT
50
51
  );
51
52
  CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
52
53
  CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(updatedAt);
@@ -68,6 +69,11 @@ export class SqliteStore extends MetadataStore {
68
69
  if (!cols.some((c) => c.name === 'deletedAt')) {
69
70
  await this.db.exec('ALTER TABLE nodes ADD COLUMN deletedAt INTEGER');
70
71
  }
72
+ // Which key opens this object, for drives that predate encryption. NULL means stored
73
+ // in the clear, which is what every existing row is.
74
+ if (!cols.some((c) => c.name === 'encryption')) {
75
+ await this.db.exec('ALTER TABLE nodes ADD COLUMN encryption TEXT');
76
+ }
71
77
  // Recreating the index is cheap and idempotent; naming the new one differently is
72
78
  // what makes "has this run?" answerable without a migrations table.
73
79
  await this.db.exec(`
@@ -130,14 +136,16 @@ export class SqliteStore extends MetadataStore {
130
136
  size: node.size ?? 0, contentType: node.contentType ?? null,
131
137
  storageKey: node.storageKey ?? null, etag: node.etag ?? null,
132
138
  createdAt: now, updatedAt: now, meta: node.meta ?? {}, facets: rawFacetsFromNode(node),
139
+ encryption: node.encryption ?? null,
133
140
  };
134
141
  try {
135
142
  await this.db.run(
136
- `INSERT INTO nodes (id,collectionId,name,size,contentType,storageKey,etag,createdAt,updatedAt,meta,facets)
137
- VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
143
+ `INSERT INTO nodes (id,collectionId,name,size,contentType,storageKey,etag,createdAt,updatedAt,meta,facets,encryption)
144
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
138
145
  full.id, full.collectionId, full.name, full.size,
139
146
  full.contentType, full.storageKey, full.etag, full.createdAt, full.updatedAt,
140
147
  JSON.stringify(full.meta), JSON.stringify(full.facets),
148
+ full.encryption ? JSON.stringify(full.encryption) : null,
141
149
  );
142
150
  return full;
143
151
  } catch (err) {
@@ -150,13 +158,17 @@ export class SqliteStore extends MetadataStore {
150
158
  const node = await this.getById(id);
151
159
  if (!node) throw TroveError.notFound('Node');
152
160
  const next = { ...node };
153
- for (const k of ['size', 'contentType', 'storageKey', 'etag', 'meta']) {
161
+ // `encryption` is here because a key rotation rewrites the object and has to record
162
+ // which key the new one is sealed with. Without it a rotated item still claims the old
163
+ // key and becomes unreadable the moment that key is retired.
164
+ for (const k of ['size', 'contentType', 'storageKey', 'etag', 'meta', 'encryption']) {
154
165
  if (k in patch) next[k] = patch[k];
155
166
  }
156
167
  next.updatedAt = Date.now();
157
168
  await this.db.run(
158
- `UPDATE nodes SET size=?, contentType=?, storageKey=?, etag=?, meta=?, updatedAt=? WHERE id=?`,
159
- next.size, next.contentType, next.storageKey, next.etag, JSON.stringify(next.meta), next.updatedAt, id,
169
+ `UPDATE nodes SET size=?, contentType=?, storageKey=?, etag=?, meta=?, encryption=?, updatedAt=? WHERE id=?`,
170
+ next.size, next.contentType, next.storageKey, next.etag, JSON.stringify(next.meta),
171
+ next.encryption ? JSON.stringify(next.encryption) : null, next.updatedAt, id,
160
172
  );
161
173
  return next;
162
174
  }
@@ -353,7 +365,15 @@ export class SqliteStore extends MetadataStore {
353
365
  function row(r) {
354
366
  if (!r) return null;
355
367
  const { contributions, tags } = splitContributions(r.facets ? JSON.parse(r.facets) : {});
356
- const out = { ...r, meta: r.meta ? JSON.parse(r.meta) : {}, contributions, tags };
368
+ const out = {
369
+ ...r,
370
+ meta: r.meta ? JSON.parse(r.meta) : {},
371
+ // Null for everything stored in the clear, which is every row on a drive that predates
372
+ // encryption and every item a rule did not match.
373
+ encryption: r.encryption ? JSON.parse(r.encryption) : null,
374
+ contributions,
375
+ tags,
376
+ };
357
377
  delete out.facets; // internal column name; exposed as contributions + tags
358
378
  return out;
359
379
  }