@3sln/trove 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
import { TroveError } from './errors.js';
|
|
24
24
|
import { extname } from './util.js';
|
|
25
25
|
import { PACKAGE_PREFIX } from './plugins/packageStore.js';
|
|
26
|
+
import { isEnvelope, decodeHeader, HEADER_BYTES } from './encryption/envelope.js';
|
|
27
|
+
import { toHex } from './encryption/keys.js';
|
|
26
28
|
|
|
27
29
|
/** Objects Trove wrote itself. Anything else in the store arrived some other way. */
|
|
28
30
|
const TROVE_KEY = /^obj_[0-9a-f]+$/i;
|
|
@@ -184,6 +186,26 @@ export class CollectionScanner {
|
|
|
184
186
|
return false;
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read an object's envelope header, or null if it does not have one.
|
|
192
|
+
*
|
|
193
|
+
* A fixed-size range read, so it costs one small request per adopted object and never
|
|
194
|
+
* pulls the object itself. A failure to read or parse means "not one of ours", which is
|
|
195
|
+
* the right answer for an ordinary file that happens to start with something odd.
|
|
196
|
+
*/
|
|
197
|
+
async #envelopeOf(collectionId, storageKey) {
|
|
198
|
+
try {
|
|
199
|
+
const storage = await this.vfs.storageFor(collectionId);
|
|
200
|
+
const head = await storage.get(storageKey, { range: { start: 0, end: HEADER_BYTES - 1 } });
|
|
201
|
+
const bytes = new Uint8Array(await new Response(head.stream).arrayBuffer());
|
|
202
|
+
if (!isEnvelope(bytes)) return null;
|
|
203
|
+
return decodeHeader(bytes);
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
187
209
|
/**
|
|
188
210
|
* Create an item for an object that arrived without us.
|
|
189
211
|
*
|
|
@@ -196,13 +218,25 @@ export class CollectionScanner {
|
|
|
196
218
|
async #adopt(collectionId, object) {
|
|
197
219
|
if (TROVE_KEY.test(object.key)) return null; // orphaned blob from an interrupted upload
|
|
198
220
|
const name = await this.#uniqueName(collectionId, object.key);
|
|
221
|
+
// Is this an encrypted object somebody copied in?
|
|
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);
|
|
199
229
|
const node = await this.vfs.metadata.create({
|
|
200
230
|
collectionId,
|
|
201
231
|
name,
|
|
202
232
|
storageKey: object.key,
|
|
203
|
-
size
|
|
233
|
+
// The size the file has, not the size the envelope occupies.
|
|
234
|
+
size: envelope ? envelope.plaintextSize : (object.size ?? 0),
|
|
204
235
|
etag: object.etag ?? null,
|
|
205
236
|
contentType: this.vfs.guessContentType(name),
|
|
237
|
+
encryption: envelope
|
|
238
|
+
? { fingerprint: toHex(envelope.fingerprint), chunkSize: envelope.chunkSize }
|
|
239
|
+
: null,
|
|
206
240
|
meta: { adopted: true, adoptedAt: Date.now() },
|
|
207
241
|
});
|
|
208
242
|
// Adopted files are indexed like any other, so they are findable immediately —
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// What a bulk pass over a collection will cost at the storage provider.
|
|
2
|
+
//
|
|
3
|
+
// Re-encrypting a collection is the first operation here big enough for the answer to
|
|
4
|
+
// matter: the server reads every object and writes it back, so on a metered store it is a
|
|
5
|
+
// real bill, and on a large collection it can be a surprising one. Someone should be able
|
|
6
|
+
// to see the number before they press the button, not on an invoice.
|
|
7
|
+
//
|
|
8
|
+
// Two things shape how this is written.
|
|
9
|
+
//
|
|
10
|
+
// THE MODEL OUTLASTS THE NUMBERS. "R2 does not charge for egress, so this costs operations
|
|
11
|
+
// only" stays true long after any per-GB figure is stale. So every provider carries a
|
|
12
|
+
// plain-language description of WHAT is charged, and that is what leads; the rates are
|
|
13
|
+
// supporting detail, stamped with the date they were taken and linked to the source.
|
|
14
|
+
//
|
|
15
|
+
// WE WILL BE WRONG EVENTUALLY. Prices change, free tiers change, and a drive can be years
|
|
16
|
+
// old. Nothing here is presented as authoritative: an estimate says where its figures came
|
|
17
|
+
// from and when, and every answer links the provider's own pricing page. An unrecognised
|
|
18
|
+
// endpoint gets an honest description of how object stores usually bill rather than a
|
|
19
|
+
// fabricated number.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* When these rates were last checked. Shown with every estimate, because a figure without
|
|
23
|
+
* a date invites being trusted longer than it deserves.
|
|
24
|
+
*/
|
|
25
|
+
export const RATES_AS_OF = '2026-07';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What we think we know, per provider.
|
|
29
|
+
*
|
|
30
|
+
* `egress` is the field that actually decides whether a rotation is cheap or expensive, and
|
|
31
|
+
* it is the one worth getting right: a store that does not charge for reads out is a store
|
|
32
|
+
* where this operation costs pennies regardless of size.
|
|
33
|
+
*/
|
|
34
|
+
const PROVIDERS = [
|
|
35
|
+
{
|
|
36
|
+
id: 'r2',
|
|
37
|
+
name: 'Cloudflare R2',
|
|
38
|
+
match: /\.r2\.cloudflarestorage\.com$/i,
|
|
39
|
+
docs: 'https://developers.cloudflare.com/r2/pricing/',
|
|
40
|
+
egress: 'none',
|
|
41
|
+
egressNote: 'R2 does not charge for data transferred out, which is the cost that would otherwise dominate this.',
|
|
42
|
+
// Class B is reads, Class A is writes.
|
|
43
|
+
readPerMillion: 0.36,
|
|
44
|
+
writePerMillion: 4.50,
|
|
45
|
+
egressPerGB: 0,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'aws',
|
|
49
|
+
name: 'Amazon S3',
|
|
50
|
+
// Covers the regional forms (s3.eu-west-2, s3-us-west-2), the legacy global endpoint
|
|
51
|
+
// (s3.amazonaws.com), and virtual-host style (bucket.s3.region.amazonaws.com).
|
|
52
|
+
match: /(^|\.)s3([.-][a-z0-9-]+)?\.amazonaws\.com$/i,
|
|
53
|
+
docs: 'https://aws.amazon.com/s3/pricing/',
|
|
54
|
+
egress: 'metered',
|
|
55
|
+
// The nuance that changes the answer by orders of magnitude, and the one people are
|
|
56
|
+
// most often caught by.
|
|
57
|
+
egressNote:
|
|
58
|
+
'S3 charges for data transferred out to the internet. If this drive runs outside AWS '
|
|
59
|
+
+ '(on Cloudflare Workers, for example) every byte read is billed at that rate; if it runs '
|
|
60
|
+
+ 'in EC2 or Lambda in the same region as the bucket, transfer is normally free and this '
|
|
61
|
+
+ 'costs requests only.',
|
|
62
|
+
readPerMillion: 400,
|
|
63
|
+
writePerMillion: 5000,
|
|
64
|
+
egressPerGB: 0.09,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'b2',
|
|
68
|
+
name: 'Backblaze B2',
|
|
69
|
+
match: /(^|\.)backblazeb2\.com$/i,
|
|
70
|
+
docs: 'https://www.backblaze.com/cloud-storage/pricing',
|
|
71
|
+
egress: 'allowance',
|
|
72
|
+
egressNote:
|
|
73
|
+
'B2 includes free egress up to a multiple of what you store, and charges beyond it. A '
|
|
74
|
+
+ 'one-off pass over a collection often falls inside that allowance.',
|
|
75
|
+
readPerMillion: 4,
|
|
76
|
+
writePerMillion: 0,
|
|
77
|
+
egressPerGB: 0.01,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'wasabi',
|
|
81
|
+
name: 'Wasabi',
|
|
82
|
+
match: /(^|\.)wasabisys\.com$/i,
|
|
83
|
+
docs: 'https://wasabi.com/cloud-storage-pricing',
|
|
84
|
+
egress: 'none',
|
|
85
|
+
egressNote:
|
|
86
|
+
'Wasabi does not charge for egress or requests. Note its minimum storage duration, '
|
|
87
|
+
+ 'though: rewriting an object can restart the clock on what the original was charged for.',
|
|
88
|
+
readPerMillion: 0,
|
|
89
|
+
writePerMillion: 0,
|
|
90
|
+
egressPerGB: 0,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'spaces',
|
|
94
|
+
name: 'DigitalOcean Spaces',
|
|
95
|
+
match: /(^|\.)digitaloceanspaces\.com$/i,
|
|
96
|
+
docs: 'https://www.digitalocean.com/pricing/spaces-object-storage',
|
|
97
|
+
egress: 'allowance',
|
|
98
|
+
egressNote: 'Spaces includes a monthly transfer allowance and charges per GB beyond it.',
|
|
99
|
+
readPerMillion: 0,
|
|
100
|
+
writePerMillion: 0,
|
|
101
|
+
egressPerGB: 0.01,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'gcs',
|
|
105
|
+
name: 'Google Cloud Storage',
|
|
106
|
+
match: /(^|\.)storage\.googleapis\.com$/i,
|
|
107
|
+
docs: 'https://cloud.google.com/storage/pricing',
|
|
108
|
+
egress: 'metered',
|
|
109
|
+
egressNote:
|
|
110
|
+
'GCS charges for data read out to the internet, and for operations. Reading from within '
|
|
111
|
+
+ 'Google Cloud in the same region is normally free.',
|
|
112
|
+
readPerMillion: 400,
|
|
113
|
+
writePerMillion: 5000,
|
|
114
|
+
egressPerGB: 0.12,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Stores where this question does not arise.
|
|
120
|
+
*
|
|
121
|
+
* A directory on a disk and a NATS object store are not metered by anyone, so offering a
|
|
122
|
+
* cost estimate would be inventing a concern.
|
|
123
|
+
*/
|
|
124
|
+
const UNMETERED_DRIVERS = new Set(['filesystem', 'memory', 'nats']);
|
|
125
|
+
|
|
126
|
+
/** Which provider an endpoint belongs to, or null if we do not recognise it. */
|
|
127
|
+
export function recognizeProvider(endpoint) {
|
|
128
|
+
if (!endpoint) return null;
|
|
129
|
+
let host;
|
|
130
|
+
try {
|
|
131
|
+
host = new URL(endpoint).hostname;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return PROVIDERS.find((p) => p.match.test(host)) || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const gb = (bytes) => bytes / 1024 ** 3;
|
|
139
|
+
const money = (n) => Math.round(n * 100) / 100;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What re-encrypting a collection will cost.
|
|
143
|
+
*
|
|
144
|
+
* The work is one read and one write per object, plus the bytes moving out of the bucket
|
|
145
|
+
* and back in. Ingress is free essentially everywhere, so it is named and not priced.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} target
|
|
148
|
+
* @param {string} target.driver the collection's store driver
|
|
149
|
+
* @param {string} [target.endpoint] its S3 endpoint, if it has one
|
|
150
|
+
* @param {number} objects how many objects will be rewritten
|
|
151
|
+
* @param {number} bytes how many bytes they hold
|
|
152
|
+
*/
|
|
153
|
+
export function estimateRotationCost({ driver, endpoint } = {}, { objects = 0, bytes = 0 } = {}) {
|
|
154
|
+
if (UNMETERED_DRIVERS.has(driver)) {
|
|
155
|
+
return {
|
|
156
|
+
applicable: false,
|
|
157
|
+
provider: null,
|
|
158
|
+
summary: 'This collection is on storage nobody bills you for, so a rotation costs only the time it takes.',
|
|
159
|
+
lines: [],
|
|
160
|
+
total: null,
|
|
161
|
+
docs: null,
|
|
162
|
+
asOf: RATES_AS_OF,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const p = recognizeProvider(endpoint);
|
|
167
|
+
const work = [
|
|
168
|
+
{ label: 'Objects read and rewritten', value: `${objects.toLocaleString()}` },
|
|
169
|
+
{ label: 'Data moved out of the bucket', value: `${gb(bytes).toFixed(2)} GB` },
|
|
170
|
+
// Named rather than priced: essentially no object store charges to receive bytes, and
|
|
171
|
+
// an estimate that lists a zero invites the reader to wonder what it is hiding.
|
|
172
|
+
{ label: 'Data written back', value: `${gb(bytes).toFixed(2)} GB (ingress is not normally charged)` },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
if (!p) {
|
|
176
|
+
// Honest rather than invented. Describing how these services usually bill is more use
|
|
177
|
+
// than a number we made up for a host we have never seen.
|
|
178
|
+
return {
|
|
179
|
+
applicable: true,
|
|
180
|
+
provider: null,
|
|
181
|
+
confidence: 'unknown',
|
|
182
|
+
summary:
|
|
183
|
+
'We do not recognise this storage endpoint, so we cannot estimate a price. Object stores '
|
|
184
|
+
+ 'usually charge for three things: the requests made, the data read back out, and what is '
|
|
185
|
+
+ 'stored. A rotation makes one read and one write per object and moves every byte out and '
|
|
186
|
+
+ 'back — so if your provider charges for egress, that is what will dominate.',
|
|
187
|
+
lines: work,
|
|
188
|
+
total: null,
|
|
189
|
+
docs: null,
|
|
190
|
+
asOf: RATES_AS_OF,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const egressCost = gb(bytes) * p.egressPerGB;
|
|
195
|
+
const readCost = (objects / 1e6) * p.readPerMillion;
|
|
196
|
+
const writeCost = (objects / 1e6) * p.writePerMillion;
|
|
197
|
+
const total = egressCost + readCost + writeCost;
|
|
198
|
+
|
|
199
|
+
const lines = [
|
|
200
|
+
...work,
|
|
201
|
+
{ label: 'Reads', value: `$${money(readCost).toFixed(2)}` },
|
|
202
|
+
{ label: 'Writes', value: `$${money(writeCost).toFixed(2)}` },
|
|
203
|
+
{
|
|
204
|
+
label: 'Egress',
|
|
205
|
+
value: p.egress === 'none' ? 'not charged' : `$${money(egressCost).toFixed(2)}`,
|
|
206
|
+
note: p.egressNote,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
applicable: true,
|
|
212
|
+
provider: p.name,
|
|
213
|
+
confidence: 'known',
|
|
214
|
+
// Leads with the model, because that is the part that stays true.
|
|
215
|
+
summary: p.egress === 'none'
|
|
216
|
+
? `${p.name} does not charge for data transferred out, so this costs operations only.`
|
|
217
|
+
: `${p.name} charges for data read out of the bucket, so the size of this collection is what drives the price.`,
|
|
218
|
+
lines,
|
|
219
|
+
total: { amount: money(total), currency: 'USD', approximate: true },
|
|
220
|
+
docs: p.docs,
|
|
221
|
+
asOf: RATES_AS_OF,
|
|
222
|
+
// Said in the estimate itself, not left to the UI to remember.
|
|
223
|
+
caveat:
|
|
224
|
+
`These figures are what we recorded in ${RATES_AS_OF} and prices change — treat this as an `
|
|
225
|
+
+ `order of magnitude, not a quote, and check ${p.name}'s own pricing page for what you will `
|
|
226
|
+
+ 'actually be billed. Free tiers and committed-use discounts are not accounted for here.',
|
|
227
|
+
};
|
|
228
|
+
}
|