@3sln/trove 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/adapters/bun.js +1 -2
  27. package/packages/server/src/adapters/node.js +1 -2
  28. package/packages/server/src/engine/index.js +1 -1
  29. package/packages/server/src/engine/providers/access.js +17 -34
  30. package/packages/server/src/engine/providers/core.js +112 -14
  31. package/packages/server/src/index.js +145 -103
  32. package/packages/server/src/mcp/index.js +7 -12
  33. package/packages/server/src/mcp/tools.js +24 -29
  34. package/packages/server/src/router.js +31 -8
  35. package/packages/server/src/routes.js +74 -113
  36. package/packages/server/src/scope.js +78 -0
  37. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  39. package/packages/web/dist/index.html +1 -1
  40. package/packages/web/dist/sw.js +1 -1
  41. package/packages/web/src/bl/actions.js +226 -104
  42. package/packages/web/src/bl/activity.js +32 -82
  43. package/packages/web/src/bl/index.js +23 -11
  44. package/packages/web/src/bl/launcher.js +2 -9
  45. package/packages/web/src/bl/links.js +31 -18
  46. package/packages/web/src/bl/offline.js +24 -10
  47. package/packages/web/src/bl/pluginInstall.js +29 -26
  48. package/packages/web/src/bl/queries.js +76 -68
  49. package/packages/web/src/bl/services.js +22 -14
  50. package/packages/web/src/bl/social.js +39 -25
  51. package/packages/web/src/bl/state.js +22 -4
  52. package/packages/web/src/bl/viewState.js +9 -0
  53. package/packages/web/src/bl/watchQuery.js +1 -8
  54. package/packages/web/src/dispatch.js +26 -0
  55. package/packages/web/src/platform/api.js +32 -0
  56. package/packages/web/src/platform/commands.js +14 -15
  57. package/packages/web/src/platform/context.js +3 -58
  58. package/packages/web/src/platform/contributions.js +1 -35
  59. package/packages/web/src/platform/fileChunks.js +281 -0
  60. package/packages/web/src/platform/index.js +8 -9
  61. package/packages/web/src/platform/mediaUrls.js +10 -0
  62. package/packages/web/src/platform/navigation.js +35 -21
  63. package/packages/web/src/platform/pickers.js +39 -19
  64. package/packages/web/src/platform/pluginHost.js +4 -1
  65. package/packages/web/src/platform/pluginRpc.js +47 -2
  66. package/packages/web/src/platform/spatialNav.js +1 -1
  67. package/packages/web/src/platform/viewport.js +14 -6
  68. package/packages/web/src/platform/voiceSearch.js +17 -9
  69. package/packages/web/src/platform/whenclause.js +9 -19
  70. package/packages/web/src/runtime.js +2 -37
  71. package/packages/web/src/ui/activate.js +18 -2
  72. package/packages/web/src/ui/components/activityPanel.js +1 -1
  73. package/packages/web/src/ui/components/adminView.js +4 -7
  74. package/packages/web/src/ui/components/editorArea.js +10 -1
  75. package/packages/web/src/ui/components/launcher.js +29 -13
  76. package/packages/web/src/ui/components/openers/index.js +17 -22
  77. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  78. package/packages/web/src/ui/components/overlays.js +31 -5
  79. package/packages/web/src/ui/components/pluginReview.js +7 -4
  80. package/packages/web/src/ui/components/pluginsView.js +1 -1
  81. package/packages/web/src/ui/components/settingsView.js +30 -27
  82. package/packages/web/src/ui/components/statusBar.js +8 -6
  83. package/packages/web/src/ui/components/views/grid.js +2 -2
  84. package/packages/web/src/ui/components/views/parts.js +1 -1
  85. package/packages/web/src/ui/compositions/workbench.js +20 -6
  86. package/packages/web/src/ui/sanitize.js +0 -5
  87. package/packages/web/src/workbench.js +37 -19
  88. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  89. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -119,16 +119,15 @@ export class Vfs {
119
119
  this.uploads = new UploadManager({
120
120
  storageFor: (cid) => this.storageFor(cid),
121
121
  sessions: uploadSessions,
122
- // What a collection encrypts and the key for it. Only the CollectionService knows,
123
- // and only it is allowed to hand the key out — see collections/index.js.
124
- encryptionFor: async (cid) => {
125
- if (!this.collections?.encryptionFor) return null;
126
- const encryption = await this.collections.encryptionFor(cid);
127
- if (!encryption?.enabled) return null;
128
- const key = await this.collections.dataKeyFor(cid);
129
- if (!key) return null;
130
- return { encryption, dataKeyHex: toHex(key) };
131
- },
122
+ // ONE implementation of "does this item get sealed, and with what" — the same method
123
+ // `writeFile` uses, so a collection with per-item rules answers identically whichever
124
+ // way the bytes arrive, and both paths fail CLOSED when the key is missing.
125
+ sealingFor: (cid, name, contentType) => this.#sealingFor(cid, name, contentType),
126
+ // The key a fingerprint names, so an upload seals its later parts with the key it
127
+ // decided on rather than with whatever became current meanwhile. Only the
128
+ // CollectionService knows it, and only it is allowed to hand it out.
129
+ keyFor: async (cid, fingerprint) =>
130
+ (this.collections?.dataKeyFor ? this.collections.dataKeyFor(cid, fingerprint) : null),
132
131
  maxBytes: maxUploadBytes,
133
132
  partSize: uploadPartSize,
134
133
  });
@@ -177,7 +176,12 @@ export class Vfs {
177
176
  if (!shouldEncrypt(encryption, { name, contentType })) return null;
178
177
  const key = await this.collections.dataKeyFor(collectionId);
179
178
  if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
180
- return { key, fingerprint: fromHex(encryption.fingerprint), chunkSize: encryption.chunkSize || DEFAULT_CHUNK_SIZE };
179
+ // `DEFAULT_CHUNK_SIZE`, not `encryption.chunkSize`. A collection cannot choose one:
180
+ // `normalizeEncryption` emits exactly `{ enabled, fingerprint, rules }` and every
181
+ // writer of `c.encryption` goes through it, so the field was structurally always
182
+ // undefined and the `||` read as a knob that could be turned. The per-OBJECT chunk
183
+ // size is real and separate — the envelope writes it, and reads take it from there.
184
+ return { key, fingerprint: fromHex(encryption.fingerprint), chunkSize: DEFAULT_CHUNK_SIZE };
181
185
  }
182
186
 
183
187
  /** Resolve the storage backend for a collection. */
@@ -476,8 +480,11 @@ export class Vfs {
476
480
  async #note(kind, subject, title, err) {
477
481
  try {
478
482
  await this.issues?.raise({
483
+ // No `retryable`: `raise` builds a fixed field set and never reads one, and the
484
+ // route recomputes it from whether a handler is registered for `retry.op`. A field
485
+ // that is written, ignored and then contradicted is worse than no field.
479
486
  kind, subject, severity: 'warning', title,
480
- detail: err?.message || String(err), retryable: false,
487
+ detail: err?.message || String(err),
481
488
  });
482
489
  } catch { /* the issue registry is itself best-effort here */ }
483
490
  }
@@ -536,25 +543,25 @@ export class Vfs {
536
543
 
537
544
  // --- download --------------------------------------------------------------
538
545
 
539
- async getDownload(id, { expiresIn, download, ciphertext = false } = {}) {
546
+ /**
547
+ * May the download route hand this object straight to the store?
548
+ *
549
+ * A PREDICATE rather than a second download implementation. `getDownload` used to
550
+ * encode the presign-or-proxy rule here while `mintUrl` encoded it two hundred lines
551
+ * away, and they disagreed: mintUrl presigned whenever the store COULD and never asked
552
+ * whether the object was sealed, so on any encrypted collection with a presigning store
553
+ * every thumbnail, preview and externally-handed URL pointed at CIPHERTEXT. That
554
+ * incident report is server/test/mint-url-encryption.test.js.
555
+ *
556
+ * An encrypted object is never redirected to. A redirect hands the caller raw ciphertext
557
+ * and most callers of a download URL can do nothing with it — an `<img src>`, a `<video
558
+ * src>`, a signed URL given to an external service. Proxying is the answer that is always
559
+ * correct.
560
+ */
561
+ async canRedirect(id) {
540
562
  const node = await this.resolve(id);
541
- const storage = await this.storageFor(node.collectionId);
542
- // An encrypted object is not redirected to by default. A redirect hands the caller raw
543
- // ciphertext, and most callers of a download URL cannot do anything with it — an <img
544
- // src>, a <video src>, a signed URL given to an external service. Proxying is the
545
- // answer that is always correct, so it is the default; a client that holds the key and
546
- // knows it can decrypt asks for `ciphertext` and gets the direct path back.
547
- if (node.encryption && !ciphertext) return { mode: 'proxy', node };
548
- if (storage.capabilities.presignDownload) {
549
- const url = await storage.presignGet(node.storageKey, {
550
- expiresIn, responseContentType: node.contentType,
551
- downloadName: download ? node.name : undefined,
552
- });
553
- // Named on the way out so a client that asked for ciphertext knows which key opens
554
- // what it is about to receive.
555
- return { mode: 'redirect', url, node, encryption: node.encryption || null };
556
- }
557
- return { mode: 'proxy', node };
563
+ if (node.encryption) return false;
564
+ return !!(await this.storageFor(node.collectionId)).capabilities?.presignDownload;
558
565
  }
559
566
 
560
567
  /**
@@ -576,8 +583,9 @@ export class Vfs {
576
583
  const node = await this.resolve(id);
577
584
  if (!node.storageKey) throw TroveError.notFound('File content');
578
585
  const storage = await this.storageFor(node.collectionId);
579
- // An ENCRYPTED object is never presigned to the store the same rule `getDownload`
580
- // already follows, and the one place it was missing. A bucket URL serves CIPHERTEXT,
586
+ // An ENCRYPTED object is never presigned to the store. This is now the ONE place that
587
+ // rule is written `getDownload` held a second copy and this was the copy that was
588
+ // missing the guard, which is how it shipped. A bucket URL serves CIPHERTEXT,
581
589
  // and everything a minted URL exists for (an <img src>, a <video src>, cache.add(), a
582
590
  // URL handed to an external service) fetches bytes with nowhere to run decryption. So
583
591
  // every thumbnail and preview in an encrypted collection on a presigning store pointed
@@ -611,8 +619,25 @@ export class Vfs {
611
619
  return { url: `${base}/api/items/download?${q}`, expiresAt: g.expiresAt, node, signed: 'trove' };
612
620
  }
613
621
 
614
- async readStream(id, { range, signal } = {}) {
615
- const node = await this.resolve(id);
622
+ async readStream(id, opts = {}) {
623
+ return this.readNode(await this.resolve(id), opts);
624
+ }
625
+
626
+ /**
627
+ * Read a node the caller has ALREADY resolved.
628
+ *
629
+ * The trash guard belongs to resolution, not to reading: `resolve` refuses a trashed
630
+ * item so a deleted file cannot answer a download-by-id, and that is the whole of the
631
+ * rule. Reading is mechanical — a storage key, an envelope, a key ring.
632
+ *
633
+ * Key rotation is the caller that needs the distinction. A trashed object keeps its
634
+ * bytes and is still sealed with whatever key sealed it, so the rotation must move it
635
+ * before that key can be retired; it reaches those nodes through `listSealed`, which
636
+ * spans the trash on purpose. Going around this and reading storage directly would put
637
+ * a second copy of the envelope/range/key-ring logic in the rotation, which is the one
638
+ * thing this file has spent the most comments arguing against.
639
+ */
640
+ async readNode(node, { range, signal } = {}) {
616
641
  if (!node.storageKey) throw TroveError.notFound('File content');
617
642
  const storage = await this.storageFor(node.collectionId);
618
643
  if (!node.encryption) return storage.get(node.storageKey, { range, signal });
@@ -42,6 +42,116 @@
42
42
  }
43
43
  const emit = (method, params) => port.postMessage({ __trove: 'event', method, params });
44
44
 
45
+ /**
46
+ * A file's bytes, addressable without holding them.
47
+ *
48
+ * `Blob` is already the browser's interface for exactly that — `slice()` is free,
49
+ * `stream()` is a reader, and everything that eats bytes eats a Blob — so a range reader
50
+ * wears the interface that exists rather than inventing a parallel vocabulary.
51
+ *
52
+ * THE SHARP EDGE, and it is sharp: a Blob SUBCLASS only overrides what JavaScript calls.
53
+ * Anything reading the blob's internal bytes — `URL.createObjectURL`, `new
54
+ * Response(blob)`, `fetch(url, {body})`, and structured clone through `postMessage` —
55
+ * bypasses every override here and sees the empty blob passed to `super()`. Two
56
+ * consequences the design is built around:
57
+ *
58
+ * 1. A RemoteBlob cannot be posted INTO this frame from the host; it would arrive as a
59
+ * plain, empty Blob. It is constructed here, in the frame that uses it.
60
+ * 2. `local()` is the escape hatch for all of them. A realized Blob really does hold
61
+ * its bytes, so it works with `createObjectURL` — which is the download-then-play
62
+ * path for anything that cannot be streamed.
63
+ */
64
+ class RemoteBlob extends Blob {
65
+ constructor(id, { size = 0, type = '', etag = null, start = 0, end = null } = {}) {
66
+ super();
67
+ this.id = id;
68
+ this.type = type;
69
+ this.etag = etag;
70
+ // A window on the source. `size` is this window's length, which is what makes
71
+ // `slice()` of a slice behave the way a caller expects.
72
+ this._start = start;
73
+ this._end = end == null ? size : end;
74
+ }
75
+
76
+ get size() { return Math.max(0, this._end - this._start); }
77
+
78
+ /**
79
+ * A window on the same source. No bytes move and none need to exist yet.
80
+ *
81
+ * Negative indices count from the end, as `Blob.slice` does — which is what makes
82
+ * "the last 64 KiB" expressible, and reading the tail of a file is half of what a
83
+ * container parser does.
84
+ */
85
+ slice(begin = 0, finish = this.size, type = this.type) {
86
+ const len = this.size;
87
+ const from = begin < 0 ? Math.max(0, len + begin) : Math.min(begin, len);
88
+ const to = finish < 0 ? Math.max(0, len + finish) : Math.min(finish, len);
89
+ const win = new RemoteBlob(this.id, {
90
+ type, etag: this.etag,
91
+ start: this._start + from,
92
+ end: this._start + Math.max(from, to),
93
+ });
94
+ return win;
95
+ }
96
+
97
+ /**
98
+ * The bytes of this window.
99
+ *
100
+ * A `signal` is checked BEFORE the call and not during it: an in-flight request over
101
+ * the port cannot be recalled, so the honest granularity is per read. `chunks()` is
102
+ * where cancelling actually bites, because there the reads are small and there are
103
+ * many of them.
104
+ */
105
+ async bytes({ signal } = {}) {
106
+ if (signal && signal.aborted) throw new Error('Aborted');
107
+ const r = await call('files:bytes', { id: this.id, start: this._start, end: this._end });
108
+ // Every read refreshes the etag, because a file overwritten in place keeps its id
109
+ // and anything cached off these bytes has to notice.
110
+ if (r.etag) this.etag = r.etag;
111
+ return new Uint8Array(r.bytes);
112
+ }
113
+ async arrayBuffer() { return (await this.bytes()).buffer; }
114
+ async text() { return new TextDecoder().decode(await this.bytes()); }
115
+
116
+ /** One window at a time, so a caller can walk a large file without holding it. */
117
+ async *chunks({ size = 4 * 1024 * 1024, signal } = {}) {
118
+ for (let at = 0; at < this.size; at += size) {
119
+ yield this.slice(at, Math.min(at + size, this.size)).bytes({ signal });
120
+ }
121
+ }
122
+
123
+ stream() {
124
+ const iter = this.chunks();
125
+ return new ReadableStream({
126
+ async pull(controller) {
127
+ const { value, done } = await iter.next();
128
+ if (done) controller.close();
129
+ else controller.enqueue(value);
130
+ },
131
+ });
132
+ }
133
+
134
+ /**
135
+ * REALIZE the bytes into an ordinary Blob.
136
+ *
137
+ * The escape hatch named above, and the download half of "this book cannot be
138
+ * streamed, here is a Download button". `onProgress` fires once with `loaded: 0`
139
+ * BEFORE the first chunk, so a bar appears at 0% instead of jumping in partway.
140
+ */
141
+ async local({ onProgress, signal, chunkSize = 4 * 1024 * 1024 } = {}) {
142
+ const total = this.size;
143
+ const parts = [];
144
+ let loaded = 0;
145
+ if (onProgress) onProgress({ loaded: 0, total, ratio: 0 });
146
+ for await (const chunk of this.chunks({ size: chunkSize, signal })) {
147
+ parts.push(chunk);
148
+ loaded += chunk.length;
149
+ if (onProgress) onProgress({ loaded, total, ratio: total ? loaded / total : 0 });
150
+ }
151
+ return new Blob(parts, { type: this.type });
152
+ }
153
+ }
154
+
45
155
  // What this frame reports about itself on every heartbeat. Contributions are the
46
156
  // host's own manifest reading — all the plugin can usefully say is which of its
47
157
  // declared contributions it actually bound a handler to, plus whether it thinks
@@ -259,9 +369,54 @@
259
369
  list: (pathOrId, opts) => (requireCap('files'), call('files:list', Object.assign({ pathOrId }, opts))),
260
370
  stat: (id) => (requireCap('files'), call('files:stat', { id })),
261
371
  downloadUrl: (id) => (requireCap('files'), call('files:downloadUrl', { id })),
372
+
373
+ /**
374
+ * A file's bytes as a Blob you can slice, stream and realize — see RemoteBlob.
375
+ *
376
+ * `stat` first, because a Blob has to know its own size before `slice` means
377
+ * anything. One round trip, and every later read is a range.
378
+ */
379
+ async blob(id) {
380
+ requireCap('files');
381
+ const { node } = await call('files:stat', { id });
382
+ return new RemoteBlob(id, { size: node.size || 0, type: node.contentType || '', etag: node.etag || null });
383
+ },
384
+
385
+ /**
386
+ * A URL a media element can load by itself, for streaming.
387
+ *
388
+ * It is MINTED — it carries its own grant and expires — which is the one place a
389
+ * host URL deliberately reaches a plugin. `<audio src>` is the only way to play a
390
+ * progressive MP4 without a fragmenter: MSE refuses one, and a Blob has to be
391
+ * whole before it can become an object URL. Use `blob(id)` for the parsing (a
392
+ * container's chapters are a few kilobytes out of a few hundred megabytes) and
393
+ * this for the playing.
394
+ */
395
+ mediaUrl: (id, opts) => (requireCap('files'), call('files:mediaUrl', Object.assign({ id }, opts))),
396
+
397
+ /**
398
+ * Keeping a file, which is a DIFFERENT act from reading one.
399
+ *
400
+ * Ranging over a file stores nothing. `start(id)` is someone asking to have it
401
+ * offline, and from then on every chunk a read fetches is kept and the background
402
+ * filler skips it — so a book listened straight through downloads itself exactly
403
+ * once, and a book skipped around in fills its gaps.
404
+ */
405
+ offline: {
406
+ start: (id) => (requireCap('files'), call('files:offline:start', { id })),
407
+ status: (id) => (requireCap('files'), call('files:offline:status', { id })),
408
+ cancel: (id) => (requireCap('files'), call('files:offline:cancel', { id })),
409
+ remove: (id) => (requireCap('files'), call('files:offline:remove', { id })),
410
+ },
262
411
  // index(indexerId, nodeId, contribution) where contribution is
263
412
  // { semanticTexts?, tags?, metadata? }. Legacy (indexerId, nodeId, documents[], facet)
264
413
  // is still accepted when the 3rd arg is an array of documents.
414
+ //
415
+ // "Contribution" here is per-node ENRICHMENT — what an indexer says ABOUT a file,
416
+ // addressed by contributorId. It is a different noun from the `contributes` map in
417
+ // your manifest, which declares extension points addressed by URI. A plugin
418
+ // declares an `indexer` contribution (that sense) and it produces contributions
419
+ // (this sense).
265
420
  index: (indexerId, nodeId, contribution, facet) => {
266
421
  requireCap('indexer');
267
422
  var payload = Array.isArray(contribution) ? { documents: contribution, facet: facet } : (contribution || {});
@@ -41,7 +41,19 @@ export const METHODS = {
41
41
  // its `commands` allowlist) to run someone else's. The direction disambiguates.
42
42
  command: { execute: 'command:execute' },
43
43
  resources: { list: 'resources:list', read: 'resources:read' },
44
- files: { read: 'files:read', list: 'files:list', stat: 'files:stat', downloadUrl: 'files:downloadUrl', index: 'files:index' },
44
+ files: {
45
+ read: 'files:read', list: 'files:list', stat: 'files:stat', downloadUrl: 'files:downloadUrl',
46
+ index: 'files:index',
47
+ // Bytes by range — the only way a viewer gets binary content at all, since `read`
48
+ // answers text and a sandboxed frame cannot authenticate a bare download URL.
49
+ bytes: 'files:bytes',
50
+ // A minted URL for a media element. See pluginRpc.js for why this one host URL crosses.
51
+ mediaUrl: 'files:mediaUrl',
52
+ offline: {
53
+ start: 'files:offline:start', status: 'files:offline:status',
54
+ cancel: 'files:offline:cancel', remove: 'files:offline:remove',
55
+ },
56
+ },
45
57
  net: { fetch: 'net:fetch' },
46
58
  storage: { sql: 'storage:sql' },
47
59
  settings: { get: 'settings:get', set: 'settings:set', getSecret: 'settings:getSecret' },
@@ -9,7 +9,7 @@
9
9
  // bun packages/server/src/adapters/bun.js
10
10
 
11
11
  import { readFileSync } from 'node:fs';
12
- import { createServer, configFromEnv, warnOnOpenAccess } from '../index.js';
12
+ import { createServer, configFromEnv } from '../index.js';
13
13
  // This runtime HAS a filesystem, so it registers the filesystem driver. Imported from
14
14
  // storage/filesystem.js rather than the package barrel: that import is what pulls in
15
15
  // node:fs, and the Workers adapter deliberately never makes it — so there, Filesystem is
@@ -49,7 +49,6 @@ const staticAssets = WEB_DIST && createStaticAssets({
49
49
 
50
50
  const hasWeb = !!WEB_DIST;
51
51
  const envConfig = configFromEnv();
52
- warnOnOpenAccess(envConfig);
53
52
  const { handle, close } = await createServer({
54
53
  ...envConfig,
55
54
  storageDrivers: [filesystemDriver()],
@@ -11,7 +11,7 @@ import http from 'node:http';
11
11
  import fs, { readFileSync } from 'node:fs';
12
12
  import fsp from 'node:fs/promises';
13
13
  import { Readable } from 'node:stream';
14
- import { createServer, configFromEnv, warnOnOpenAccess } from '../index.js';
14
+ import { createServer, configFromEnv } from '../index.js';
15
15
  // This runtime HAS a filesystem, so it registers the filesystem driver. Imported from
16
16
  // storage/filesystem.js rather than the package barrel: that import is what pulls in
17
17
  // node:fs, and the Workers adapter deliberately never makes it — so there, Filesystem is
@@ -84,7 +84,6 @@ async function writeWebResponse(res, webRes) {
84
84
 
85
85
  const hasWeb = !!WEB_DIST;
86
86
  const envConfig = configFromEnv();
87
- warnOnOpenAccess(envConfig);
88
87
  const { handle, close } = await createServer({
89
88
  ...envConfig,
90
89
  storageDrivers: [filesystemDriver()],
@@ -49,7 +49,7 @@ export function createDriveEngine(config = {}, lifecycleState = { closing: false
49
49
  export const BACKBONE = [
50
50
  'storage', 'sqlite', 'metadata', 'kv', 'tasks', 'issues', 'notifications',
51
51
  'sidecar', 'collections', 'identity', 'auth', 'search', 'vfs', 'plugins',
52
- 'apiKeys', 'capabilities', 'rotation', 'lifecycle',
52
+ 'apiKeys', 'capabilities', 'rotation', 'lifecycle', 'storageCheck', 'rateLimiter',
53
53
  ];
54
54
 
55
55
  /** The shape `beginScan` has always returned, so no caller has to change. */
@@ -23,15 +23,6 @@
23
23
  import { Provider } from '@3sln/ngin';
24
24
  import { TroveError } from '@3sln/trove/core';
25
25
 
26
- /**
27
- * The one place that decides whether this deployment enforces ACLs at all.
28
- *
29
- * From configuration, never from whether a service is present — those agree when
30
- * everything is wired correctly and diverge exactly when it is not, and the
31
- * second stops enforcing at the worst possible moment.
32
- */
33
- const enforcing = (config) => config?.collections !== false;
34
-
35
26
  /** Conversations are optional; a drive without them says so rather than crashing. */
36
27
  function requireSidecar(sidecar) {
37
28
  if (!sidecar) throw TroveError.unsupported('Conversations are not enabled on this server');
@@ -120,7 +111,10 @@ function nodeHandle(vfs, sidecar, node, held) {
120
111
  // much its content as its bytes are.
121
112
  if (permits('read')) {
122
113
  handle.read = (opts) => vfs.readStream(node.id, opts);
123
- handle.download = (opts) => vfs.getDownload(node.id, opts);
114
+ // Two calls, deliberately: "may we redirect" and "mint the URL". `mintUrl` is where
115
+ // the expiry clamp and the encrypted-never-presigns rule live, so a download URL and
116
+ // an <img src> URL are produced by the same code rather than by two that agreed once.
117
+ handle.canRedirect = () => vfs.canRedirect(node.id);
124
118
  // Minting a URL that carries its own grant is an exercise of `read` — you are
125
119
  // delegating the read you hold, to something that cannot present credentials. So it
126
120
  // hangs off the read handle like everything else, and a caller without `read`
@@ -206,14 +200,13 @@ function collectionHandle(vfs, collectionId, held) {
206
200
  * never runs — when the node is missing or the capability is not held.
207
201
  */
208
202
  export class NodeAccessProvider extends Provider {
209
- static deps = ['vfs', 'sidecar', 'collections', 'config', 'signedUrls'];
203
+ static deps = ['vfs', 'sidecar', 'collections', 'signedUrls'];
210
204
 
211
- constructor({ vfs, sidecar, collections, config, signedUrls }) {
205
+ constructor({ vfs, sidecar, collections, signedUrls }) {
212
206
  super();
213
207
  this.vfs = vfs;
214
208
  this.sidecar = sidecar;
215
209
  this.collections = collections;
216
- this.config = config;
217
210
  this.signedUrls = signedUrls;
218
211
  }
219
212
 
@@ -256,9 +249,6 @@ export class NodeAccessProvider extends Provider {
256
249
  return nodeHandle(vfs, sidecar, node, new Set(['read']));
257
250
  }
258
251
 
259
- const config = await this.config.obtain();
260
- if (!enforcing(config)) return nodeHandle(vfs, sidecar, node, requested(capability));
261
-
262
252
  // A key IS the grant, scoped to the node's own collection. Checked before the ACL
263
253
  // and never alongside it: a request bearing a key is the key's request, and falling
264
254
  // back to whatever principal happens to be attached would let a weak key borrow a
@@ -290,20 +280,17 @@ export class NodeAccessProvider extends Provider {
290
280
  * usage, starting an upload.
291
281
  */
292
282
  export class CollectionAccessProvider extends Provider {
293
- static deps = ['vfs', 'collections', 'config'];
283
+ static deps = ['vfs', 'collections'];
294
284
 
295
- constructor({ vfs, collections, config }) {
285
+ constructor({ vfs, collections }) {
296
286
  super();
297
287
  this.vfs = vfs;
298
288
  this.collections = collections;
299
- this.config = config;
300
289
  }
301
290
 
302
291
  async obtain({ principal, grant = null, id = 'default', capability = 'read' } = {}) {
303
292
  assertCapability(capability);
304
293
  const vfs = await this.vfs.obtain();
305
- const config = await this.config.obtain();
306
- if (!enforcing(config)) return collectionHandle(vfs, id, requested(capability));
307
294
 
308
295
  // Same rule as the node path: a key's grant decides, alone, and refuses rather than
309
296
  // narrowing. This is the check that keeps a key scoped to `photos` out of `invoices`.
@@ -333,13 +320,12 @@ export class CollectionAccessProvider extends Provider {
333
320
  * grant was thrown away in every one. Here the session IS the handle.
334
321
  */
335
322
  export class UploadAccessProvider extends Provider {
336
- static deps = ['vfs', 'collections', 'config'];
323
+ static deps = ['vfs', 'collections'];
337
324
 
338
- constructor({ vfs, collections, config }) {
325
+ constructor({ vfs, collections }) {
339
326
  super();
340
327
  this.vfs = vfs;
341
328
  this.collections = collections;
342
- this.config = config;
343
329
  }
344
330
 
345
331
  async obtain({ principal, grant = null, id } = {}) {
@@ -348,16 +334,13 @@ export class UploadAccessProvider extends Provider {
348
334
  // Resolving first is what makes the check possible at all: only the session knows
349
335
  // which collection the bytes are destined for.
350
336
  const session = await vfs.uploadStatus(id);
351
- const config = await this.config.obtain();
352
- if (enforcing(config)) {
353
- // Re-checked on EVERY request of the upload, keys included the point of this
354
- // provider. A key revoked between `POST /api/uploads` and `complete` stops the
355
- // upload, which it would not if the grant were only checked when it began.
356
- if (grant) grantedCapabilities(grant, session.collectionId, 'write');
357
- else {
358
- const collections = await this.collections.obtain();
359
- await collections.assert(principal, session.collectionId, 'write');
360
- }
337
+ // Re-checked on EVERY request of the upload, keys included — the point of this
338
+ // provider. A key revoked between `POST /api/uploads` and `complete` stops the
339
+ // upload, which it would not if the grant were only checked when it began.
340
+ if (grant) grantedCapabilities(grant, session.collectionId, 'write');
341
+ else {
342
+ const collections = await this.collections.obtain();
343
+ await collections.assert(principal, session.collectionId, 'write');
361
344
  }
362
345
  return {
363
346
  id,
@@ -21,7 +21,7 @@
21
21
  // means the same thing it always did.
22
22
 
23
23
  import {
24
- StorageBackend, MemoryStorage, S3Storage,
24
+ StorageBackend, MemoryStorage,
25
25
  StorageDriverRegistry, portableDrivers,
26
26
  MetadataStore, MemoryStore, SqliteStore,
27
27
  SearchService, EmbeddingProvider, LocalHashEmbedding, HttpEmbedding,
@@ -44,6 +44,8 @@ import {
44
44
  Vfs, TroveError,
45
45
  resolveAuthDiscovery,
46
46
  SignedUrls, resolveUrlSecret,
47
+ diagnoseStorage, STORAGE_ISSUE_CODES,
48
+ RateLimiter, MemoryRateStore, KvRateStore, DEFAULT_RATE_LIMITS,
47
49
  } from '@3sln/trove/core';
48
50
  import { Provider } from '@3sln/ngin';
49
51
  import { need } from '../lazy.js';
@@ -171,6 +173,33 @@ export function coreProviders(config, lifecycleState) {
171
173
  // reads it has to say so.
172
174
  config: Provider.fromSingleton(config),
173
175
 
176
+ /**
177
+ * What one caller may cost, per class of work.
178
+ *
179
+ * A provider so the route table can lease it like anything else, and so the store is a
180
+ * decision made once from configuration rather than at each call site. Null when
181
+ * limiting is switched off, which is the one case a caller has to handle — and it does
182
+ * so by not asking, since `Router.handle` only enforces for routes that named a class.
183
+ */
184
+ rateLimiter: Provider.fromLazySingleton(
185
+ async (deps) => {
186
+ // ON unless switched off, including for a library caller who built a config by
187
+ // hand rather than through `configFromEnv`. A limit that only exists when somebody
188
+ // remembers to ask for it is not a limit.
189
+ const rl = config.rateLimit ?? { enabled: true, store: 'memory', limits: DEFAULT_RATE_LIMITS };
190
+ if (!rl.enabled) return null;
191
+ // KV counters are shared across instances and cost a read and a write per limited
192
+ // request; memory counters are free and exact on ONE long-lived process. See
193
+ // rateLimit.js — the choice is a property of the deployment, not of the code.
194
+ const store = rl.store === 'kv'
195
+ ? new KvRateStore({ kv: (await need(deps, ['kv'])).kv })
196
+ : new MemoryRateStore();
197
+ return new RateLimiter({ store, limits: rl.limits || DEFAULT_RATE_LIMITS });
198
+ },
199
+ null,
200
+ { deps: ['kv'] },
201
+ ),
202
+
174
203
  // Shutdown, likewise. Long work has to be able to ask whether the server is
175
204
  // going down, and closing over a `let` made that invisible.
176
205
  lifecycle: Provider.fromSingleton({
@@ -198,12 +227,83 @@ export function coreProviders(config, lifecycleState) {
198
227
  beginReindex: (opts) => lifecycleState.background.beginReindex(opts),
199
228
  }),
200
229
 
201
- // The storage self-check, late-bound for the same reason: it needs `collections` and
202
- // `issues` from this container, so it is assembled in createServer and reached back
203
- // into rather than built here.
204
- storageCheck: Provider.fromSingleton({
205
- run: (opts) => lifecycleState.storageCheck(opts),
206
- }),
230
+ /**
231
+ * The storage self-check.
232
+ *
233
+ * The failure this exists for: a bucket with no CORS policy serves the SERVER fine and
234
+ * serves the browser nothing, so the drive looks healthy and every file opens to a
235
+ * spinner. See core/storage/diagnose.js for why the check has to be a real preflight.
236
+ *
237
+ * `origin` is the browser origin to check the policy against, and there is no guessing
238
+ * it: a policy may legitimately name one origin, so checking the wrong one would invent
239
+ * a problem. A request supplies its own; a cron firing has only `config.publicUrl`, and
240
+ * without either the CORS half is skipped rather than assumed.
241
+ *
242
+ * A PROVIDER, not a seam stamped onto `lifecycleState` after construction. The stated
243
+ * reason for that shape was "it needs `collections` and `issues` from this container",
244
+ * which is exactly what `fromLazySingleton` with deps is for — and unlike
245
+ * `backgroundWork`, whose comment names a real circularity (dispatching needs the
246
+ * engine that owns the container), there is none here.
247
+ */
248
+ storageCheck: Provider.fromLazySingleton(
249
+ async (deps) => {
250
+ const { collections, issues, config: cfg } = await need(deps, ['collections', 'issues', 'config']);
251
+ const KIND = 'storage';
252
+ return {
253
+ async run({ origin = null } = {}) {
254
+ // `all()`, not `list(null)`: this has no user, and asking what the anonymous
255
+ // principal may read means checking nothing at all on a drive that is not public.
256
+ const list = await collections.all().catch(() => []);
257
+ const results = [];
258
+ for (const c of list) {
259
+ let findings;
260
+ try {
261
+ const storage = await collections.storageFor(c.id);
262
+ findings = await diagnoseStorage({
263
+ storage, origin, driver: c.store?.driver || null, fetchImpl: cfg.fetch,
264
+ });
265
+ } catch (err) {
266
+ // Failing to BUILD the store is itself the most severe version of
267
+ // unreachable — an unknown driver, or a config missing a required field,
268
+ // never gets far enough to be asked whether it can be read.
269
+ findings = [{
270
+ code: 'storage-unreachable',
271
+ severity: 'error',
272
+ title: 'This collection\u2019s store could not be opened',
273
+ detail: err?.message || String(err),
274
+ }];
275
+ }
276
+ const found = new Set(findings.map((f) => f.code));
277
+ for (const f of findings) {
278
+ await issues.raise({
279
+ kind: KIND,
280
+ subject: `${c.id}:${f.code}`,
281
+ title: `${c.name || c.id}: ${f.title}`,
282
+ detail: f.detail,
283
+ remedy: f.remedy || null,
284
+ severity: f.severity,
285
+ collectionId: c.id,
286
+ // Re-running the check IS the fix verification, so Retry rechecks against
287
+ // the same origin the finding was made for. Checking a different one
288
+ // would report a pass for a policy the affected browser still cannot use.
289
+ retry: { op: 'storage-check', origin },
290
+ });
291
+ }
292
+ // Whatever is no longer true stops being listed. Without this, fixing the
293
+ // bucket leaves the warning up, and a problem list that outlives its problems
294
+ // is one people learn to scroll past.
295
+ for (const code of STORAGE_ISSUE_CODES) {
296
+ if (!found.has(code)) await issues.clear(KIND, `${c.id}:${code}`);
297
+ }
298
+ results.push({ collectionId: c.id, name: c.name || c.id, findings });
299
+ }
300
+ return { checked: results.length, corsChecked: !!origin, results };
301
+ },
302
+ };
303
+ },
304
+ null,
305
+ { deps: ['collections', 'issues', 'config'] },
306
+ ),
207
307
 
208
308
  storage: Provider.fromLazySingleton(
209
309
  () => resolve(config.storage ?? config.vfs?.storage, StorageBackend, (cfg) => buildStorage(cfg, config)),
@@ -525,13 +625,11 @@ export function coreProviders(config, lifecycleState) {
525
625
  indexers: r.indexerRuntime
526
626
  ? new PluginIndexers({ vfs: r.vfs, runtime: r.indexerRuntime, packages: r.packageStore })
527
627
  : null,
528
- // Who may install a plugin. With no ACL layer configured there is no admin
529
- // list to consult and any authenticated caller qualifies decided from
530
- // config, not from whether `collections` happens to be here, so a graph that
531
- // failed to build cannot silently promote everyone.
532
- isAdmin: (principal) => (config.collections === false
533
- ? !!principal
534
- : r.collections.isAdmin(principal)),
628
+ // Who may install a plugin. There is no "any authenticated caller qualifies"
629
+ // fallback: the arm that provided one required `collections: false`, which this
630
+ // provider refuses above and `configFromEnv` refuses again, so it could only ever
631
+ // have fired for a direct-container caller — where it handed out an open drive.
632
+ isAdmin: (principal) => r.collections.isAdmin(principal),
535
633
  maxPackageBytes: config.maxUploadBytes ?? undefined,
536
634
  strict: config.enforcePluginCaps === true,
537
635
  });