@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.
Files changed (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -0,0 +1,101 @@
1
+ // What encryption does not protect you from, said plainly and per collection.
2
+ //
3
+ // Encryption here defends the BUCKET. Anything that indexes a file sees it in the clear,
4
+ // because indexing is by definition reading the contents — the server decrypts before
5
+ // handing bytes to an indexer, and that is the whole reason full search still works on an
6
+ // encrypted collection. So a badge saying "encrypted" is true and, on its own, misleading.
7
+ //
8
+ // The disclosure that matters is therefore not about encryption at all. It is: which things
9
+ // read your files, which of them are third-party code, and where that code is allowed to
10
+ // send what it reads. A built-in indexer runs in this drive and talks to nobody. A plugin
11
+ // indexer might be pointed at an external API, and the manifest already says which one.
12
+ //
13
+ // Read off the manifest rather than written as prose. A sentence in a settings page drifts
14
+ // from what a plugin is actually permitted the moment either changes; a list derived from
15
+ // the declaration it is enforced against cannot. If it says a plugin may reach one host,
16
+ // that is because the plugin may reach exactly that host.
17
+
18
+ /**
19
+ * @typedef {object} IndexerExposure
20
+ * @property {string} id
21
+ * @property {string} name
22
+ * @property {'built-in'|'plugin'} source
23
+ * @property {string|null} pluginId
24
+ * @property {string[]} endpoints where this one may send what it reads; empty means nowhere
25
+ */
26
+
27
+ /**
28
+ * Who reads the files in this collection, and where it can send them.
29
+ *
30
+ * @param {object} deps
31
+ * @param {Array<{id: string, displayName?: string}>} deps.indexers what will run
32
+ * @param {Array<object>} [deps.plugins] installed plugin records, with manifests
33
+ * @param {(manifest: object) => string[]} [deps.endpointsOf] how to read declared egress
34
+ * @param {object|null} [deps.encryption] the collection's encryption config
35
+ */
36
+ export function describeExposure({ indexers = [], plugins = [], endpointsOf = null, encryption = null } = {}) {
37
+ // Defaulting this to `() => []` would have every plugin report "reaches nowhere" whenever
38
+ // a caller forgot to wire it — an affirmative safety claim made with no evidence, which
39
+ // is the same mistake as calling an unresolved plugin built-in. No reader means unknown.
40
+ const readEndpoints = typeof endpointsOf === 'function' ? endpointsOf : null;
41
+ // A plugin indexer's id is a contribution URI — `trove+contrib:<domain>/<name>/<what>` —
42
+ // so the plugin it belongs to is derivable from the id rather than tracked separately.
43
+ const byId = new Map();
44
+ for (const p of plugins) {
45
+ const id = p.id || p.manifest?.name;
46
+ if (id) byId.set(id, p);
47
+ }
48
+
49
+ const rows = indexers.map((i) => {
50
+ // Whether something is a plugin is decided by its ID, not by whether we managed to find
51
+ // its install record. An indexer whose plugin we cannot resolve is still third-party
52
+ // code, and calling it built-in would be the most dangerous mislabel available here.
53
+ const contributed = String(i.id || '').startsWith('trove+contrib:');
54
+ const owner = contributed ? pluginOf(i.id, byId) : null;
55
+ return {
56
+ id: i.id,
57
+ name: i.displayName || i.id,
58
+ source: contributed ? 'plugin' : 'built-in',
59
+ pluginId: owner ? (owner.id || owner.manifest?.name || null) : null,
60
+ // `[]` is an affirmative claim that this reaches nowhere. Without a manifest we
61
+ // cannot make it, so an unresolved plugin gets `null` — unknown — and is counted
62
+ // among the things that might send data out rather than among the things that cannot.
63
+ endpoints: owner && readEndpoints ? [...new Set(readEndpoints(owner.manifest) || [])] : (contributed ? null : []),
64
+ };
65
+ });
66
+
67
+ const reachOut = rows.filter((r) => r.endpoints === null || r.endpoints.length);
68
+ const unknown = rows.filter((r) => r.endpoints === null);
69
+ return {
70
+ encrypted: !!encryption?.enabled,
71
+ // Said explicitly, because "encrypted" without a scope is the thing people
72
+ // over-read. This is what the encryption is and is not.
73
+ protects: encryption?.enabled
74
+ ? 'Files are encrypted before they reach the storage provider, so the bucket holds ciphertext. '
75
+ + 'It is not end-to-end: this drive holds the key, and anything that indexes a file reads it in the clear.'
76
+ : null,
77
+ indexers: rows,
78
+ // The single fact someone should be able to see without reading a list.
79
+ anyEgress: reachOut.length > 0,
80
+ egressSummary: reachOut.length
81
+ ? `${reachOut.length} of ${rows.length} indexers may send file contents outside this drive.`
82
+ + (unknown.length
83
+ ? ` ${unknown.length} could not be checked, because the plugin that provides it is not installed here.`
84
+ : '')
85
+ : rows.length
86
+ ? 'No indexer on this collection may send file contents anywhere.'
87
+ : 'Nothing indexes this collection.',
88
+ };
89
+ }
90
+
91
+ /** Which installed plugin an indexer id belongs to, if any. */
92
+ function pluginOf(indexerId, byId) {
93
+ const id = String(indexerId || '');
94
+ if (!id.startsWith('trove+contrib:')) return null;
95
+ // `trove+contrib:<domain>/<name>/<contribution>` — the plugin is domain/name.
96
+ const path = id.slice('trove+contrib:'.length);
97
+ const parts = path.split('/');
98
+ if (parts.length < 2) return null;
99
+ const owner = `${parts[0]}/${parts[1]}`;
100
+ return byId.get(owner) || byId.get(parts[1]) || null;
101
+ }
@@ -0,0 +1,88 @@
1
+ // The key a collection's objects are encrypted with, and the fingerprint that names it.
2
+ //
3
+ // The threat model is the STORAGE HOST, and only that: the bucket holds ciphertext, so a
4
+ // leaked bucket credential, a public-bucket misconfiguration, or a storage vendor who is
5
+ // not the compute vendor learns sizes and timestamps and no content. The server holds the
6
+ // key — it has to, in order to hand it to a client and to decrypt for an indexer — so this
7
+ // is explicitly not end-to-end and does not pretend to be.
8
+ //
9
+ // Which is why the key is GENERATED rather than derived from something a user types.
10
+ // A passphrase would buy nothing here: the server knows the key either way, so there is no
11
+ // protection to gain from the user holding it, and every cost still applies — a slow KDF
12
+ // on every unlock, a prompt in front of every collection, a key that can be forgotten and
13
+ // then cannot be reset by anyone, and a re-encryption of everything whenever someone
14
+ // changes their password. A random 256-bit key has none of that and is stronger than any
15
+ // passphrase a person would choose.
16
+ //
17
+ // Access to the key is therefore an ACCESS-CONTROL question, not a knowledge one: whoever
18
+ // may read the collection may have the key, because they may already read its contents.
19
+ //
20
+ // If this ever becomes end-to-end, the change is confined to where the key comes from and
21
+ // who is allowed it. The envelope, the fingerprint, and every stored object are unchanged.
22
+
23
+ import { TroveError } from '../errors.js';
24
+
25
+ const enc = new TextEncoder();
26
+
27
+ export const KEY_BYTES = 32; // AES-256
28
+ export const FINGERPRINT_BYTES = 16;
29
+
30
+ /** A new collection key. Random, because there is nothing to derive it from. */
31
+ export function generateDataKey() {
32
+ return crypto.getRandomValues(new Uint8Array(KEY_BYTES));
33
+ }
34
+
35
+ /**
36
+ * The public name of a key.
37
+ *
38
+ * Stamped into every object and recorded on the collection, so an object can be matched to
39
+ * a key by whoever holds it — which is what makes a sideloaded object identifiable and
40
+ * what lets a key rotation tell what it has already converted.
41
+ *
42
+ * Derived through HKDF rather than being a plain hash of the key. With a random 256-bit key
43
+ * there is nothing to guess, so this is no longer load-bearing against an offline attack;
44
+ * it stays because a fingerprint should be a value derived FOR this purpose, and a bare
45
+ * `SHA-256(key)` is a value that might mean something somewhere else. The label makes it
46
+ * unambiguously this and nothing else.
47
+ *
48
+ * @param {Uint8Array} dataKey
49
+ * @returns {Promise<Uint8Array>} 16 bytes — far past collision risk for "which key is this"
50
+ */
51
+ export async function fingerprint(dataKey) {
52
+ if (!(dataKey instanceof Uint8Array) || dataKey.length !== KEY_BYTES) {
53
+ throw TroveError.invalid('A data key must be 32 bytes');
54
+ }
55
+ const base = await crypto.subtle.importKey('raw', dataKey, 'HKDF', false, ['deriveBits']);
56
+ const bits = await crypto.subtle.deriveBits(
57
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode('trove-key-id') },
58
+ base,
59
+ FINGERPRINT_BYTES * 8,
60
+ );
61
+ return new Uint8Array(bits);
62
+ }
63
+
64
+ /** Bytes as lowercase hex — how a key and a fingerprint are written down. */
65
+ export function toHex(b) {
66
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
67
+ }
68
+
69
+ export function fromHex(hex) {
70
+ if (typeof hex !== 'string' || !hex.length || hex.length % 2 || /[^0-9a-f]/i.test(hex)) {
71
+ throw TroveError.invalid('Not hex');
72
+ }
73
+ return new Uint8Array(hex.match(/../g).map((h) => parseInt(h, 16)));
74
+ }
75
+
76
+ /** Kept as the name the rest of the code already uses for a fingerprint in hex. */
77
+ export const fingerprintHex = toHex;
78
+
79
+ /**
80
+ * A fresh key and what the collection records about it.
81
+ *
82
+ * The key is returned separately from the config because they go to different places: the
83
+ * config is what any reader may see, and the key is what the server keeps.
84
+ */
85
+ export async function newCollectionKey() {
86
+ const dataKey = generateDataKey();
87
+ return { dataKey, config: { fingerprint: toHex(await fingerprint(dataKey)) } };
88
+ }
@@ -0,0 +1,112 @@
1
+ // Which items in a collection get encrypted, and what the collection tells a client
2
+ // about its key.
3
+ //
4
+ // Encryption is per collection and selective within it, because "encrypt everything" is
5
+ // not always what someone wants and the cost is not free: an encrypted object cannot be
6
+ // served straight from the bucket to something that does not hold the key, and the storage
7
+ // host can no longer deduplicate it. So a collection says which extensions and which media
8
+ // types are sensitive, and the rest is stored as it always was.
9
+ //
10
+ // The rules are matched at UPLOAD time and the answer is recorded in the object itself —
11
+ // the envelope header — rather than re-derived later. Rules change; an object that was
12
+ // encrypted must stay readable as an encrypted object regardless of what the collection
13
+ // says today, and one that was not must not suddenly be interpreted as one.
14
+
15
+ import { TroveError } from '../errors.js';
16
+
17
+ /**
18
+ * What a collection stores about its encryption.
19
+ *
20
+ * The fingerprint is safe to show anyone who may see the collection: it names the key
21
+ * without being it. The key itself is never part of this — it reaches a client through a
22
+ * transfer plan, which is authorized per operation.
23
+ *
24
+ * @typedef {object} EncryptionConfig
25
+ * @property {boolean} enabled
26
+ * @property {string} fingerprint hex — which key this collection's objects are sealed with
27
+ * @property {{extensions: string[], mimeTypes: string[], all: boolean}} rules
28
+ */
29
+
30
+ const normList = (v) => (Array.isArray(v) ? v : [])
31
+ .map((s) => String(s || '').trim().toLowerCase())
32
+ .filter(Boolean);
33
+
34
+ /** An extension without its dot, so ".PDF", "PDF" and "pdf" are one rule. */
35
+ const normExt = (e) => e.replace(/^\./, '');
36
+
37
+ /**
38
+ * Validate and normalise what a caller asked for, against the key the server holds.
39
+ *
40
+ * The fingerprint is a separate argument rather than a field of `input` because the two
41
+ * come from different places and only one of them is the caller's to decide: rules are
42
+ * asked for, the key is minted. A collection recorded as encrypted with no fingerprint is
43
+ * one whose objects could never be matched to a key, so it is refused rather than repaired.
44
+ *
45
+ * @param {object|null} input what the caller asked for: `{ enabled, rules }`
46
+ * @param {string} [fingerprint] hex, from the collection's key
47
+ */
48
+ export function normalizeEncryption(input, fingerprint) {
49
+ if (!input || input.enabled === false) return null;
50
+ const fp = fingerprint ?? input.fingerprint;
51
+ if (!fp) throw TroveError.invalid('An encrypted collection needs a key fingerprint');
52
+ if (!/^[0-9a-f]{32}$/.test(String(fp))) {
53
+ throw TroveError.invalid('Not a key fingerprint');
54
+ }
55
+ const r = input.rules || {};
56
+ const out = {
57
+ enabled: true,
58
+ fingerprint: String(fp),
59
+ rules: {
60
+ all: !!r.all,
61
+ extensions: [...new Set(normList(r.extensions).map(normExt))],
62
+ mimeTypes: [...new Set(normList(r.mimeTypes))],
63
+ },
64
+ };
65
+ if (!out.rules.all && !out.rules.extensions.length && !out.rules.mimeTypes.length) {
66
+ // Enabling encryption and matching nothing is almost certainly a mistake, and a silent
67
+ // one: every upload would be stored in the clear on a collection labelled encrypted.
68
+ throw TroveError.invalid(
69
+ 'This collection is set to encrypt, but no file would match. Choose "all files", or name some extensions or media types.',
70
+ );
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Should this item be encrypted?
77
+ *
78
+ * A media type match is by full type or by its leading part, so `image` covers
79
+ * `image/png` without listing every format — which is how someone actually thinks about
80
+ * "encrypt my photos".
81
+ */
82
+ export function shouldEncrypt(encryption, { name = '', contentType = '' } = {}) {
83
+ if (!encryption?.enabled) return false;
84
+ const { rules } = encryption;
85
+ if (rules.all) return true;
86
+
87
+ const ext = normExt((String(name).match(/\.[^./\\]+$/) || [''])[0].toLowerCase());
88
+ if (ext && rules.extensions.includes(ext)) return true;
89
+
90
+ const type = String(contentType || '').toLowerCase().split(';')[0].trim();
91
+ if (!type) return false;
92
+ if (rules.mimeTypes.includes(type)) return true;
93
+ const [top] = type.split('/');
94
+ return !!top && rules.mimeTypes.includes(top);
95
+ }
96
+
97
+ /**
98
+ * What a client is told about a collection's encryption.
99
+ *
100
+ * Enough to know that objects here are sealed and which key seals them; never the key.
101
+ * There is no "locked" state and nothing to prompt for — a client that may read the
102
+ * collection is handed the key with the transfer plan, because being allowed to read the
103
+ * contents and being allowed to decrypt them are the same permission.
104
+ */
105
+ export function describeEncryption(encryption) {
106
+ if (!encryption?.enabled) return null;
107
+ return {
108
+ enabled: true,
109
+ fingerprint: encryption.fingerprint,
110
+ rules: { ...encryption.rules },
111
+ };
112
+ }