@3sln/trove 0.0.2

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 (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,377 @@
1
+ // PluginRpcRouter — the trusted boundary: every call a sandboxed plugin makes into
2
+ // the host lands here, is capability-gated against what the user granted at install
3
+ // time, and is then serviced against host APIs the plugin can never reach directly.
4
+ //
5
+ // The frame has NO ambient authority: no host DOM, no cookies, no network
6
+ // (connect-src 'none'), no access to its own package bytes. Everything it can do is
7
+ // one of the methods below, and each one that touches something sensitive calls
8
+ // `cap(...)` first. Extracted from PluginHost so the allow/deny logic is one focused
9
+ // unit rather than sharing a class with iframe lifecycle and DOM placement.
10
+
11
+ import { networkEndpoints, canExecuteCommand, displayName } from './pluginPackage.js';
12
+ import { isAllowedUrl } from './pluginNet.js';
13
+ import { isSourceModule } from './pluginModules.js';
14
+ import { contribUri, parseContribUri } from '@3sln/trove/core/plugins/identity.js';
15
+ import { assertSafePluginSql } from '@3sln/trove/core/plugins/sql.js';
16
+
17
+ // Response bodies larger than this are refused, so a plugin can't exhaust host
18
+ // memory through the brokered fetch.
19
+ const MAX_FETCH_BYTES = 25 * 1024 * 1024;
20
+
21
+ /**
22
+ * Read a response body, aborting the moment it exceeds `max`.
23
+ *
24
+ * `await res.arrayBuffer()` then checking `.byteLength` does not achieve the thing the
25
+ * cap exists for: by the time the check runs the whole body is already resident, so a
26
+ * declared endpoint returning a multi-GB stream OOMs the tab before we ever refuse it.
27
+ * Check the declared length first, then enforce while streaming in case it lied.
28
+ */
29
+ async function readCappedBody(res, max) {
30
+ const declared = Number(res.headers.get('content-length') || 0);
31
+ if (declared && declared > max) throw new Error('Response too large');
32
+ const reader = res.body?.getReader?.();
33
+ if (!reader) {
34
+ const buf = await res.arrayBuffer();
35
+ if (buf.byteLength > max) throw new Error('Response too large');
36
+ return buf;
37
+ }
38
+ const chunks = [];
39
+ let total = 0;
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) break;
43
+ total += value.byteLength;
44
+ if (total > max) { await reader.cancel().catch(() => {}); throw new Error('Response too large'); }
45
+ chunks.push(value);
46
+ }
47
+ const out = new Uint8Array(total);
48
+ let at = 0;
49
+ for (const c of chunks) { out.set(c, at); at += c.byteLength; }
50
+ return out.buffer;
51
+ }
52
+
53
+ export class PluginRpcRouter {
54
+ /**
55
+ * @param {object} deps
56
+ * @param {object} deps.platform host services (api, commands, contributions, settings, …)
57
+ * @param {object} deps.clientDb on-device scoped SQLite provider
58
+ * @param {import('./pluginMedia.js').MediaController} deps.media
59
+ * @param {import('./pluginDock.js').FrameDock} deps.dock
60
+ * @param {()=>void} deps.onChange notify listeners that plugin state changed
61
+ */
62
+ constructor({ platform, clientDb, media, dock, onChange } = {}) {
63
+ this.platform = platform;
64
+ this.clientDb = clientDb;
65
+ this.media = media;
66
+ this.dock = dock;
67
+ this.onChange = onChange || (() => {});
68
+ }
69
+
70
+ // --- plugin → host RPC (calls: awaited, may return a value) -----------------
71
+
72
+ async hostCall(record, method, params, frame) {
73
+ const cap = (c) => { if (!record.grants.includes(c)) throw new Error(`Capability "${c}" not granted`); };
74
+ const pid = record.id;
75
+ switch (method) {
76
+ case 'activated': {
77
+ const f = frame || record.frame;
78
+ clearTimeout(f?._timer);
79
+ if (params.ok) f?.resolveActivated?.(f);
80
+ else f?.rejectActivated?.(new Error(params.error || 'activate() failed'));
81
+ return { ok: true };
82
+ }
83
+
84
+ // Run a command (the SDK's ctx.commands.execute). Gated per COMMAND, not per
85
+ // capability: the plugin's manifest lists exactly which commands it may run
86
+ // (plus its own, implicitly). A blanket "commands" grant would let a plugin that
87
+ // wanted `workbench.view.home` also call `explorer.delete`.
88
+ case 'command:execute': {
89
+ const target = this.#resolveCommand(record, params.id);
90
+ if (!canExecuteCommand(record.manifest, target)) {
91
+ throw new Error(`Command "${params.id}" is not in this plugin's declared commands`);
92
+ }
93
+ const result = await this.platform.commands.execute(target, ...(params.args || []));
94
+ return { ok: true, result: result ?? null };
95
+ }
96
+
97
+ // Drive a DECLARED status slot: push sanitized HTML into it, or show/hide it.
98
+ // The slot itself is a manifest contribution — this only fills one in, so a
99
+ // plugin can never grow its footprint in the shell past what was approved.
100
+ case 'ui:status': {
101
+ cap('ui');
102
+ const slot = this.#ownContribution(record, params.name, 'statusItem');
103
+ this.platform.contributions.update(slot.uri, {
104
+ ...(params.html !== undefined ? { html: String(params.html ?? '') } : {}),
105
+ ...(params.tooltip !== undefined ? { tooltip: String(params.tooltip ?? '') } : {}),
106
+ ...(params.visible !== undefined ? { visible: !!params.visible } : { visible: true }),
107
+ });
108
+ return { ok: true };
109
+ }
110
+
111
+ // Set a DECLARED register — a context value slot other contributions' when-clauses
112
+ // can read, addressed by its contribution URI.
113
+ case 'context:setRegister': {
114
+ const slot = this.#ownContribution(record, params.name, 'register');
115
+ this.platform.context.set(slot.uri, params.value);
116
+ return { ok: true };
117
+ }
118
+ // Package resources — opaque byte handles (transferred, no host URLs). Code
119
+ // under src/ and the manifest are not resources (src/ is loaded as modules).
120
+ case 'resources:list':
121
+ return [...record.files.keys()].filter(isResourcePath);
122
+ case 'resources:read': {
123
+ if (!isResourcePath(params.path)) throw new Error(`No such resource ${params.path}`);
124
+ const bytes = record.files.get(params.path);
125
+ if (!bytes) throw new Error(`No such resource ${params.path}`);
126
+ return { bytes: bytes.slice().buffer }; // copied into the iframe by structured clone
127
+ }
128
+
129
+ // Files — inherit the host's authenticated API client.
130
+ case 'files:read': return cap('files'), { text: await this.platform.api.readText(params.id) };
131
+ // ONE options object — `api.list(opts)`. Called as `list(pathOrId, params)` the
132
+ // collection became the query string's key and everything else was dropped, so a
133
+ // plugin asking for `photos` silently got the default collection unsorted.
134
+ case 'files:list': return cap('files'), this.platform.api.list({
135
+ collection: params.collection || params.pathOrId || undefined,
136
+ sort: params.sort, order: params.order, limit: params.limit, cursor: params.cursor,
137
+ });
138
+ case 'files:stat': return cap('files'), this.platform.api.stat(params.id);
139
+ case 'files:downloadUrl': return cap('files'), { url: this.platform.api.downloadUrl(params.id) };
140
+ case 'files:index': {
141
+ cap('indexer');
142
+ const ns = parseContribUri(params.indexerId) ? params.indexerId : contribUri(record.manifest, params.indexerId || 'default');
143
+ return this.platform.api.pushIndex(ns, params.nodeId, {
144
+ semanticTexts: params.semanticTexts, tags: params.tags, metadata: params.metadata,
145
+ documents: params.documents, facet: params.facet, // legacy
146
+ });
147
+ }
148
+
149
+ // Network — brokered by the host and confined to declared endpoints. The
150
+ // sandboxed frame has no direct network at all (connect-src 'none').
151
+ case 'net:fetch': cap('network'); return this.#brokerFetch(record, params);
152
+
153
+ // Persistent storage — an isolated SQLite database per scope (plugin/domain),
154
+ // on the server or on-device. SQL runs only against the plugin's own scoped db.
155
+ case 'storage:sql': cap('storage'); return this.#pluginSql(record, params);
156
+
157
+ // Plugin settings + secrets.
158
+ case 'settings:get': return this.platform.settings.get(`${pid}.${params.key}`);
159
+ case 'settings:getSecret': return record.secrets?.[params.key] ?? null;
160
+ case 'settings:set':
161
+ this.platform.settings.set(`${pid}.${params.key}`, params.value);
162
+ return { ok: true };
163
+
164
+ // Putting a panel on screen is a UI action, and this was the one `ui:` method with
165
+ // no check — so a plugin granted nothing at all could open its own panel (and, with
166
+ // an unvalidated displayName, render into the host page from `activate()`).
167
+ case 'ui:showPanel':
168
+ cap('ui');
169
+ record.hasUi = true;
170
+ this.platform.openPluginPanel?.(pid);
171
+ this.onChange();
172
+ return { ok: true };
173
+
174
+ // Media session — the host owns navigator.mediaSession; the calling frame
175
+ // (a viewer) surfaces its playback so the OS shows transport controls. Actions
176
+ // fire back over that same frame's RPC channel.
177
+ // Artwork is a URL the BROWSER fetches on the plugin's behalf, so it is egress
178
+ // and belongs under the same allowlist the broker enforces. Left open, `media`
179
+ // alone bought an outbound GET to any URL the plugin chose — the exfiltration
180
+ // channel `connect-src 'none'` and the broker exist to close.
181
+ case 'media:metadata': {
182
+ cap('media');
183
+ const artwork = (params.artwork || []).filter((a) => this.#artworkAllowed(record, a?.src));
184
+ return this.media.apply(frame, 'metadata', { ...params, artwork });
185
+ }
186
+ case 'media:playbackState': cap('media'); return this.media.apply(frame, 'playbackState', params);
187
+ case 'media:position': cap('media'); return this.media.apply(frame, 'position', params);
188
+ case 'media:action': cap('media'); return this.media.apply(frame, 'action', params);
189
+ case 'media:clear': return this.media.apply(frame, 'clear', params);
190
+
191
+ // Dock — the calling frame registers/unregisters itself for the floating dock.
192
+ case 'dock:enable': cap('dock'); if (frame) frame.dock = { enabled: true, minSize: params.minSize, maxSize: params.maxSize, dismissed: false }; return { ok: true };
193
+ case 'dock:disable': cap('dock'); if (frame?.dock) frame.dock.enabled = false; if (frame && this.dock.docked === frame) this.dock.closeDock(frame); return { ok: true };
194
+ case 'dock:close': if (frame) this.dock.closeDock(frame); return { ok: true };
195
+
196
+ default:
197
+ throw new Error(`Unknown host method ${method}`);
198
+ }
199
+ }
200
+
201
+ // --- plugin → host events (fire-and-forget) --------------------------------
202
+
203
+ hostEvent(record, method, params, frame) {
204
+ switch (method) {
205
+ case 'manifest':
206
+ // Only the primary frame's manifest defines the plugin's live feature list;
207
+ // a viewer frame re-announces its own (opener-only) manifest — ignore it.
208
+ if (frame && frame.role !== 'primary') break;
209
+ record.live = params; record.responsive = true;
210
+ this.onChange();
211
+ break;
212
+ case 'ui:toast':
213
+ this.platform.notifications[params.level || 'info'](`${displayName(record.manifest)}: ${params.text}`);
214
+ break;
215
+ case 'ui:badge':
216
+ record.badge = params.text; this.onChange();
217
+ break;
218
+ }
219
+ }
220
+
221
+ // --- addressing ------------------------------------------------------------
222
+
223
+ /**
224
+ * Resolve a command reference from inside a plugin frame. A plugin names its OWN
225
+ * commands by their short contribution name (that's the only name it knows); anything
226
+ * else must be a full address — a built-in like `explorer.download`, or another
227
+ * plugin's contribution URI.
228
+ */
229
+ #resolveCommand(record, id) {
230
+ if (!id || parseContribUri(id)) return id;
231
+ const own = this.platform.contributions.get(contribUri(record.manifest, id));
232
+ return own?.type === 'command' ? own.uri : id;
233
+ }
234
+
235
+ /**
236
+ * The plugin's own contribution called `name`, of the expected type. Anything else —
237
+ * a name it never declared, or one of the wrong type — is refused: a plugin drives
238
+ * only slots the user saw and approved at install.
239
+ */
240
+ #ownContribution(record, name, type) {
241
+ const c = name ? this.platform.contributions.get(contribUri(record.manifest, name)) : null;
242
+ if (!c || c.pluginId !== record.id || c.type !== type) {
243
+ throw new Error(`"${name}" is not a ${type} declared by this plugin`);
244
+ }
245
+ return c;
246
+ }
247
+
248
+ // --- brokered capabilities -------------------------------------------------
249
+
250
+ /**
251
+ * Perform a network request on a plugin's behalf, but only to a URL it declared
252
+ * in its manifest `network` allowlist. The request runs from the host with
253
+ * credentials omitted (no ambient cookies/auth), and any redirect that lands
254
+ * off the allowlist is rejected.
255
+ */
256
+ /** blob:/data: carry their own bytes; anything else must be a declared endpoint. */
257
+ #artworkAllowed(record, src) {
258
+ if (!src) return false;
259
+ if (/^(blob:|data:image\/)/i.test(src)) return true;
260
+ return isAllowedUrl(networkEndpoints(record.manifest), src);
261
+ }
262
+
263
+ async #brokerFetch(record, { url, method = 'GET', headers, body }) {
264
+ const allow = networkEndpoints(record.manifest);
265
+ // The drive itself is never a "network endpoint".
266
+ //
267
+ // The broker runs in the HOST page, which is same-origin with the API. A manifest
268
+ // declaring `network: ["https://*.com/"]` matches essentially any drive host, so a
269
+ // plugin approved only for "connect to the internet" could call /api/items,
270
+ // /api/items/delete, and other plugins' /api/plugins/:id/sql directly — collecting
271
+ // the whole `files` capability, the per-command grant system, and every other
272
+ // plugin's server-side store in one hop, none of which the user approved. Those
273
+ // routes have a legitimate caller: the host, through the capability-gated methods
274
+ // above.
275
+ if (sameOrigin(url, this.platform.api?.baseUrl)) {
276
+ throw new Error('Blocked: a plugin may not call the drive\'s own API through the network broker');
277
+ }
278
+ if (!isAllowedUrl(allow, url)) {
279
+ throw new Error(`Blocked: "${url}" is not one of this plugin's declared network endpoints`);
280
+ }
281
+ const init = { method, credentials: 'omit', redirect: 'follow', headers: sanitizeHeaders(headers) };
282
+ if (body != null && method !== 'GET' && method !== 'HEAD') {
283
+ init.body = body instanceof ArrayBuffer ? new Uint8Array(body) : body;
284
+ }
285
+ const res = await fetch(url, init);
286
+ // A redirect chain must not escape the declared endpoints — nor land back on the
287
+ // drive, which a declared endpoint is free to redirect to.
288
+ if (res.url && res.url !== url) {
289
+ if (sameOrigin(res.url, this.platform.api?.baseUrl)) {
290
+ throw new Error('Blocked: request redirected onto the drive\'s own API');
291
+ }
292
+ if (!isAllowedUrl(allow, res.url)) {
293
+ throw new Error(`Blocked: request redirected off this plugin's declared endpoints (${res.url})`);
294
+ }
295
+ }
296
+ const buf = await readCappedBody(res, MAX_FETCH_BYTES);
297
+ const outHeaders = {};
298
+ res.headers.forEach((v, k) => { outHeaders[k] = v; });
299
+ return { ok: res.ok, status: res.status, statusText: res.statusText, url: res.url, headers: outHeaders, bytes: buf };
300
+ }
301
+
302
+ /**
303
+ * Plugin storage: run a SQL op against one scoped, isolated database. `scope` is
304
+ * 'plugin' or 'domain' (each granted separately); `side` is 'server' or 'client'.
305
+ */
306
+ async #pluginSql(record, { scope = 'plugin', side = 'server', op, sql, params = [], statements }) {
307
+ if (!record.storage?.[scope]) {
308
+ throw new Error(`Storage scope "${scope}" not granted${scope === 'domain' ? ' (needs a verified domain)' : ''}`);
309
+ }
310
+ if (side === 'client') {
311
+ // On-device: an isolated wasm SQLite db per scope, held by the host. Domain
312
+ // scope keys by the verified domain so a vendor's plugins share it.
313
+ //
314
+ // Same guard as the server path. The client dbs share ONE emscripten module
315
+ // across every scope, so ATTACH / VACUUM INTO / PRAGMA are an isolation escape
316
+ // here for the same reason they are on disk — the blast radius is the browser's
317
+ // in-memory filesystem rather than the host's, which makes it smaller, not fine.
318
+ if (op === 'batch') for (const s of (Array.isArray(statements) ? statements : [])) assertSafePluginSql(s?.sql);
319
+ else assertSafePluginSql(sql);
320
+ const key = scope === 'domain' ? `dom:${record.manifest.domain}` : `plg:${record.id}`;
321
+ const db = await this.clientDb.obtain(key);
322
+ return runSqlOp(db, op, sql, params, statements);
323
+ }
324
+ // Server: the host proxies to the scoped db over the authenticated API; the
325
+ // domain (for the shared scope) comes from the verified install record, never
326
+ // the plugin.
327
+ const body = { scope, op, sql, params, statements, domain: scope === 'domain' ? record.manifest.domain : undefined };
328
+ const res = await this.platform.api.request('POST', `/api/plugins/${encodeURIComponent(record.id)}/sql`, { body });
329
+ return res.result;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Is `url` on the drive's own origin?
335
+ *
336
+ * `baseUrl` is '' in the shipped app (the API is served from the same origin as the
337
+ * page), so the comparison is against `location.origin`; a library caller pointing the
338
+ * client at another host gets that one. A redirect is checked separately, on the way
339
+ * back, since a declared endpoint could redirect here.
340
+ */
341
+ function sameOrigin(url, baseUrl) {
342
+ try {
343
+ const here = new URL(baseUrl || '', globalThis.location?.href || 'http://localhost/');
344
+ return new URL(url, here).origin === here.origin;
345
+ } catch {
346
+ return false;
347
+ }
348
+ }
349
+
350
+ /** True for files a plugin can read as opaque resources (not code, not manifest). */
351
+ function isResourcePath(path) {
352
+ return path !== 'manifest.json' && !isSourceModule(path);
353
+ }
354
+
355
+ // Drop headers the plugin isn't allowed to set (the browser forbids most of these
356
+ // anyway; we strip explicitly so intent is clear and cookies never ride along).
357
+ const FORBIDDEN_HEADERS = new Set(['host', 'cookie', 'cookie2', 'set-cookie', 'origin', 'referer', 'content-length', 'connection']);
358
+ function sanitizeHeaders(headers) {
359
+ const out = {};
360
+ for (const [k, v] of Object.entries(headers || {})) {
361
+ if (!FORBIDDEN_HEADERS.has(String(k).toLowerCase())) out[k] = v;
362
+ }
363
+ return out;
364
+ }
365
+
366
+ // Dispatch one SQL op onto a SqliteDatabase-shaped handle (the client store; the
367
+ // server mirrors this in routes.js).
368
+ function runSqlOp(db, op, sql, params = [], statements) {
369
+ switch (op) {
370
+ case 'exec': return db.exec(sql);
371
+ case 'run': return db.run(sql, ...params);
372
+ case 'get': return db.get(sql, ...params);
373
+ case 'all': return db.all(sql, ...params);
374
+ case 'batch': return db.batch(statements);
375
+ default: throw new Error(`Unknown storage op "${op}"`);
376
+ }
377
+ }
@@ -0,0 +1,168 @@
1
+ // Plugin package signing + domain verification, on Web Crypto.
2
+ //
3
+ // A package is a zip of files including manifest.json. Signing binds two things:
4
+ // 1. every non-manifest file, via `contentHash` (SHA-256 over the sorted file
5
+ // set) recorded in the manifest — tamper any file and it breaks;
6
+ // 2. the manifest itself, via an ECDSA-P256 `signature` over its canonical
7
+ // JSON (with `signature` removed) — tamper the manifest and it breaks.
8
+ // The signer's public key (SPKI) travels in the manifest; its fingerprint is
9
+ // SHA-256 of that key. A plugin is "domain verified" when the manifest declares
10
+ // a `domain` and that domain publishes the signer's fingerprint at
11
+ // https://<domain>/.well-known/trove-assetlinks.json
12
+ // (Digital-Asset-Links style), proving the domain owner vouches for the key.
13
+ //
14
+ // Verification is best-effort and layered: unsigned → "unverified"; signed but
15
+ // the domain doesn't list the key → "signed (self)"; signed + listed → "verified
16
+ // · <domain>". None of this sandboxes the plugin (the iframe does); it's a trust
17
+ // signal for the human deciding whether to install.
18
+
19
+ const enc = new TextEncoder();
20
+
21
+ function b64ToBytes(b64) {
22
+ const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
23
+ const out = new Uint8Array(bin.length);
24
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
25
+ return out;
26
+ }
27
+ function bytesToB64(bytes) {
28
+ let s = '';
29
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
30
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
31
+ return btoa(s);
32
+ }
33
+ function hex(buf) {
34
+ const b = new Uint8Array(buf);
35
+ let s = '';
36
+ for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0');
37
+ return s;
38
+ }
39
+ async function sha256(bytes) {
40
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
41
+ }
42
+
43
+ /** Stable JSON: recursively sorted keys, `signature` stripped from the top. */
44
+ export function canonicalManifest(manifest) {
45
+ const strip = { ...manifest };
46
+ delete strip.signature;
47
+ return JSON.stringify(sortKeys(strip));
48
+ }
49
+ function sortKeys(v) {
50
+ if (Array.isArray(v)) return v.map(sortKeys);
51
+ if (v && typeof v === 'object') {
52
+ const out = {};
53
+ for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]);
54
+ return out;
55
+ }
56
+ return v;
57
+ }
58
+
59
+ /** SHA-256 over every file except manifest.json, in sorted-path order. */
60
+ export async function contentHash(files) {
61
+ const paths = [...files.keys()].filter((p) => p !== 'manifest.json').sort();
62
+ const chunks = [];
63
+ for (const p of paths) {
64
+ const bytes = files.get(p);
65
+ const pathBytes = enc.encode(p);
66
+ const len = new Uint8Array(4);
67
+ new DataView(len.buffer).setUint32(0, bytes.length);
68
+ chunks.push(pathBytes, new Uint8Array([0]), len, bytes);
69
+ }
70
+ const total = chunks.reduce((n, c) => n + c.length, 0);
71
+ const buf = new Uint8Array(total);
72
+ let o = 0;
73
+ for (const c of chunks) {
74
+ buf.set(c, o);
75
+ o += c.length;
76
+ }
77
+ return hex(await sha256(buf));
78
+ }
79
+
80
+ /** SHA-256 fingerprint of an SPKI public key (lowercase hex). */
81
+ export async function fingerprintOf(publicKeyB64) {
82
+ return hex(await sha256(b64ToBytes(publicKeyB64)));
83
+ }
84
+ export function displayFingerprint(hexStr) {
85
+ return (hexStr.match(/.{2}/g) || []).join(':');
86
+ }
87
+
88
+ /**
89
+ * Verify a package's integrity + signature (does NOT check the domain).
90
+ * @returns {Promise<{signed:boolean, valid:boolean, fingerprint?:string, reason?:string}>}
91
+ */
92
+ export async function verifyPackage({ manifest, files }) {
93
+ if (!manifest.signature || !manifest.publicKey) return { signed: false, valid: false };
94
+ // 1. content hash binds all other files.
95
+ const expected = await contentHash(files);
96
+ if (manifest.contentHash && manifest.contentHash !== expected) {
97
+ return { signed: true, valid: false, reason: 'File contents do not match the manifest hash' };
98
+ }
99
+ // 2. signature binds the manifest.
100
+ try {
101
+ const key = await crypto.subtle.importKey('spki', b64ToBytes(manifest.publicKey), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
102
+ const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, b64ToBytes(manifest.signature), enc.encode(canonicalManifest(manifest)));
103
+ if (!ok) return { signed: true, valid: false, reason: 'Signature does not verify' };
104
+ return { signed: true, valid: true, fingerprint: await fingerprintOf(manifest.publicKey) };
105
+ } catch (err) {
106
+ return { signed: true, valid: false, reason: 'Bad key/signature: ' + err.message };
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Given the domain's assetlinks doc, is this key fingerprint vouched for this plugin?
112
+ * Format:
113
+ * { "version": 1, "keys": [ { "fingerprint": "<hex|colon-hex>", "plugins": ["docs" | "*"] } ] }
114
+ *
115
+ * The doc is served BY the domain, so a plugin is named by its name within that domain
116
+ * ("docs"); the fully-qualified "acme.com/docs" is accepted too, for docs that prefer
117
+ * to be explicit.
118
+ */
119
+ export function checkAssetlinks(assetlinks, fingerprint, manifest) {
120
+ const norm = (f) => (f || '').toLowerCase().replace(/:/g, '');
121
+ const want = norm(fingerprint);
122
+ const names = [manifest.name, `${manifest.domain}/${manifest.name}`];
123
+ for (const k of assetlinks?.keys || []) {
124
+ if (norm(k.fingerprint) !== want) continue;
125
+ if ((k.plugins || []).some((p) => p === '*' || names.includes(p))) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Full trust status for a package.
132
+ * @param {(domain:string)=>Promise<object|null>} fetchAssetlinks resolves the domain's assetlinks doc (or null)
133
+ * @returns {Promise<{status:'unverified'|'invalid'|'signed'|'verified', domain?:string, fingerprint?:string, reason?:string}>}
134
+ */
135
+ export async function assessTrust({ manifest, files }, fetchAssetlinks) {
136
+ const sig = await verifyPackage({ manifest, files });
137
+ if (!sig.signed) return { status: 'unverified', reason: 'Package is not signed' };
138
+ // A present-but-failed signature is evidence of tampering — a distinct, alarming
139
+ // status, NOT the same grey "unverified" as an ordinary unsigned package.
140
+ if (!sig.valid) return { status: 'invalid', reason: sig.reason || 'Invalid signature — the package may have been tampered with' };
141
+ let doc = null;
142
+ try {
143
+ doc = await fetchAssetlinks(manifest.domain);
144
+ } catch { /* unreachable domain */ }
145
+ if (doc && checkAssetlinks(doc, sig.fingerprint, manifest)) {
146
+ return { status: 'verified', domain: manifest.domain, fingerprint: sig.fingerprint };
147
+ }
148
+ return { status: 'signed', domain: manifest.domain, fingerprint: sig.fingerprint, reason: doc ? 'Domain does not list this key' : 'Could not reach the domain' };
149
+ }
150
+
151
+ // --- signing (tooling / tests) ---------------------------------------------
152
+
153
+ export async function generateSigningKey() {
154
+ return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
155
+ }
156
+
157
+ /** Produce a signed manifest for a package. Returns the manifest with
158
+ * contentHash + publicKey + signature filled in. */
159
+ export async function signManifest(manifest, files, keyPair) {
160
+ const withHash = { ...manifest, contentHash: await contentHash(files) };
161
+ const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
162
+ withHash.publicKey = bytesToB64(spki);
163
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, enc.encode(canonicalManifest(withHash))));
164
+ withHash.signature = bytesToB64(sig);
165
+ return withHash;
166
+ }
167
+
168
+ export { b64ToBytes, bytesToB64 };
@@ -0,0 +1,67 @@
1
+ // PluginRegistry — install records (manifest, package files, granted caps, trust
2
+ // status, settings, secrets) persisted in IndexedDB so plugins survive reloads on
3
+ // this device. Package bytes stay on-device (they can be large); non-secret
4
+ // settings can also be mirrored to the server (see the settings service) so they
5
+ // follow the user. A plugin's OWN data lives in per-scope SQLite databases (server
6
+ // via the host API; on-device via the wasm store) — not here.
7
+
8
+ const DB = 'trove-plugins';
9
+ const STORE = 'installs';
10
+
11
+ function openDb() {
12
+ return new Promise((resolve, reject) => {
13
+ const req = indexedDB.open(DB, 1);
14
+ req.onupgradeneeded = () => {
15
+ const db = req.result;
16
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'id' });
17
+ };
18
+ req.onsuccess = () => resolve(req.result);
19
+ req.onerror = () => reject(req.error);
20
+ });
21
+ }
22
+ function tx(db, mode, fn) {
23
+ return new Promise((resolve, reject) => {
24
+ const t = db.transaction(STORE, mode);
25
+ const s = t.objectStore(STORE);
26
+ let out;
27
+ Promise.resolve(fn(s)).then((r) => (out = r));
28
+ t.oncomplete = () => resolve(out);
29
+ t.onerror = () => reject(t.error);
30
+ });
31
+ }
32
+ const reqP = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
33
+
34
+ export class PluginRegistry {
35
+ constructor() {
36
+ this._db = null;
37
+ }
38
+ async #db() {
39
+ return (this._db ??= await openDb());
40
+ }
41
+ /** record: { id, manifest, files:{path:Uint8Array}, grants, trust, settings, secrets, installedAt } */
42
+ async save(record) {
43
+ const db = await this.#db();
44
+ await tx(db, 'readwrite', (s) => s.put(record));
45
+ return record;
46
+ }
47
+ async get(id) {
48
+ const db = await this.#db();
49
+ return tx(db, 'readonly', (s) => reqP(s.get(id)));
50
+ }
51
+ async list() {
52
+ const db = await this.#db();
53
+ return tx(db, 'readonly', (s) => reqP(s.getAll()));
54
+ }
55
+ async remove(id) {
56
+ const db = await this.#db();
57
+ await tx(db, 'readwrite', (s) => s.delete(id));
58
+ }
59
+ async patch(id, patch) {
60
+ const rec = await this.get(id);
61
+ if (!rec) return null;
62
+ const next = { ...rec, ...patch };
63
+ await this.save(next);
64
+ return next;
65
+ }
66
+ }
67
+