@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,109 @@
1
+ // What a plugin package's signature actually means.
2
+ //
3
+ // This is a security classification, not a label. It decided the same four states in two
4
+ // different components — the installed-plugin list and the install-review dialog — with two
5
+ // different sets of words, and the distinction that matters most was the one that drifted:
6
+ //
7
+ // `invalid` is NOT `unverified`. Unverified means nobody signed it, which is the ordinary
8
+ // case for a plugin you got from a friend. Invalid means it WAS signed and the signature
9
+ // does not verify — the bytes are not the bytes that were signed. That is the one state
10
+ // implying someone altered the package, and the list once rendered it in the same amber as
11
+ // the benign case, because the two `if` ladders were maintained separately and one of them
12
+ // was missing a branch.
13
+ //
14
+ // So the classification lives here, once, and the components choose their own wording from
15
+ // `status`. What is a security judgement is in the business layer; how many characters fit
16
+ // in a badge is not.
17
+
18
+ /**
19
+ * Whether a package may be installed at all, and on what terms.
20
+ *
21
+ * Three answers, because the states differ in kind rather than in degree:
22
+ *
23
+ * - `invalid` is REFUSED. It means the package was signed and the signature does not
24
+ * verify — the bytes are not the bytes that were signed. No development workflow
25
+ * produces that; it means the package was altered in transit or at rest. There is
26
+ * nothing for a user to weigh, so they are not asked.
27
+ *
28
+ * - `unverified` is installable but only DELIBERATELY. Nobody signed it, which is the
29
+ * ordinary state of a plugin you are writing: you cannot sign a package you are still
30
+ * changing. So it stays possible, behind an acknowledgement that says what is being
31
+ * given up — no publisher, no proof the code is what its author shipped.
32
+ *
33
+ * - `signed` carries a real signature whose domain does not vouch for the key. That is a
34
+ * claim to a namespace rather than a proof of one, so it is said prominently and left
35
+ * to the user; refusing it would block a valid publisher whose assetlinks are merely
36
+ * misconfigured.
37
+ *
38
+ * This is also what closes namespace squatting. A package claiming another publisher's
39
+ * domain is unverified by construction — the real domain does not publish its key — so it
40
+ * cannot be installed without the user being told exactly that.
41
+ */
42
+ export function installPolicyFor(trust) {
43
+ const t = describeTrust(trust);
44
+ if (t.status === 'invalid') {
45
+ return {
46
+ status: t.status,
47
+ allowed: false,
48
+ requiresAcknowledgement: false,
49
+ headline: 'This package has been altered',
50
+ detail: `${t.explanation}. It was signed, and the signature does not match its contents — so it is not what its author published. Trove will not install it.`,
51
+ };
52
+ }
53
+ if (t.status === 'unverified') {
54
+ return {
55
+ status: t.status,
56
+ allowed: true,
57
+ requiresAcknowledgement: true,
58
+ headline: 'Unsigned — for development only',
59
+ detail: 'Nobody has signed this package, so there is no way to tell who wrote it or whether it has been altered since. Install it only if you built it yourself or you trust wherever you got it from.',
60
+ };
61
+ }
62
+ if (t.status === 'signed') {
63
+ return {
64
+ status: t.status,
65
+ allowed: true,
66
+ requiresAcknowledgement: false,
67
+ headline: 'Signed, but the domain does not vouch for the key',
68
+ detail: `${t.explanation}. The signature is real; what is unproven is that ${t.domain || 'the publisher'} authorised it.`,
69
+ };
70
+ }
71
+ return { status: t.status, allowed: true, requiresAcknowledgement: false, headline: null, detail: null };
72
+ }
73
+
74
+ /**
75
+ * @param {{status?: string, domain?: string, reason?: string}|null} trust
76
+ * @returns {{status: string, tone: string, icon: string, domain: string|null, explanation: string, severity: number}}
77
+ */
78
+ export function describeTrust(trust) {
79
+ const t = trust || {};
80
+ switch (t.status) {
81
+ case 'verified':
82
+ return {
83
+ status: 'verified', tone: 'verified', icon: 'check', domain: t.domain || null,
84
+ explanation: t.domain
85
+ ? `Signed by a key published at ${t.domain}`
86
+ : 'Signed by a key the publishing domain vouches for',
87
+ severity: 0,
88
+ };
89
+ case 'signed':
90
+ return {
91
+ status: 'signed', tone: 'signed', icon: 'info', domain: t.domain || null,
92
+ explanation: t.reason || 'Signed, but the domain does not vouch for the key',
93
+ severity: 1,
94
+ };
95
+ case 'invalid':
96
+ return {
97
+ status: 'invalid', tone: 'invalid', icon: 'warn', domain: t.domain || null,
98
+ // Said plainly. This is the only state that means tampering rather than absence.
99
+ explanation: t.reason || 'The signature did not verify — this package may have been altered',
100
+ severity: 3,
101
+ };
102
+ default:
103
+ return {
104
+ status: 'unverified', tone: 'unverified', icon: 'warn', domain: t.domain || null,
105
+ explanation: t.reason || 'This plugin is not signed',
106
+ severity: 2,
107
+ };
108
+ }
109
+ }
@@ -0,0 +1,60 @@
1
+ // View state: what the UI is in the middle of doing.
2
+ //
3
+ // Which keybinding is mid-capture, what has been typed into a dialog that has not been
4
+ // submitted, which capabilities are ticked in a plugin review. None of it is drive state —
5
+ // it is thrown away when the dialog closes — but all of it decides what is on screen, and
6
+ // anything that decides what is on screen is engine state. A component reads it through a
7
+ // query and writes it through an action, the same as everything else.
8
+ //
9
+ // The history is worth keeping because it went wrong twice in the same direction. It began
10
+ // as module-level `let`s mutated in place, with a `rerender()` hook threaded through
11
+ // fourteen modules so the leaf that changed one could poke the root into redrawing — data
12
+ // down, invalidation back up out of band. Moving it into a cell fixed the invalidation and
13
+ // left a second version of the same mistake: components still READ it by calling the module
14
+ // during their own render, and two of them WROTE to it there as well, to lazily initialise a
15
+ // default. A render that writes state is a render with a side effect.
16
+ //
17
+ // So this is a resource now, and the lazy-initialise trick is gone: where a component used
18
+ // to write a default on first render, it now computes one and writes nothing until the user
19
+ // does something. `ref` is what makes that possible — it records which dialog instance the
20
+ // value belongs to, so a stale entry is simply ignored rather than needing to be cleared.
21
+ //
22
+ // The holder itself is a slice in bl/state.js; what stays here is how to read one.
23
+
24
+ /**
25
+ * The slice for `key`, or `fallback` when it belongs to a different dialog instance.
26
+ *
27
+ * Two components keep a draft that must reset when the dialog is reopened. They used to do
28
+ * that by writing the default during the render that noticed the mismatch. Deriving it
29
+ * instead means the render stays a pure function of the state it was given, and the first
30
+ * write happens when the user actually changes something.
31
+ *
32
+ * @param {object} slice the view-state slice from the query
33
+ * @param {string} key
34
+ * @param {object} ref the dialog instance the value must belong to
35
+ * @param {any} fallback
36
+ */
37
+ export function draftFor(slice, key, ref, fallback) {
38
+ const held = slice?.[key];
39
+ return held && held.ref === ref ? held : { ref, ...fallback };
40
+ }
41
+
42
+ /** Where a prompt dialog's typed value lives while it is being typed. */
43
+ export const PROMPT = 'promptValue';
44
+
45
+ /**
46
+ * What has been typed into the open prompt, or its initial value.
47
+ *
48
+ * The prompt kept this in a `let` closed over by the render and mutated by the input
49
+ * handler, and handed it back through an `onSubmit` callback — which meant a FUNCTION was
50
+ * living in the dialog spec, and the dialog spec is engine state. Keeping the value here
51
+ * instead lets a prompt carry actions like everything else, and each action reads what was
52
+ * typed rather than being handed it.
53
+ *
54
+ * `ref` ties it to the dialog instance, so reopening starts from the new initial value
55
+ * rather than whatever the last prompt left behind.
56
+ */
57
+ export function promptValueOf(slice, dialog) {
58
+ const held = slice?.[PROMPT];
59
+ return held && held.ref === dialog ? held.value : (dialog?.value ?? '');
60
+ }
@@ -0,0 +1,64 @@
1
+ // Which of the registered views should draw the results.
2
+ //
3
+ // The reactive half — which views exist, their when-clauses, which one the user pinned —
4
+ // is the `views` query. This is the rest: arithmetic over the items on screen.
5
+ //
6
+ // Still a plain function rather than part of that query only because the launcher assembles
7
+ // the items mid-render. They are themselves derived from the explorer and search state, so
8
+ // this folds into a query as soon as that assembly does.
9
+ //
10
+ // It lived in ui/components/views/index.js, which was half a job: the registry reads moved
11
+ // into the engine and the DECISION stayed in the render layer. Choosing a layout from what
12
+ // a collection contains is a claim about the drive, not about markup.
13
+
14
+ /** Where a pinned view choice is remembered. */
15
+ export const SETTING = 'explorer.view';
16
+
17
+ /**
18
+ * The view to draw with, in order of how much someone meant it.
19
+ *
20
+ * 1. A saved choice — they pressed a button. Nothing infers its way past that.
21
+ * 2. The search transformer's hint, when there is a search on screen. It read the
22
+ * sentence: "photos from the trip last summer" is a request for a gallery as much as
23
+ * it is a query, and nothing downstream can recover that from a list of content types.
24
+ * 3. A view whose `match` suits what is actually there — a collection of photographs
25
+ * opens as a grid without anyone asking.
26
+ * 4. The highest priority, which is the list.
27
+ *
28
+ * A hint naming a view this build doesn't have is ignored, not an error: the transformer
29
+ * is deployment configuration and may outlive the build it was written against.
30
+ *
31
+ * PURE. It used to take `platform` and read the contribution registry, the context keys and
32
+ * the settings mid-render — three reads that nothing invalidated on, so the launcher only
33
+ * kept up because the shell's snapshot was coarse enough to redraw it anyway. The reactive
34
+ * half is the `views` query now; what is left is arithmetic over the items already on
35
+ * screen, which is the one input that cannot be a query (there is no sensible key for "these
36
+ * forty search results").
37
+ *
38
+ * @param {{views: Array, saved: string|null}} slice from the `views` query
39
+ * @param {Array} items the rows being drawn, `{ node }`-shaped
40
+ * @param {string|null} hint the search transformer's suggestion, if this is a search
41
+ */
42
+ export function pickView({ views = [], saved = null } = {}, items = [], hint = null) {
43
+ if (!views.length) return null;
44
+ const chosen = saved && views.find((v) => v.id === saved);
45
+ if (chosen) return chosen;
46
+ const suggested = hint && views.find((v) => v.id === hint);
47
+ if (suggested) return suggested;
48
+ const nodes = items.map((i) => i.node).filter(Boolean);
49
+ if (nodes.length >= 3) {
50
+ const suits = (v) => v.match && Object.keys(v.match).length
51
+ && nodes.filter((n) => matchesView(v, n)).length / nodes.length > 0.6;
52
+ const fitted = views.find(suits);
53
+ if (fitted) return fitted;
54
+ }
55
+ return views[0];
56
+ }
57
+
58
+ function matchesView(view, node) {
59
+ const ct = node.contentType || '';
60
+ const name = (node.name || '').toLowerCase();
61
+ const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
62
+ if ((view.match.ext || []).includes(ext)) return true;
63
+ return (view.match.mime || []).some((m) => (m.endsWith('/*') ? ct.startsWith(m.slice(0, -1)) : ct === m));
64
+ }
@@ -0,0 +1,73 @@
1
+ // Reading an engine query from the render layer.
2
+ //
3
+ // Mostly this exists to be the ONE place that knows how a query reaches a component. The
4
+ // bridging itself is a single call; the value is that it is not re-derived at fifteen call
5
+ // sites, and that if it ever needs an initial value or an error view, there is somewhere to
6
+ // put it.
7
+ //
8
+ // `engine.query(q)` answers an observable — `subscribe({next, error, complete})` — and dodo's
9
+ // `watch` takes a Cell — `onDirty(fn)` plus `getValue()`. `fromObservable` already bridges
10
+ // exactly those two shapes, so this is thin on purpose: the point is that there is ONE place
11
+ // that knows how a query reaches a component, rather than the adapter being re-derived at
12
+ // every call site.
13
+ //
14
+ // The subscription belongs to the CELL, not to the component: dodo connects a cell when its
15
+ // first watcher attaches and disconnects when the last one leaves, which is the same
16
+ // lifetime ngin gives a query realization. So a region that scrolls out of the tree stops
17
+ // observing, the query's `kill` runs, and whatever it was holding is released — without any
18
+ // component writing teardown.
19
+ //
20
+ // See docs/tickets/009.
21
+
22
+ import { fromObservable, PENDING } from '../runtime.js';
23
+
24
+ /**
25
+ * Cache the bridged cell per (engine, query instance).
26
+ *
27
+ * Not, as this first claimed, because two cells would "undo ngin's sharing" — they would
28
+ * not. Measured: two independent `fromObservable(engine.query(q))` over one instance boot
29
+ * the query ONCE and kill it once, because ngin shares the realization by instance and does
30
+ * not care how many observers arrive or through what. The sharing was never at risk.
31
+ *
32
+ * What the cache actually buys is smaller and worth stating accurately:
33
+ *
34
+ * - **Idempotence.** `watchQuery` can be called freely and returns the same cell, so it
35
+ * is safe in a render. Without it, `watch` — which compares its source by identity and
36
+ * resubscribes when it changes — would tear down and re-establish every pass.
37
+ * - **One fan-out per change** instead of one per bridge. Each extra cell is another
38
+ * observer on the controller and another invalidation to propagate.
39
+ *
40
+ * Neither is correctness, so this is an optimisation and a convenience, not a safeguard.
41
+ * The safeguard against duplicate realizations is interning the query instance — see
42
+ * bl/intern.js, which is where that problem is actually solved.
43
+ *
44
+ * Weak on the engine so a disposed engine takes its cells with it, and weak on the query so
45
+ * an interned instance that falls out of use does too.
46
+ */
47
+ const cells = new WeakMap();
48
+
49
+ /**
50
+ * @param {object} engine
51
+ * @param {object} queryInstance an INTERNED instance — see bl/intern.js
52
+ * @returns {object} a dodo Cell, PENDING until the query produces its first value
53
+ */
54
+ export function watchQuery(engine, queryInstance) {
55
+ let forEngine = cells.get(engine);
56
+ if (!forEngine) {
57
+ forEngine = new WeakMap();
58
+ cells.set(engine, forEngine);
59
+ }
60
+ let cell = forEngine.get(queryInstance);
61
+ if (!cell) {
62
+ // A query may declare what it means before it knows — `initial = null` for something
63
+ // fetched over the network, say. Without one the cell is PENDING, and PENDING reaching
64
+ // a `watch` renders its placeholder: fine for a region that can show an empty bar for a
65
+ // frame, wrong for a snapshot feeding the whole shell, which would blank until the
66
+ // request came back. The initial belongs to the query rather than to this call, so it
67
+ // cannot differ between two call sites reading the same thing.
68
+ const initial = 'initial' in queryInstance ? queryInstance.initial : PENDING;
69
+ cell = fromObservable(engine.query(queryInstance), { initial });
70
+ forEngine.set(queryInstance, cell);
71
+ }
72
+ return cell;
73
+ }
@@ -238,6 +238,28 @@ export class TroveApiClient {
238
238
  return this.request('POST', '/api/collections', { body });
239
239
  }
240
240
 
241
+ /** Change a collection's settings — including turning encryption on. */
242
+ updateCollection(id, patch) {
243
+ return this.request('PATCH', `/api/collections/${encodeURIComponent(id)}`, { body: patch });
244
+ }
245
+
246
+ // --- key rotation ---------------------------------------------------------
247
+ //
248
+ // What a rotation would cost before anyone starts one: on a metered store it is a real
249
+ // bill, and the button should not be the first place that becomes apparent.
250
+ rotationEstimate(id) {
251
+ return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate/estimate`);
252
+ }
253
+ rotationState(id) {
254
+ return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate`);
255
+ }
256
+ beginRotation(id) {
257
+ return this.request('POST', `/api/collections/${encodeURIComponent(id)}/rotate`);
258
+ }
259
+ cancelRotation(id) {
260
+ return this.request('DELETE', `/api/collections/${encodeURIComponent(id)}/rotate`);
261
+ }
262
+
241
263
  // --- conversations, tags, sidecar ------------------------------------------
242
264
  sidecar(id) {
243
265
  return this.request('GET', `/api/items/${encodeURIComponent(id)}/sidecar`);
@@ -387,32 +409,57 @@ export class TroveApiClient {
387
409
  async upload(file, opts) {
388
410
  const name = opts.name || file.name || 'untitled';
389
411
  const size = file.size;
390
- const plan = await this.request('POST', `${this.#scope(opts.collection)}/uploads`, {
391
- body: { name, size, contentType: file.type || undefined },
412
+ // Every step of an upload is retried, not just the parts.
413
+ //
414
+ // Multipart parts already had this; nothing else did — so a transient failure on
415
+ // `create`, on the single presigned PUT, or on `complete` killed the whole upload on
416
+ // the first stumble, while the same failure mid-multipart was shrugged off. The
417
+ // single-PUT path is the one most uploads take (anything under the multipart floor),
418
+ // which is to say the retries were on the rarer half.
419
+ //
420
+ // `withRetry` decides what is worth retrying — see errors.js. Notably a lost upload
421
+ // session is NOT retryable, and must not be: a session that has genuinely expired or
422
+ // been aborted will never come back, and hammering it just delays the error. That
423
+ // case is what the manual retry is for.
424
+ const step = (fn) => withRetry(fn, {
392
425
  signal: opts.signal,
426
+ retries: 4,
427
+ onRetry: ({ attempt, delayMs, error }) => opts.onRetry?.({ attempt, delayMs, message: error.message }),
393
428
  });
429
+
430
+ const plan = await step(() => this.request('POST', `${this.#scope(opts.collection)}/uploads`, {
431
+ body: { name, size, contentType: file.type || undefined },
432
+ signal: opts.signal,
433
+ }));
394
434
  // Hand the caller the server upload id so a cancel/failure can abort the session
395
435
  // (otherwise a multipart upload leaks server + storage state).
396
436
  if (plan.uploadId) opts.onStart?.(plan.uploadId);
397
437
 
438
+ // Progress is measured against what TRAVELS, which is now the file itself even for an
439
+ // encrypted collection: the drive seals it on the way to the store, so nothing larger
440
+ // than the file ever leaves this tab.
398
441
  const progress = new ProgressAggregator(size, opts.onProgress);
399
442
  const t = plan.transfer || {};
400
443
  const completeUrl = plan.endpoints?.complete || `/api/uploads/${plan.uploadId}/complete`;
401
444
 
402
445
  if (plan.strategy === 'single') {
403
- // One presigned PUT straight to storage (bytes never touch our server).
404
- await xhrPut(t.url || plan.url, file, { headers: t.requiredHeaders, signal: opts.signal, onProgress: (l) => progress.set('single', l) });
405
- const done = await this.request('POST', completeUrl, { body: {}, signal: opts.signal });
446
+ // One presigned PUT straight to storage (bytes never touch our server). The plan
447
+ // only ever chooses this for an UNENCRYPTED collection now a presigned PUT of a
448
+ // file this client has not sealed would put plaintext in the bucket.
449
+ const body = file;
450
+ await step(() => xhrPut(t.url || plan.url, body, { headers: t.requiredHeaders, signal: opts.signal, onProgress: (l) => progress.set('single', l) }));
451
+ const done = await step(() => this.request('POST', completeUrl, { body: {}, signal: opts.signal }));
406
452
  return done.node;
407
453
  }
408
454
  if (plan.strategy === 'direct-single') {
409
- await xhrPut(this.baseUrl + this.#partUrl(plan, 1), file, {
455
+ const body = file;
456
+ await step(() => xhrPut(this.baseUrl + this.#partUrl(plan, 1), body, {
410
457
  // Our own server, so it needs our bearer token; `t.authHeaders` lets the plan
411
458
  // add its own and wins on a clash.
412
459
  headers: { ...this.authHeaders(), ...t.authHeaders },
413
460
  signal: opts.signal, onProgress: (l) => progress.set(1, l),
414
- });
415
- const done = await this.request('POST', completeUrl, { body: {}, signal: opts.signal });
461
+ }));
462
+ const done = await step(() => this.request('POST', completeUrl, { body: {}, signal: opts.signal }));
416
463
  return done.node;
417
464
  }
418
465
 
@@ -459,7 +506,11 @@ export class TroveApiClient {
459
506
  () => this.#uploadPart(plan, n, blob, {
460
507
  signal: inner.signal, onProgress: (l) => progress.set(n, l),
461
508
  }),
462
- { signal: inner.signal, retries: 4 },
509
+ {
510
+ signal: inner.signal,
511
+ retries: 4,
512
+ onRetry: ({ attempt, delayMs, error }) => opts.onRetry?.({ attempt, delayMs, part: n, message: error.message }),
513
+ },
463
514
  );
464
515
  results[n - 1] = { partNumber: n, etag };
465
516
  }
@@ -474,9 +525,9 @@ export class TroveApiClient {
474
525
  }
475
526
 
476
527
  const reportedParts = results.filter(Boolean);
477
- const done = await this.request('POST', completeUrl, {
528
+ const done = await step(() => this.request('POST', completeUrl, {
478
529
  body: { parts: reportedParts }, signal: opts.signal,
479
- });
530
+ }));
480
531
  return done.node;
481
532
  }
482
533
 
@@ -486,6 +537,7 @@ export class TroveApiClient {
486
537
  return tmpl.replace('{partNumber}', String(n));
487
538
  }
488
539
 
540
+
489
541
  async #uploadPart(plan, n, blob, { signal, onProgress }) {
490
542
  const t = plan.transfer || {};
491
543
  if (plan.strategy === 'presign') {
@@ -15,16 +15,30 @@ export class CommandService {
15
15
  this.contributions = contributions;
16
16
  this.context = context;
17
17
  this.notifications = notifications;
18
- this.handlers = new Map(); // id -> fn
18
+ this.handlers = new Map(); // id -> (...args) => Action | Action[] | null
19
+ // How a resolved command reaches the engine. Filled in by createApp, because this
20
+ // service is built with the rest of the platform and the engine does not exist yet.
21
+ //
22
+ // It is a seam rather than a smell: keystrokes and plugin RPC arrive from outside the
23
+ // engine and something has to carry them in. Until it is set, a command says so instead
24
+ // of silently doing nothing.
25
+ this.dispatch = () => {
26
+ throw new Error('CommandService has no dispatcher yet — see createApp');
27
+ };
19
28
  }
20
29
 
21
30
  /**
22
- * Register a command. Accepts either register(id, handler) or a full spec
23
- * object { id, title, handler, category, icon, when, palette }.
31
+ * Register a command. Accepts either register(id, actions) or a full spec object
32
+ * { id, title, actions, category, icon, when, palette }.
33
+ *
34
+ * `actions` is a PURE FACTORY: `(...args) => Action | Action[] | null`. It is not a
35
+ * handler, and the difference is the point — a handler did the work, which meant every
36
+ * menu item, keybinding and palette entry a person triggered went around the engine. A
37
+ * factory only says what should be dispatched, and `execute` dispatches it.
24
38
  */
25
- register(idOrSpec, handler) {
26
- const spec = typeof idOrSpec === 'string' ? { id: idOrSpec, handler } : idOrSpec;
27
- if (spec.handler) this.handlers.set(spec.id, spec.handler);
39
+ register(idOrSpec, actions) {
40
+ const spec = typeof idOrSpec === 'string' ? { id: idOrSpec, actions } : idOrSpec;
41
+ if (spec.actions) this.handlers.set(spec.id, spec.actions);
28
42
  const dispose = this.contributions.register(spec.id, {
29
43
  type: 'command',
30
44
  title: spec.title ?? spec.id,
@@ -58,9 +72,20 @@ export class CommandService {
58
72
  return this.availability ? this.availability(cmd) : true;
59
73
  }
60
74
 
75
+ /**
76
+ * Run a command: resolve what it means, then dispatch it.
77
+ *
78
+ * The gating stays here rather than moving into an action, because it is the same three
79
+ * questions for every command however it was triggered — does it exist, is the plugin
80
+ * behind it answering, does its when-clause hold — and answering them in one place is
81
+ * what keeps a keybinding and a palette entry behaving identically.
82
+ *
83
+ * What is NOT here any more is the work. `actions` returns descriptions and this
84
+ * dispatches them, so the engine sees the command AND everything it causes.
85
+ */
61
86
  async execute(id, ...args) {
62
- const handler = this.handlers.get(id);
63
- if (!handler) {
87
+ const actionsFor = this.handlers.get(id);
88
+ if (!actionsFor) {
64
89
  this.notifications.error(`Command not found: ${id}`);
65
90
  return;
66
91
  }
@@ -71,7 +96,22 @@ export class CommandService {
71
96
  }
72
97
  if (!this.isEnabled(id)) return; // gated by when-clause
73
98
  try {
74
- return await handler(...args);
99
+ const actions = actionsFor(...args);
100
+ if (!actions) return;
101
+ // One at a time, each genuinely finished before the next begins.
102
+ //
103
+ // `dispatch` answers an event FEED, not a promise for the work — awaiting it resolves
104
+ // immediately and the action runs afterwards, so a bare `await dispatch(a)` in a loop
105
+ // would start all of them at once while looking like it sequenced them. `next` on the
106
+ // terminal events is the actual completion signal.
107
+ //
108
+ // It resolves with whichever of complete/error/abort fired rather than rejecting, so
109
+ // a failing action does not throw here — but it does stop the rest, because a command
110
+ // whose first step failed rarely wants its second.
111
+ for (const action of [].concat(actions)) {
112
+ const settled = await this.dispatch(action).next(['complete', 'error', 'abort']);
113
+ if (settled?.type !== 'complete') break;
114
+ }
75
115
  } catch (err) {
76
116
  console.error(`Command ${id} failed`, err);
77
117
  this.notifications.error(err?.message || `Command failed: ${id}`);