@3sln/trove 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/access/externalEvaluation.js +17 -0
  27. package/packages/server/src/adapters/bun.js +1 -2
  28. package/packages/server/src/adapters/node.js +1 -2
  29. package/packages/server/src/engine/index.js +1 -1
  30. package/packages/server/src/engine/providers/access.js +17 -34
  31. package/packages/server/src/engine/providers/core.js +112 -14
  32. package/packages/server/src/index.js +160 -105
  33. package/packages/server/src/mcp/index.js +7 -12
  34. package/packages/server/src/mcp/tools.js +24 -29
  35. package/packages/server/src/router.js +31 -8
  36. package/packages/server/src/routes.js +74 -113
  37. package/packages/server/src/scope.js +78 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  39. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  40. package/packages/web/dist/index.html +1 -1
  41. package/packages/web/dist/sw.js +1 -1
  42. package/packages/web/src/bl/actions.js +226 -104
  43. package/packages/web/src/bl/activity.js +32 -82
  44. package/packages/web/src/bl/index.js +23 -11
  45. package/packages/web/src/bl/launcher.js +2 -9
  46. package/packages/web/src/bl/links.js +31 -18
  47. package/packages/web/src/bl/offline.js +24 -10
  48. package/packages/web/src/bl/pluginInstall.js +29 -26
  49. package/packages/web/src/bl/queries.js +76 -68
  50. package/packages/web/src/bl/services.js +22 -14
  51. package/packages/web/src/bl/social.js +39 -25
  52. package/packages/web/src/bl/state.js +22 -4
  53. package/packages/web/src/bl/viewState.js +9 -0
  54. package/packages/web/src/bl/watchQuery.js +1 -8
  55. package/packages/web/src/dispatch.js +26 -0
  56. package/packages/web/src/platform/api.js +32 -0
  57. package/packages/web/src/platform/commands.js +14 -15
  58. package/packages/web/src/platform/context.js +3 -58
  59. package/packages/web/src/platform/contributions.js +1 -35
  60. package/packages/web/src/platform/fileChunks.js +281 -0
  61. package/packages/web/src/platform/index.js +8 -9
  62. package/packages/web/src/platform/mediaUrls.js +10 -0
  63. package/packages/web/src/platform/navigation.js +35 -21
  64. package/packages/web/src/platform/pickers.js +39 -19
  65. package/packages/web/src/platform/pluginHost.js +4 -1
  66. package/packages/web/src/platform/pluginRpc.js +47 -2
  67. package/packages/web/src/platform/spatialNav.js +1 -1
  68. package/packages/web/src/platform/viewport.js +14 -6
  69. package/packages/web/src/platform/voiceSearch.js +17 -9
  70. package/packages/web/src/platform/whenclause.js +9 -19
  71. package/packages/web/src/runtime.js +2 -37
  72. package/packages/web/src/ui/activate.js +18 -2
  73. package/packages/web/src/ui/components/activityPanel.js +1 -1
  74. package/packages/web/src/ui/components/adminView.js +4 -7
  75. package/packages/web/src/ui/components/editorArea.js +10 -1
  76. package/packages/web/src/ui/components/launcher.js +29 -13
  77. package/packages/web/src/ui/components/openers/index.js +17 -22
  78. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  79. package/packages/web/src/ui/components/overlays.js +31 -5
  80. package/packages/web/src/ui/components/pluginReview.js +7 -4
  81. package/packages/web/src/ui/components/pluginsView.js +1 -1
  82. package/packages/web/src/ui/components/settingsView.js +30 -27
  83. package/packages/web/src/ui/components/statusBar.js +8 -6
  84. package/packages/web/src/ui/components/views/grid.js +2 -2
  85. package/packages/web/src/ui/components/views/parts.js +1 -1
  86. package/packages/web/src/ui/compositions/workbench.js +20 -6
  87. package/packages/web/src/ui/sanitize.js +0 -5
  88. package/packages/web/src/workbench.js +37 -19
  89. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  90. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -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' },
@@ -35,6 +35,18 @@
35
35
  // Evaluate URL https://<your drive>/api/access/evaluate
36
36
  // Keys URL https://<your drive>/api/access/keys
37
37
  //
38
+ // 4. AND let Access reach them. If the drive sits behind Access — which is the whole
39
+ // point — then by default these two paths do as well, and Access would have to
40
+ // authenticate to itself to call them. It cannot, so it fails with nothing useful to
41
+ // say. Add a Bypass policy for `/api/access/*`.
42
+ //
43
+ // Bypassing is safe here rather than a concession, and that is by construction: the
44
+ // keys endpoint serves a public key, and the evaluate endpoint authenticates its
45
+ // caller itself by verifying the assertion. Neither ever depended on Access for its
46
+ // own protection. They are also `public: true`, so this drive's OWN identity
47
+ // requirement does not apply to them either — Access holds no Trove session and
48
+ // never will.
49
+ //
38
50
  // WHY IT REFUSES TO RUN UNVERIFIED
39
51
  //
40
52
  // This endpoint answers "does this email have access to this drive". That is a question
@@ -116,6 +128,10 @@ export function externalEvaluation({ privateJwk, team, kid = 'trove-access', jwk
116
128
  method: 'POST',
117
129
  path: '/api/access/evaluate',
118
130
  deps: ['collections'],
131
+ // No Trove identity: the caller is Cloudflare, not a user of this drive, and it
132
+ // authenticates by signing the assertion — which `verifyJwt` below checks. A
133
+ // session requirement here would be asking Access to log in as somebody.
134
+ public: true,
119
135
  async handler(ctx) {
120
136
  const token = await parseAssertion(ctx.req);
121
137
  if (!token) throw TroveError.invalid('No access assertion in the request');
@@ -141,6 +157,7 @@ export function externalEvaluation({ privateJwk, team, kid = 'trove-access', jwk
141
157
  method: 'GET',
142
158
  path: '/api/access/keys',
143
159
  deps: [],
160
+ public: true,
144
161
  // Public by design: it is a public key, and Cloudflare fetches it unauthenticated.
145
162
  handler() {
146
163
  return { keys: [publicJwkOf(privateJwk, { kid })] };
@@ -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,