@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.
- package/README.md +13 -1
- package/package.json +2 -1
- package/packages/core/src/collections/index.js +71 -10
- package/packages/core/src/encryption/keys.js +4 -3
- package/packages/core/src/encryption/policy.js +14 -7
- package/packages/core/src/encryption/rotation.js +46 -12
- package/packages/core/src/errors.js +8 -0
- package/packages/core/src/index.js +8 -2
- package/packages/core/src/indexers/contribution.js +18 -17
- package/packages/core/src/indexing.js +8 -4
- package/packages/core/src/issues.js +8 -0
- package/packages/core/src/links.js +21 -9
- package/packages/core/src/metadata/interface.js +22 -0
- package/packages/core/src/metadata/memory.js +15 -0
- package/packages/core/src/metadata/sqlite.js +23 -1
- package/packages/core/src/plugins/contributions.js +13 -0
- package/packages/core/src/rateLimit.js +253 -0
- package/packages/core/src/scan.js +44 -16
- package/packages/core/src/sidecar/document.js +18 -14
- package/packages/core/src/sidecar/index.js +9 -10
- package/packages/core/src/sidecar/manager.js +10 -15
- package/packages/core/src/uploads.js +40 -15
- package/packages/core/src/vfs.js +59 -34
- package/packages/plugin-sdk/src/browser.js +155 -0
- package/packages/plugin-sdk/src/protocol.js +13 -1
- package/packages/server/src/access/externalEvaluation.js +17 -0
- package/packages/server/src/adapters/bun.js +1 -2
- package/packages/server/src/adapters/node.js +1 -2
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +17 -34
- package/packages/server/src/engine/providers/core.js +112 -14
- package/packages/server/src/index.js +160 -105
- package/packages/server/src/mcp/index.js +7 -12
- package/packages/server/src/mcp/tools.js +24 -29
- package/packages/server/src/router.js +31 -8
- package/packages/server/src/routes.js +74 -113
- package/packages/server/src/scope.js +78 -0
- package/packages/web/dist/assets/main-828yzsr7.js +511 -0
- package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +226 -104
- package/packages/web/src/bl/activity.js +32 -82
- package/packages/web/src/bl/index.js +23 -11
- package/packages/web/src/bl/launcher.js +2 -9
- package/packages/web/src/bl/links.js +31 -18
- package/packages/web/src/bl/offline.js +24 -10
- package/packages/web/src/bl/pluginInstall.js +29 -26
- package/packages/web/src/bl/queries.js +76 -68
- package/packages/web/src/bl/services.js +22 -14
- package/packages/web/src/bl/social.js +39 -25
- package/packages/web/src/bl/state.js +22 -4
- package/packages/web/src/bl/viewState.js +9 -0
- package/packages/web/src/bl/watchQuery.js +1 -8
- package/packages/web/src/dispatch.js +26 -0
- package/packages/web/src/platform/api.js +32 -0
- package/packages/web/src/platform/commands.js +14 -15
- package/packages/web/src/platform/context.js +3 -58
- package/packages/web/src/platform/contributions.js +1 -35
- package/packages/web/src/platform/fileChunks.js +281 -0
- package/packages/web/src/platform/index.js +8 -9
- package/packages/web/src/platform/mediaUrls.js +10 -0
- package/packages/web/src/platform/navigation.js +35 -21
- package/packages/web/src/platform/pickers.js +39 -19
- package/packages/web/src/platform/pluginHost.js +4 -1
- package/packages/web/src/platform/pluginRpc.js +47 -2
- package/packages/web/src/platform/spatialNav.js +1 -1
- package/packages/web/src/platform/viewport.js +14 -6
- package/packages/web/src/platform/voiceSearch.js +17 -9
- package/packages/web/src/platform/whenclause.js +9 -19
- package/packages/web/src/runtime.js +2 -37
- package/packages/web/src/ui/activate.js +18 -2
- package/packages/web/src/ui/components/activityPanel.js +1 -1
- package/packages/web/src/ui/components/adminView.js +4 -7
- package/packages/web/src/ui/components/editorArea.js +10 -1
- package/packages/web/src/ui/components/launcher.js +29 -13
- package/packages/web/src/ui/components/openers/index.js +17 -22
- package/packages/web/src/ui/components/openers/markdown.js +2 -2
- package/packages/web/src/ui/components/overlays.js +31 -5
- package/packages/web/src/ui/components/pluginReview.js +7 -4
- package/packages/web/src/ui/components/pluginsView.js +1 -1
- package/packages/web/src/ui/components/settingsView.js +30 -27
- package/packages/web/src/ui/components/statusBar.js +8 -6
- package/packages/web/src/ui/components/views/grid.js +2 -2
- package/packages/web/src/ui/components/views/parts.js +1 -1
- package/packages/web/src/ui/compositions/workbench.js +20 -6
- package/packages/web/src/ui/sanitize.js +0 -5
- package/packages/web/src/workbench.js +37 -19
- package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
- package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
package/packages/core/src/vfs.js
CHANGED
|
@@ -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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
580
|
-
//
|
|
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,
|
|
615
|
-
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
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', '
|
|
203
|
+
static deps = ['vfs', 'sidecar', 'collections', 'signedUrls'];
|
|
210
204
|
|
|
211
|
-
constructor({ vfs, sidecar, collections,
|
|
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'
|
|
283
|
+
static deps = ['vfs', 'collections'];
|
|
294
284
|
|
|
295
|
-
constructor({ vfs, collections
|
|
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'
|
|
323
|
+
static deps = ['vfs', 'collections'];
|
|
337
324
|
|
|
338
|
-
constructor({ vfs, collections
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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,
|