@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.
- package/package.json +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- 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
|
-
|
|
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),
|
|
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 = {
|
|
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
|
}
|