@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
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
// plugin, so its status item, its register and its command can't shadow each other,
|
|
18
18
|
// and `trove+contrib:acme.com/docs/status` names exactly one thing in the world.
|
|
19
19
|
//
|
|
20
|
+
//
|
|
21
|
+
// TWO SENSES OF "CONTRIBUTION", and this is one of them. Here it is an EXTENSION POINT a
|
|
22
|
+
// package declares in its manifest and the host addresses by URI —
|
|
23
|
+
// `trove+contrib:acme.com/docs/player`. The other sense lives in core/src/indexers/
|
|
24
|
+
// contribution.js: per-node ENRICHMENT produced at runtime and addressed by contributorId,
|
|
25
|
+
// which is what an `indexer` declared here goes on to emit. So a plugin declares a
|
|
26
|
+
// contribution (this sense) which produces contributions (that one).
|
|
27
|
+
//
|
|
28
|
+
// The rule for telling them apart: this sense is DECLARED, static, and named by URI; that
|
|
29
|
+
// sense is PRODUCED, per node, and named by contributor. Renaming the second was weighed
|
|
30
|
+
// and declined — it is a wire rename on `node.contributions` and `/api/index/:indexerId`,
|
|
31
|
+
// needing a dual-read window for a stored field, which is a lot of churn for a distinction
|
|
32
|
+
// two sentences can carry.
|
|
20
33
|
// Each contribution's `entry` (openers, indexers) points into the plugin's ONE module
|
|
21
34
|
// tree — they are not nested sub-packages, so everything in a plugin shares modules
|
|
22
35
|
// and code. What gets opened or indexed depends only on which entry module runs.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// What one caller may cost.
|
|
2
|
+
//
|
|
3
|
+
// There was no limit anywhere: a key, a signed-in person, or on an open drive a stranger,
|
|
4
|
+
// could issue requests as fast as they could open sockets and every one was served. That is
|
|
5
|
+
// survivable while the only user is whoever deployed it, and stops being survivable the
|
|
6
|
+
// moment a key is handed to somebody else's script or the drive is put somewhere reachable.
|
|
7
|
+
//
|
|
8
|
+
// REQUESTS ARE NOT EQUAL, so neither are the limits. A limit that treats a range request
|
|
9
|
+
// and a semantic search as the same thing is either useless against the expensive one or
|
|
10
|
+
// absurd against the cheap one. Work is named by CLASS, and a class is a statement about
|
|
11
|
+
// what one call can cost someone:
|
|
12
|
+
//
|
|
13
|
+
// search a paid third-party call per query where TROVE_EMBEDDINGS_URL is set. The one
|
|
14
|
+
// where an attacker spends the operator's money rather than their CPU.
|
|
15
|
+
// upload bytes through the drive — and both directions for an encrypted collection,
|
|
16
|
+
// since the drive seals. `maxUploadBytes` caps one file and nothing capped the
|
|
17
|
+
// rate, so a thousand small uploads cost what the limit was meant to prevent.
|
|
18
|
+
// download bytes out. Generous, because a media player range-requests one file many
|
|
19
|
+
// times and that is one file's worth of bandwidth, not many.
|
|
20
|
+
// job scan, reindex, rotate. Each schedules real work over a whole collection.
|
|
21
|
+
// install unzip, verify, store.
|
|
22
|
+
// evaluate the deliberately unauthenticated access endpoint: a JWKS fetch or cache read
|
|
23
|
+
// and an RSA verify, reachable with no credential at all.
|
|
24
|
+
// write ordinary mutations. A backstop, not a real cost model.
|
|
25
|
+
//
|
|
26
|
+
// Reads are not limited. They are cheap, the shell issues many, and a limit low enough to
|
|
27
|
+
// matter would break normal use — which is the definition of theatre.
|
|
28
|
+
//
|
|
29
|
+
// WHERE THE COUNTERS LIVE is the hard part, and the honest answer differs by runtime. A
|
|
30
|
+
// per-process counter is exact on a long-lived Bun or Node instance and a lie on Workers,
|
|
31
|
+
// where each isolate has its own memory and "60 a minute" becomes 60 per isolate per
|
|
32
|
+
// minute. So there are two stores, the choice is configuration, and `describeRateLimits`
|
|
33
|
+
// exists so a deployment that cannot enforce them SAYS SO rather than appearing to.
|
|
34
|
+
|
|
35
|
+
import { TroveError } from './errors.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `count/window` for each class of work.
|
|
39
|
+
*
|
|
40
|
+
* Generous enough that someone using the drive normally never sees a 429, tight enough
|
|
41
|
+
* that a loop does. Windows are all a minute because a limit you have to reason about in
|
|
42
|
+
* two units is one nobody reasons about.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_RATE_LIMITS = {
|
|
45
|
+
search: { limit: 60, windowMs: 60_000 },
|
|
46
|
+
upload: { limit: 240, windowMs: 60_000 },
|
|
47
|
+
download: { limit: 1200, windowMs: 60_000 },
|
|
48
|
+
job: { limit: 10, windowMs: 60_000 },
|
|
49
|
+
install: { limit: 10, windowMs: 60_000 },
|
|
50
|
+
evaluate: { limit: 60, windowMs: 60_000 },
|
|
51
|
+
write: { limit: 600, windowMs: 60_000 },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const RATE_CLASSES = Object.keys(DEFAULT_RATE_LIMITS);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Counters in this process's memory.
|
|
58
|
+
*
|
|
59
|
+
* EXACT on a runtime where one process serves every request, which is what a self-hosted
|
|
60
|
+
* Bun or Node drive is. Wrong on Workers — see the module header, and `describeRateLimits`,
|
|
61
|
+
* which is how a deployment finds out rather than assuming.
|
|
62
|
+
*
|
|
63
|
+
* Buckets expire, and expired ones are swept when the map grows rather than on a timer:
|
|
64
|
+
* there is no timer to hang it on inside a request, and a sweep proportional to how much
|
|
65
|
+
* traffic there was is the right shape anyway.
|
|
66
|
+
*/
|
|
67
|
+
export class MemoryRateStore {
|
|
68
|
+
constructor({ maxEntries = 10_000 } = {}) {
|
|
69
|
+
this.buckets = new Map(); // key -> { count, expiresAt }
|
|
70
|
+
this.maxEntries = maxEntries;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async bump(key, windowMs, now) {
|
|
74
|
+
// A cheap backstop between sweeps, for a burst that outruns the maintenance tick.
|
|
75
|
+
if (this.buckets.size > this.maxEntries) await this.sweep(now);
|
|
76
|
+
const held = this.buckets.get(key);
|
|
77
|
+
if (held && held.expiresAt > now) {
|
|
78
|
+
held.count += 1;
|
|
79
|
+
return held.count;
|
|
80
|
+
}
|
|
81
|
+
this.buckets.set(key, { count: 1, expiresAt: now + windowMs });
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drop buckets whose window has passed. The same method the KV store has, and the same
|
|
87
|
+
* caller — periodic maintenance.
|
|
88
|
+
*
|
|
89
|
+
* It exists on BOTH stores so the caller does not have to ask which one it got. A
|
|
90
|
+
* `store.sweep?.()` there would be the optional-call shape this codebase records as
|
|
91
|
+
* having turned a sweep into a permanent no-op once already; and without a periodic
|
|
92
|
+
* sweep a long-lived process holds an expired bucket for every subject it has ever seen
|
|
93
|
+
* until the size backstop fires, which on a quiet drive is never.
|
|
94
|
+
*
|
|
95
|
+
* @returns {Promise<number>} how many went
|
|
96
|
+
*/
|
|
97
|
+
async sweep(now = Date.now()) {
|
|
98
|
+
let dropped = 0;
|
|
99
|
+
for (const [k, b] of this.buckets) if (b.expiresAt <= now) { this.buckets.delete(k); dropped++; }
|
|
100
|
+
return dropped;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Counters in the shared KeyValueStore, so every instance sees one budget.
|
|
106
|
+
*
|
|
107
|
+
* The cost is a read and a write per LIMITED request — which is why only the expensive
|
|
108
|
+
* classes are limited at all. And it is read-modify-write rather than an atomic increment,
|
|
109
|
+
* because the store has no atomic increment: requests that overlap exactly can each read
|
|
110
|
+
* the same count and each write count+1, so a burst can slip a few through. That is a
|
|
111
|
+
* limiter that is occasionally generous, which is a different thing from no limiter, and
|
|
112
|
+
* it is stated here rather than discovered.
|
|
113
|
+
*
|
|
114
|
+
* Expired buckets are removed by `sweep`, called from periodic maintenance. Without it the
|
|
115
|
+
* namespace grows one key per subject per class per window, forever.
|
|
116
|
+
*/
|
|
117
|
+
export class KvRateStore {
|
|
118
|
+
static NS = 'ratelimit';
|
|
119
|
+
|
|
120
|
+
constructor({ kv }) {
|
|
121
|
+
if (!kv) throw TroveError.invalid('KvRateStore needs a KeyValueStore');
|
|
122
|
+
this.kv = kv;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async bump(key, windowMs, now) {
|
|
126
|
+
const held = await this.kv.get(KvRateStore.NS, key).catch(() => null);
|
|
127
|
+
const next = held && held.expiresAt > now
|
|
128
|
+
? { count: held.count + 1, expiresAt: held.expiresAt }
|
|
129
|
+
: { count: 1, expiresAt: now + windowMs };
|
|
130
|
+
await this.kv.set(KvRateStore.NS, key, next).catch(() => {});
|
|
131
|
+
return next.count;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Drop buckets whose window has passed. @returns {Promise<number>} how many went */
|
|
135
|
+
async sweep(now = Date.now()) {
|
|
136
|
+
const rows = await this.kv.list(KvRateStore.NS).catch(() => []);
|
|
137
|
+
let dropped = 0;
|
|
138
|
+
for (const { key, value } of rows) {
|
|
139
|
+
if (!value || value.expiresAt <= now) {
|
|
140
|
+
await this.kv.delete(KvRateStore.NS, key).catch(() => {});
|
|
141
|
+
dropped++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return dropped;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decide whether this subject may do another unit of this class of work.
|
|
150
|
+
*
|
|
151
|
+
* FIXED WINDOWS, not a sliding log. A sliding window is fairer at the boundary and costs a
|
|
152
|
+
* list per request to be so; a fixed window's worst case is twice the limit across two
|
|
153
|
+
* adjacent windows, which for limits chosen to bound cost rather than to be precise is a
|
|
154
|
+
* trade worth taking. It also gives an exact `Retry-After`: the window's own end.
|
|
155
|
+
*/
|
|
156
|
+
export class RateLimiter {
|
|
157
|
+
/**
|
|
158
|
+
* @param {object} deps
|
|
159
|
+
* @param {{bump: (key: string, windowMs: number, now: number) => Promise<number>}} deps.store
|
|
160
|
+
* @param {Record<string, {limit: number, windowMs: number}>} [deps.limits]
|
|
161
|
+
* @param {() => number} [deps.now] injected clock, for tests
|
|
162
|
+
*/
|
|
163
|
+
constructor({ store, limits = DEFAULT_RATE_LIMITS, now = () => Date.now() } = {}) {
|
|
164
|
+
if (!store) throw TroveError.invalid('RateLimiter needs a store');
|
|
165
|
+
this.store = store;
|
|
166
|
+
this.limits = limits;
|
|
167
|
+
this.now = now;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @returns {Promise<{ok: boolean, limit?: number, remaining?: number, retryAfterMs?: number}>}
|
|
172
|
+
* `{ok: true}` for a class with no limit configured — an unnamed class is not an
|
|
173
|
+
* error, it is work nobody decided to meter.
|
|
174
|
+
*/
|
|
175
|
+
async check(subject, className) {
|
|
176
|
+
const rule = this.limits[className];
|
|
177
|
+
if (!rule || !rule.limit) return { ok: true };
|
|
178
|
+
const now = this.now();
|
|
179
|
+
// The bucket is part of the key, so a new window is a new counter and there is nothing
|
|
180
|
+
// to reset — which is also what makes this safe across processes that never talk.
|
|
181
|
+
const bucket = Math.floor(now / rule.windowMs);
|
|
182
|
+
const endsAt = (bucket + 1) * rule.windowMs;
|
|
183
|
+
const count = await this.store.bump(`${className}:${subject}:${bucket}`, rule.windowMs, now);
|
|
184
|
+
if (count > rule.limit) {
|
|
185
|
+
return { ok: false, limit: rule.limit, remaining: 0, retryAfterMs: Math.max(0, endsAt - now) };
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, limit: rule.limit, remaining: rule.limit - count };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The same, as a refusal.
|
|
192
|
+
*
|
|
193
|
+
* QUOTA and retryable, which errors.js already maps to 429 — "A rate limit is 429: back
|
|
194
|
+
* off and try again", as distinct from being out of disk, which is 507 because retrying
|
|
195
|
+
* changes nothing. `retryAfterMs` rides in `details` so the HTTP layer can turn it into
|
|
196
|
+
* a `Retry-After` header instead of every client guessing.
|
|
197
|
+
*/
|
|
198
|
+
async enforce(subject, className) {
|
|
199
|
+
const verdict = await this.check(subject, className);
|
|
200
|
+
if (verdict.ok) return verdict;
|
|
201
|
+
throw TroveError.rateLimited(
|
|
202
|
+
`Too many ${className} requests — wait ${Math.ceil(verdict.retryAfterMs / 1000)}s and try again`,
|
|
203
|
+
{ details: { limit: verdict.limit, retryAfterMs: verdict.retryAfterMs, kind: className } },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Who is being limited.
|
|
210
|
+
*
|
|
211
|
+
* The two credentials are already resolved in one place, and a grant is resolved FIRST and
|
|
212
|
+
* an identity only if there is no grant — so this does not re-derive anything, it names
|
|
213
|
+
* what was decided.
|
|
214
|
+
*
|
|
215
|
+
* The third case is the one that needs a decision rather than a default. With no
|
|
216
|
+
* credential there is nothing stable to key on but the address, and an address behind a
|
|
217
|
+
* proxy is whatever the proxy says — trusting a header a client can set is worse than not
|
|
218
|
+
* limiting at all, because it hands every caller their own budget for the asking. So the
|
|
219
|
+
* forwarded address is used only where the operator has said the proxy is trustworthy
|
|
220
|
+
* (TROVE_TRUST_PROXY), and otherwise every anonymous caller shares ONE budget.
|
|
221
|
+
*
|
|
222
|
+
* Sharing one budget is a real limit — it bounds what the drive spends, which is the point
|
|
223
|
+
* — and it has a real cost: on a drive open to the internet, one stranger can exhaust the
|
|
224
|
+
* anonymous allowance for the rest. That is the trade, stated. An operator who does not
|
|
225
|
+
* want it configures a proxy and says so, or requires authentication.
|
|
226
|
+
*/
|
|
227
|
+
export function rateSubject({ grant, principal, req, trustProxy = false } = {}) {
|
|
228
|
+
if (grant?.keyId) return `key:${grant.keyId}`;
|
|
229
|
+
if (principal?.id) return `user:${principal.id}`;
|
|
230
|
+
if (trustProxy && req) {
|
|
231
|
+
const forwarded = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim();
|
|
232
|
+
if (forwarded) return `ip:${forwarded}`;
|
|
233
|
+
}
|
|
234
|
+
return 'anon';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What this deployment will actually enforce, in its own words.
|
|
239
|
+
*
|
|
240
|
+
* Exists because the ticket's requirement was that a runtime which cannot enforce a limit
|
|
241
|
+
* says so rather than pretending. `scope: 'isolate'` is that admission: in-memory counters
|
|
242
|
+
* on a runtime with no long-lived process count per isolate, so the effective limit is the
|
|
243
|
+
* configured one times however many isolates the platform decided to run.
|
|
244
|
+
*/
|
|
245
|
+
export function describeRateLimits({ enabled, store, limits, perProcess }) {
|
|
246
|
+
if (!enabled) return { enabled: false, scope: 'none', limits: {} };
|
|
247
|
+
return {
|
|
248
|
+
enabled: true,
|
|
249
|
+
store,
|
|
250
|
+
scope: store === 'kv' ? 'drive' : (perProcess ? 'process' : 'isolate'),
|
|
251
|
+
limits,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -96,6 +96,9 @@ export class CollectionScanner {
|
|
|
96
96
|
// outside: the scan adopts it AGAIN, resurrecting the deleted file under a new id
|
|
97
97
|
// that shares the original's storage key. Emptying the trash then deletes the live
|
|
98
98
|
// copy's bytes, leaving an item that lists, opens, and 404s forever.
|
|
99
|
+
// The one optional call left on a store method, and the interface says why: soft
|
|
100
|
+
// delete is documented as optional — a store that makes deletes permanent has no trash
|
|
101
|
+
// to enumerate. Every other declared method is called unguarded.
|
|
99
102
|
const trashedKeys = await this.vfs.metadata.trashedStorageKeys?.(collectionId) ?? new Set();
|
|
100
103
|
|
|
101
104
|
const result = {
|
|
@@ -215,28 +218,52 @@ export class CollectionScanner {
|
|
|
215
218
|
* excluded: one of those with no metadata row is a leftover from a failed write, not
|
|
216
219
|
* a file someone put there, and adopting it would surface `obj_9fc0…` as a document.
|
|
217
220
|
*/
|
|
221
|
+
/**
|
|
222
|
+
* What a record should say about an object, read from the OBJECT.
|
|
223
|
+
*
|
|
224
|
+
* Is this an encrypted object somebody copied in? The envelope says so, and says which
|
|
225
|
+
* key it wants, WITHOUT the key — which is the entire reason the header is readable.
|
|
226
|
+
* Without this the object is recorded as plaintext, and every read of it hands back raw
|
|
227
|
+
* ciphertext with no error: the drive shows a file, and opening it gives you an
|
|
228
|
+
* unreadable blob. Sideloading is a named use case here, so it has to be the case that
|
|
229
|
+
* works.
|
|
230
|
+
*
|
|
231
|
+
* ONE rule, and both callers ask it. They disagreed before: adoption read the envelope
|
|
232
|
+
* while refresh took `object.size`, so an encrypted item replaced in place — which is
|
|
233
|
+
* precisely the scenario this scanner exists for — recorded the size of the ENVELOPE
|
|
234
|
+
* (plaintext + 44 header + 16 tag per chunk) as the size of the file. Wrong from then on
|
|
235
|
+
* in listings, quotas and collectionStats, and it feeds rotation, where `node.size`
|
|
236
|
+
* bounds the read loop and is written into the new header.
|
|
237
|
+
*
|
|
238
|
+
* `size` is left undefined when the object reports none, so each caller can apply the
|
|
239
|
+
* fallback that makes sense for it. Costs one 44-byte read per object.
|
|
240
|
+
*/
|
|
241
|
+
async #factsOf(collectionId, object) {
|
|
242
|
+
const envelope = await this.#envelopeOf(collectionId, object.key);
|
|
243
|
+
return {
|
|
244
|
+
// The size the file has, not the size the envelope occupies.
|
|
245
|
+
size: envelope ? envelope.plaintextSize : object.size,
|
|
246
|
+
etag: object.etag ?? null,
|
|
247
|
+
// Null when there is no envelope, deliberately: an encrypted object replaced by a
|
|
248
|
+
// plaintext one must stop claiming a key, or every read of it fails looking for a
|
|
249
|
+
// header that is not there.
|
|
250
|
+
encryption: envelope
|
|
251
|
+
? { fingerprint: toHex(envelope.fingerprint), chunkSize: envelope.chunkSize }
|
|
252
|
+
: null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
218
256
|
async #adopt(collectionId, object) {
|
|
219
257
|
if (TROVE_KEY.test(object.key)) return null; // orphaned blob from an interrupted upload
|
|
220
258
|
const name = await this.#uniqueName(collectionId, object.key);
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
// The envelope says so, and says which key it wants, WITHOUT the key — which is the
|
|
224
|
-
// entire reason the header is readable. Without this an adopted object is recorded as
|
|
225
|
-
// plaintext, and every read of it hands back raw ciphertext with no error: the drive
|
|
226
|
-
// shows a file, and opening it gives you an unreadable blob. Sideloading is a named
|
|
227
|
-
// use case here, so it has to be the case that works.
|
|
228
|
-
const envelope = await this.#envelopeOf(collectionId, object.key);
|
|
259
|
+
const facts = await this.#factsOf(collectionId, object);
|
|
229
260
|
const node = await this.vfs.metadata.create({
|
|
230
261
|
collectionId,
|
|
231
262
|
name,
|
|
232
263
|
storageKey: object.key,
|
|
233
|
-
|
|
234
|
-
size:
|
|
235
|
-
etag: object.etag ?? null,
|
|
264
|
+
...facts,
|
|
265
|
+
size: facts.size ?? 0,
|
|
236
266
|
contentType: this.vfs.guessContentType(name),
|
|
237
|
-
encryption: envelope
|
|
238
|
-
? { fingerprint: toHex(envelope.fingerprint), chunkSize: envelope.chunkSize }
|
|
239
|
-
: null,
|
|
240
267
|
meta: { adopted: true, adoptedAt: Date.now() },
|
|
241
268
|
});
|
|
242
269
|
// Adopted files are indexed like any other, so they are findable immediately —
|
|
@@ -247,9 +274,10 @@ export class CollectionScanner {
|
|
|
247
274
|
|
|
248
275
|
/** Re-read an item whose bytes were replaced in place. */
|
|
249
276
|
async #refresh(node, object) {
|
|
277
|
+
const facts = await this.#factsOf(node.collectionId, object);
|
|
250
278
|
const updated = await this.vfs.metadata.update(node.id, {
|
|
251
|
-
|
|
252
|
-
|
|
279
|
+
...facts,
|
|
280
|
+
size: facts.size ?? node.size,
|
|
253
281
|
});
|
|
254
282
|
await this.vfs.indexing.indexNode(updated).catch(() => {});
|
|
255
283
|
return updated;
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// The per-file sidecar document — a small CRDT that holds everything mutable and
|
|
2
2
|
// social about a file WITHOUT touching the file bytes: its conversation
|
|
3
|
-
// (threaded comments + reactions), its tags,
|
|
4
|
-
//
|
|
3
|
+
// (threaded comments + reactions), its tags, and who's subscribed to the thread.
|
|
4
|
+
//
|
|
5
|
+
// It does NOT hold indexer output. It advertised a per-indexer `facets` register for a
|
|
6
|
+
// long time with no writer and no reader anywhere in core, server, web or the plugin SDK,
|
|
7
|
+
// and there is no facet verb on the plugin RPC surface either — so the header was the
|
|
8
|
+
// documentation someone extending an indexer would find first, and following it would land
|
|
9
|
+
// data nothing queries. Indexer contributions live in the queryable metadata store, which
|
|
10
|
+
// is what makes them show up in list/stat and drive tag filtering (see indexing.js).
|
|
5
11
|
//
|
|
6
12
|
// It's designed to live as cold JSON in object storage and be merged whenever
|
|
7
13
|
// it's read-before-write, so two servers (or a stale hot copy vs the cold one)
|
|
8
14
|
// converge without a lock or a conflict. Every field is a CRDT register:
|
|
9
15
|
// • tags — LWW-Element-Set (add/remove wins by Lamport stamp)
|
|
10
|
-
// • facets — per-indexer LWW register
|
|
11
16
|
// • comments — grow-only map; body edit & deletion are LWW registers; reactions
|
|
12
17
|
// are an OR-map (per user, per emoji, LWW on/off)
|
|
13
18
|
// • subscribers — LWW register (subscribed / muted)
|
|
@@ -17,7 +22,7 @@
|
|
|
17
22
|
export const SIDECAR_VERSION = 1;
|
|
18
23
|
|
|
19
24
|
export function emptyDoc(nodeId) {
|
|
20
|
-
return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {},
|
|
25
|
+
return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, comments: {}, subscribers: {} };
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
// A stamp orders and tie-breaks a write. Higher clock wins; equal clock → higher
|
|
@@ -96,12 +101,6 @@ export function removeTag(doc, name, { actor, at } = {}) {
|
|
|
96
101
|
if (newer(s, cur)) doc.tags[name] = { present: false, value: cur?.value, ...s };
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
export function setFacet(doc, indexerId, data, { actor, at } = {}) {
|
|
100
|
-
const s = stamp(doc, actor ?? indexerId, at);
|
|
101
|
-
const cur = doc.facets[indexerId];
|
|
102
|
-
if (newer(s, cur)) doc.facets[indexerId] = { data, ...s };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
104
|
export function subscribe(doc, userId, { muted = false, actor, at } = {}) {
|
|
106
105
|
if (!userId) return;
|
|
107
106
|
const s = stamp(doc, actor ?? userId, at);
|
|
@@ -120,10 +119,16 @@ export function unsubscribe(doc, userId, { actor, at } = {}) {
|
|
|
120
119
|
export function mergeDoc(a, b) {
|
|
121
120
|
if (!a) return structuredCloneSafe(b);
|
|
122
121
|
if (!b) return structuredCloneSafe(a);
|
|
123
|
-
const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {},
|
|
122
|
+
const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, comments: {}, subscribers: {} };
|
|
124
123
|
|
|
125
124
|
for (const key of union(a.tags, b.tags)) out.tags[key] = pick(a.tags[key], b.tags[key]);
|
|
126
|
-
|
|
125
|
+
// Documents written before the register was removed still carry one, and a merge that
|
|
126
|
+
// dropped half of a stored document would not be a merge. Guarded on presence so it
|
|
127
|
+
// costs nothing on the documents every writer produces now.
|
|
128
|
+
if (a.facets || b.facets) {
|
|
129
|
+
out.facets = {};
|
|
130
|
+
for (const key of union(a.facets, b.facets)) out.facets[key] = pick(a.facets[key], b.facets[key]);
|
|
131
|
+
}
|
|
127
132
|
for (const key of union(a.subscribers, b.subscribers)) out.subscribers[key] = pick(a.subscribers[key], b.subscribers[key]);
|
|
128
133
|
|
|
129
134
|
for (const id of union(a.comments, b.comments)) {
|
|
@@ -183,9 +188,8 @@ export function viewDoc(doc) {
|
|
|
183
188
|
if (c.parentId && byId.has(c.parentId)) byId.get(c.parentId).replies.push(c);
|
|
184
189
|
else roots.push(c);
|
|
185
190
|
}
|
|
186
|
-
const facets = Object.fromEntries(Object.entries(doc.facets || {}).map(([k, v]) => [k, v.data]));
|
|
187
191
|
const subscribers = Object.entries(doc.subscribers || {}).filter(([, s]) => s.subscribed).map(([id]) => id).sort();
|
|
188
|
-
return { nodeId: doc.nodeId, tags, comments: roots, commentCount: all.filter((c) => !c.deleted).length,
|
|
192
|
+
return { nodeId: doc.nodeId, tags, comments: roots, commentCount: all.filter((c) => !c.deleted).length, subscribers };
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
function summariseReactions(reactions = {}) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SidecarService — the API surface over sidecar documents: conversations
|
|
2
|
-
// (threaded comments, edits, reactions), tags,
|
|
2
|
+
// (threaded comments, edits, reactions), tags, and thread
|
|
3
3
|
// subscriptions. It resolves the acting Principal into comment authorship,
|
|
4
4
|
// extracts @mentions, auto-subscribes participants, and emits mention events to
|
|
5
5
|
// a callback (wired to the notification batcher). Routes call these methods.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { SidecarStore } from './store.js';
|
|
8
8
|
import { SidecarManager } from './manager.js';
|
|
9
9
|
import {
|
|
10
|
-
addComment, editComment, deleteComment, react, setTag, removeTag,
|
|
10
|
+
addComment, editComment, deleteComment, react, setTag, removeTag,
|
|
11
11
|
subscribe, unsubscribe, viewDoc, extractMentions,
|
|
12
12
|
} from './document.js';
|
|
13
13
|
import { newId } from '../util.js';
|
|
@@ -47,6 +47,13 @@ export class SidecarService {
|
|
|
47
47
|
flushAll() {
|
|
48
48
|
return this.manager.flushAll();
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Try again to save what could not be saved — what the `sidecar-flush` issue's Retry
|
|
52
|
+
* button runs. Clears the issue for each document that lands.
|
|
53
|
+
*/
|
|
54
|
+
retryPending() {
|
|
55
|
+
return this.manager.retryPending();
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
// --- conversation ----------------------------------------------------------
|
|
52
59
|
|
|
@@ -108,14 +115,6 @@ export class SidecarService {
|
|
|
108
115
|
return this.view(nodeId);
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
// --- facets (indexer-scoped) ----------------------------------------------
|
|
112
|
-
|
|
113
|
-
/** Write an indexer's facet into the sidecar, namespaced under indexerId. */
|
|
114
|
-
async setFacet(nodeId, indexerId, data) {
|
|
115
|
-
await this.manager.mutate(nodeId, (doc) => setFacet(doc, indexerId, data, { actor: indexerId }));
|
|
116
|
-
return { ok: true };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
118
|
// --- subscriptions ---------------------------------------------------------
|
|
120
119
|
|
|
121
120
|
async subscribe(nodeId, principal, muted = false) {
|
|
@@ -27,10 +27,10 @@ export class SidecarManager {
|
|
|
27
27
|
let e = this.hot.get(nodeId);
|
|
28
28
|
if (e) {
|
|
29
29
|
if (e.loading) await e.loading;
|
|
30
|
-
e.lastAccess = now();
|
|
30
|
+
e.lastAccess = Date.now();
|
|
31
31
|
return e;
|
|
32
32
|
}
|
|
33
|
-
e = { doc: null, dirty: false, timer: null, lastAccess: now() };
|
|
33
|
+
e = { doc: null, dirty: false, timer: null, lastAccess: Date.now() };
|
|
34
34
|
this.hot.set(nodeId, e);
|
|
35
35
|
e.loading = (async () => {
|
|
36
36
|
e.doc = (await this.store.load(nodeId)) || this.store.emptyDoc(nodeId);
|
|
@@ -99,15 +99,18 @@ export class SidecarManager {
|
|
|
99
99
|
// Out of retries. The comment is still in memory and still served, but it will
|
|
100
100
|
// not survive a restart, and the person who wrote it has been told it saved.
|
|
101
101
|
// That is a standing problem, which is exactly what the issue registry is for.
|
|
102
|
-
this.issues?.raise
|
|
102
|
+
this.issues?.raise({
|
|
103
103
|
kind: 'sidecar-flush',
|
|
104
104
|
subject: nodeId,
|
|
105
105
|
severity: 'error',
|
|
106
106
|
title: 'A comment or tag could not be saved',
|
|
107
107
|
detail: `Changes to this item's conversation are held in memory only — ${err?.message || err}`,
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
retry
|
|
108
|
+
// `{ op, nodeId }`, not a bare string: `canRetry` reads `issue.retry.op`, so
|
|
109
|
+
// the string form answered false and the Retry button never rendered — and if
|
|
110
|
+
// it had, `IssueRegistry.retry` would have thrown. Both halves were broken
|
|
111
|
+
// independently, on the one retry that matters most: the user has been told
|
|
112
|
+
// their comment saved and it exists only in memory.
|
|
113
|
+
retry: { op: 'sidecar-flush', nodeId },
|
|
111
114
|
}).catch(() => {});
|
|
112
115
|
}
|
|
113
116
|
});
|
|
@@ -189,7 +192,7 @@ export class SidecarManager {
|
|
|
189
192
|
* transient storage blip into silent data loss a minute later.
|
|
190
193
|
*/
|
|
191
194
|
async sweep() {
|
|
192
|
-
const cutoff = now() - this.idleEvictMs;
|
|
195
|
+
const cutoff = Date.now() - this.idleEvictMs;
|
|
193
196
|
for (const [id, e] of this.hot) {
|
|
194
197
|
if (e.lastAccess >= cutoff || e.timer) continue;
|
|
195
198
|
if (e.dirty) {
|
|
@@ -229,11 +232,3 @@ export class SidecarManager {
|
|
|
229
232
|
return result;
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
|
-
|
|
233
|
-
function now() {
|
|
234
|
-
try {
|
|
235
|
-
return Date.now();
|
|
236
|
-
} catch {
|
|
237
|
-
return 0;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
HEADER_BYTES as ENVELOPE_HEAD,
|
|
20
20
|
} from './encryption/envelope.js';
|
|
21
21
|
import { fromHex, toHex } from './encryption/keys.js';
|
|
22
|
-
import { shouldEncrypt } from './encryption/policy.js';
|
|
23
22
|
|
|
24
23
|
export const DEFAULT_PART_SIZE = 8 * 1024 * 1024; // 8 MiB
|
|
25
24
|
const MIN_MULTIPART_PART = 5 * 1024 * 1024; // S3 floor (except final part)
|
|
@@ -147,14 +146,28 @@ export class UploadManager {
|
|
|
147
146
|
* @param {object} [deps.sessions] session store (defaults in-memory)
|
|
148
147
|
* @param {number} [deps.partSize]
|
|
149
148
|
*/
|
|
150
|
-
constructor({ storage, storageFor, sessions,
|
|
149
|
+
constructor({ storage, storageFor, sessions, sealingFor, keyFor, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
|
|
151
150
|
// Either a single backend, or a resolver keyed by collectionId (collections).
|
|
152
151
|
this.storageFor = storageFor ?? (async () => storage);
|
|
153
152
|
this.sessions = sessions ?? new MemorySessionStore();
|
|
154
|
-
//
|
|
155
|
-
// `(collectionId) => {
|
|
156
|
-
//
|
|
157
|
-
|
|
153
|
+
// Whether this item gets sealed and with what:
|
|
154
|
+
// `(collectionId, name, contentType) => { key, fingerprint, chunkSize } | null`.
|
|
155
|
+
//
|
|
156
|
+
// INJECTED rather than decided here, because there was a second implementation of the
|
|
157
|
+
// same question and the two disagreed in the direction that matters. This one used to
|
|
158
|
+
// ask for a collection-wide policy and apply `shouldEncrypt` itself, and a miss on the
|
|
159
|
+
// key answered `null` — which made the upload session PLAINTEXT in a collection set up
|
|
160
|
+
// to encrypt. `Vfs.#sealingFor`, the other implementation, throws. `#assertSealed` is
|
|
161
|
+
// the guard built to stop exactly this and it is gated on `s.encrypted`, so it never
|
|
162
|
+
// ran on the fail-open path. There is one implementation now and it fails closed.
|
|
163
|
+
//
|
|
164
|
+
// Absent means nothing is encrypted, which is what a Vfs-less deployment is.
|
|
165
|
+
this.sealingFor = sealingFor ?? (async () => null);
|
|
166
|
+
// The key a FINGERPRINT names, for sealing the remaining parts of an upload that has
|
|
167
|
+
// already decided. Not `sealingFor` again: that answers with whatever key is current,
|
|
168
|
+
// and a rotation starting mid-upload makes the current key a different one — parts
|
|
169
|
+
// sealed under it while the header names the old fingerprint decrypt as corruption.
|
|
170
|
+
this.keyFor = keyFor ?? (async () => null);
|
|
158
171
|
this.partSize = partSize;
|
|
159
172
|
this.maxBytes = maxBytes || null; // per-file quota (null = unbounded)
|
|
160
173
|
}
|
|
@@ -197,9 +210,9 @@ export class UploadManager {
|
|
|
197
210
|
// plus an authentication tag per chunk. Planning multipart boundaries against the
|
|
198
211
|
// plaintext size is short by exactly that, which is the difference between a final part
|
|
199
212
|
// that exists and one that does not.
|
|
200
|
-
const
|
|
201
|
-
const encrypting = !!
|
|
202
|
-
const chunkSize =
|
|
213
|
+
const sealing = await this.sealingFor(collectionId, req.name, contentType);
|
|
214
|
+
const encrypting = !!sealing;
|
|
215
|
+
const chunkSize = sealing?.chunkSize || DEFAULT_CHUNK_SIZE;
|
|
203
216
|
const storedSize = encrypting ? cipherSize(req.size, chunkSize) : req.size;
|
|
204
217
|
|
|
205
218
|
// The per-file limit is checked against what will be STORED, and checked here rather
|
|
@@ -245,7 +258,7 @@ export class UploadManager {
|
|
|
245
258
|
// may arrive in any order — see `#sealPart`. Generating it per part would reuse
|
|
246
259
|
// nonces across the object, which AES-GCM does not survive.
|
|
247
260
|
noncePrefix: encrypting ? toHex(crypto.getRandomValues(new Uint8Array(8))) : null,
|
|
248
|
-
keyFingerprint: encrypting ?
|
|
261
|
+
keyFingerprint: encrypting ? toHex(sealing.fingerprint) : null,
|
|
249
262
|
contentType,
|
|
250
263
|
createdAt: Date.now(),
|
|
251
264
|
strategy: null,
|
|
@@ -279,7 +292,9 @@ export class UploadManager {
|
|
|
279
292
|
const wireSize = encrypting ? req.size : storedSize;
|
|
280
293
|
const partCount = Math.max(1, Math.ceil(wireSize / this.partSize));
|
|
281
294
|
// A part must hold a whole number of chunks, or the chunk a part boundary lands
|
|
282
|
-
// inside would be sealed twice under two different indices.
|
|
295
|
+
// inside would be sealed twice under two different indices. Reachable, unlike a
|
|
296
|
+
// per-collection chunk size: `partSize` really is injectable (`uploadPartSize`), and
|
|
297
|
+
// the chunk size is fixed, so this checks the half a deployment can get wrong.
|
|
283
298
|
if (encrypting && this.partSize % chunkSize !== 0) {
|
|
284
299
|
throw TroveError.internal(
|
|
285
300
|
`Part size ${this.partSize} is not a multiple of the ${chunkSize}-byte chunk size`,
|
|
@@ -466,10 +481,16 @@ export class UploadManager {
|
|
|
466
481
|
return prependBytes(header, sealed);
|
|
467
482
|
}
|
|
468
483
|
|
|
469
|
-
/**
|
|
484
|
+
/**
|
|
485
|
+
* The key this SESSION decided on, for sealing. Never leaves this process.
|
|
486
|
+
*
|
|
487
|
+
* By fingerprint, not "whatever the collection encrypts with now": the fingerprint was
|
|
488
|
+
* pinned at negotiation because the collection's rules and its current key can both
|
|
489
|
+
* change while an upload is in flight, and half an object sealed under each is
|
|
490
|
+
* unreadable in a way nothing reports.
|
|
491
|
+
*/
|
|
470
492
|
async #dataKeyFor(s) {
|
|
471
|
-
const
|
|
472
|
-
const key = policy?.dataKeyHex ? fromHex(policy.dataKeyHex) : null;
|
|
493
|
+
const key = await this.keyFor(s.collectionId, s.keyFingerprint);
|
|
473
494
|
if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
|
|
474
495
|
return key;
|
|
475
496
|
}
|
|
@@ -598,7 +619,11 @@ export class UploadManager {
|
|
|
598
619
|
* @returns {Promise<{aborted: number, failed: number}>}
|
|
599
620
|
*/
|
|
600
621
|
async sweepExpired(now = Date.now()) {
|
|
601
|
-
|
|
622
|
+
// Not `expired?.()`. Both session stores define it and it is part of the store
|
|
623
|
+
// interface — the optional call is the shape server/src/index.js records as having
|
|
624
|
+
// turned "evict idle documents" into a permanent no-op, because a name that stopped
|
|
625
|
+
// existing looked exactly like a name that was never required.
|
|
626
|
+
const ids = (await this.sessions.expired(now)) || [];
|
|
602
627
|
let aborted = 0;
|
|
603
628
|
let failed = 0;
|
|
604
629
|
for (const id of ids) {
|