@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,225 @@
1
+ // PluginService — server-side install lifecycle for account-scoped plugins. Holds the
2
+ // pluggable PackageStore (blobs) + PluginInstallStore (records), re-parses uploaded
3
+ // packages, enforces the scope/authz gate (admin required for server indexers or
4
+ // shared resources), and is the authority for capability checks on plugin API calls.
5
+ //
6
+ // Device-scoped plugins (pure client capabilities) never reach here — they live only
7
+ // in the browser. Anything with a server footprint installs through this.
8
+
9
+ import { TroveError } from '../errors.js';
10
+ import { parsePluginPackage } from './package.js';
11
+
12
+ export { PackageStore, StoragePackageStore } from './packageStore.js';
13
+ export { PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore } from './installStore.js';
14
+ export { parsePluginPackage, capabilityList, ALL_CAPABILITIES, serverIndexers, declaredOpeners, declaredContributions } from './package.js';
15
+ export * from './identity.js';
16
+ export { CONTRIBUTION_TYPES, contributionsOfType } from './contributions.js';
17
+ export { IndexerRuntime, InProcessIndexerRuntime } from './runtime.js';
18
+ export { clampContribution, DEFAULT_CAPS } from '../indexers/contribution.js';
19
+ export { PluginIndexers, matchFromSelector } from './indexers.js';
20
+
21
+ // The account a principal installs into. Per-user for now; a workspace/org model can
22
+ // override this later without touching call sites.
23
+ function accountOf(principal) {
24
+ if (!principal) throw TroveError.unauthorized('Authentication required');
25
+ return principal.id;
26
+ }
27
+
28
+ export class PluginService {
29
+ /**
30
+ * @param {object} deps
31
+ * @param {import('./packageStore.js').PackageStore} deps.packages
32
+ * @param {import('./installStore.js').PluginInstallStore} deps.installs
33
+ * @param {(principal:object)=>boolean} [deps.isAdmin]
34
+ * @param {number} [deps.maxPackageBytes]
35
+ * @param {import('./indexers.js').PluginIndexers} [deps.indexers] activate/deactivate server indexers
36
+ */
37
+ constructor({ packages, installs, isAdmin, indexers = null, maxPackageBytes = 32 * 1024 * 1024, strict = false } = {}) {
38
+ this.packages = packages;
39
+ this.installs = installs;
40
+ this._isAdmin = isAdmin || (() => false);
41
+ this.indexers = indexers; // PluginIndexers coordinator, or null when indexers are disabled
42
+ this.maxPackageBytes = maxPackageBytes;
43
+ // strict = deny a plugin API call when there's no server install record. Off by
44
+ // default so plugins installed before server-installs existed keep working; a
45
+ // deployment flips it on (once its clients have re-uploaded) to fully close the
46
+ // "any client can name any pluginId" gap.
47
+ this.strict = strict;
48
+ }
49
+ async init() {
50
+ await this.installs?.init?.();
51
+ // Re-register every installed server indexer into the pipeline (no backfill — the
52
+ // files were indexed when installed; this just restores the live-upload hooks).
53
+ if (this.indexers && this.installs?.all) {
54
+ const records = (await this.installs.all()).filter((r) => r.indexers?.length);
55
+ if (records.length) await this.indexers.activateAll(records);
56
+ }
57
+ }
58
+
59
+ /** Whether a package needs admin approval: ships server code, or touches shared state. */
60
+ requiresAdmin(pkg) {
61
+ return (pkg.indexers && pkg.indexers.length > 0) || !!pkg.sharedStorage;
62
+ }
63
+
64
+ /**
65
+ * Install (account scope). `bytes` is the raw package zip. `grants` is the caps the
66
+ * user approved (defaults to all declared). Re-validates server-side, gates on scope,
67
+ * stores the blob (deduped by digest) + the install record.
68
+ */
69
+ async install({ principal, bytes, grants }) {
70
+ const account = accountOf(principal);
71
+ if (bytes.byteLength > this.maxPackageBytes) throw TroveError.invalid('Package exceeds the maximum size');
72
+ const pkg = await parsePluginPackage(bytes);
73
+ const granted = (grants || pkg.capabilities).filter((c) => pkg.capabilities.includes(c));
74
+
75
+ if (this.requiresAdmin(pkg) && !this._isAdmin(principal)) {
76
+ throw TroveError.forbidden('This plugin ships server components or uses shared resources and needs an administrator to install it');
77
+ }
78
+ // Refuse server-indexer plugins when this deployment has no indexer runtime.
79
+ if (pkg.indexers.length && !this.indexers) {
80
+ throw TroveError.unsupported('Server indexers are disabled on this deployment');
81
+ }
82
+
83
+ const version = pkg.manifest.version || '0';
84
+ // CONTENT-addressed. The ref used to be `<account>/<pluginId>/<version>.zip`, written
85
+ // only when absent — so re-installing at the same version (the ordinary way to
86
+ // iterate on a plugin) stored the new digest, the new grants and the new indexer
87
+ // specs against the OLD bytes, and every device that synced the package got the old
88
+ // code forever. Worse for a server indexer, whose entry modules are loaded from the
89
+ // ref while the record advertises the new manifest's. Bumping the version instead
90
+ // just leaked the previous blob.
91
+ const prev = await this.installs.get(account, pkg.pluginId);
92
+ const digestPart = String(pkg.digest).replace(/^sha256:/, '').slice(0, 32);
93
+ const ref = `${encodeURIComponent(account)}/${encodeURIComponent(pkg.pluginId)}/${digestPart}.zip`;
94
+ if (!(await this.packages.has(ref))) await this.packages.put(ref, bytes);
95
+
96
+ const record = {
97
+ account, pluginId: pkg.pluginId, version,
98
+ scope: 'account', grants: granted, indexers: pkg.indexers, // full specs (id/match/entry/dir)
99
+ config: {}, secrets: {},
100
+ installedBy: principal.id, adminApprovedBy: this.requiresAdmin(pkg) ? principal.id : null,
101
+ // Whether the SHARED domain store was actually approved. Computed at install
102
+ // (it is what makes the package admin-only) and never written down, so the
103
+ // runtime check in /api/plugins/:id/sql had nothing to consult: a plugin that
104
+ // declared plain `storage: true`, needing no admin at all, could then open
105
+ // `scope: "domain"` and reach its vendor's shared database.
106
+ sharedStorage: !!pkg.sharedStorage,
107
+ packageRef: ref, digest: pkg.digest, createdAt: Date.now(), updatedAt: Date.now(),
108
+ };
109
+ await this.installs.put(record);
110
+ // The bytes the previous install pointed at are now unreferenced — unless another
111
+ // account installed the identical package, which is what countByDigest answers.
112
+ if (prev?.packageRef && prev.packageRef !== ref && !(await this.#refIsShared(prev.packageRef, account))) {
113
+ await this.packages.delete(prev.packageRef).catch(() => {});
114
+ }
115
+ // Register + backfill any server indexers this package ships.
116
+ if (this.indexers && pkg.indexers.length) {
117
+ try { await this.indexers.activate(record); }
118
+ catch (err) { console.error(`activating indexers for ${record.pluginId} failed:`, err.message); }
119
+ }
120
+ return this.#publicRecord(record);
121
+ }
122
+
123
+ /** An account's installed plugins (secrets stripped). */
124
+ async list(principal) {
125
+ return (await this.installs.list(accountOf(principal))).map((r) => this.#publicRecord(r));
126
+ }
127
+ async get(principal, pluginId) {
128
+ const r = await this.installs.get(accountOf(principal), pluginId);
129
+ return r ? this.#publicRecord(r) : null;
130
+ }
131
+
132
+ /** Download the package blob (for a device to sync + enable). */
133
+ async getPackage(principal, pluginId) {
134
+ const r = await this.installs.get(accountOf(principal), pluginId);
135
+ if (!r) throw TroveError.notFound('Plugin');
136
+ return this.packages.get(r.packageRef);
137
+ }
138
+
139
+ /** Remove: drop the record, then the blob if no other install shares its digest. */
140
+ async remove(principal, pluginId) {
141
+ const account = accountOf(principal);
142
+ const r = await this.installs.get(account, pluginId);
143
+ if (!r) return { ok: true, removed: null };
144
+ // Unregister + purge server indexers before dropping the record/blob they load from.
145
+ if (this.indexers && r.indexers?.length) {
146
+ try { await this.indexers.deactivate(r); }
147
+ catch (err) { console.error(`deactivating indexers for ${pluginId} failed:`, err.message); }
148
+ }
149
+ await this.installs.delete(account, pluginId);
150
+ // Refs embed the ACCOUNT, so two accounts holding the same digest hold two distinct
151
+ // blobs — a global digest count said "someone still has it" and left this one behind
152
+ // with nothing that would ever reference it again. Ask about the blob we actually
153
+ // hold, not about its contents.
154
+ if (r.packageRef && !(await this.#refIsShared(r.packageRef, account))) {
155
+ await this.packages.delete(r.packageRef).catch(() => {});
156
+ }
157
+ return { ok: true, removed: pluginId, indexers: (r.indexers || []).map((i) => i.id || i) };
158
+ }
159
+
160
+ /**
161
+ * Authoritative capability check for a plugin API call. If the plugin is
162
+ * server-installed, its granted caps are enforced. Transitional: with no server
163
+ * install record we allow (device-installed plugins predate this and haven't
164
+ * migrated); once the client account-install flow lands this becomes deny-by-default.
165
+ */
166
+ /**
167
+ * Is this plugin installed on this account at all?
168
+ *
169
+ * Deliberately separate from `assertCapability`, because the two questions are not
170
+ * the same and only one of them is transitional. Whether a plugin holds a GRANT can
171
+ * fall back to allow while device-installed plugins migrate. Whether a caller may act
172
+ * AS a plugin cannot: writing under `trove+contrib:vendor.com/plugin/…`, or opening a
173
+ * vendor's shared domain store, is a claim on somebody else's identity, and index
174
+ * contributions carry into search results and tag mirrors that other people in the
175
+ * collection see. No record, no identity — in strict mode and out of it.
176
+ */
177
+ async assertInstalled(principal, pluginId) {
178
+ const r = await this.installs.get(accountOf(principal), pluginId);
179
+ if (!r) throw TroveError.forbidden(`Plugin "${pluginId}" is not installed on this account`);
180
+ return r;
181
+ }
182
+
183
+ /**
184
+ * May this plugin open its vendor's SHARED domain store?
185
+ *
186
+ * Only if it declared that scope, which is what made the package admin-gated in the
187
+ * first place. Declaring plain `storage: true` installs without an admin — and used
188
+ * to reach the domain scope anyway, because nothing recorded which of the two had
189
+ * been approved.
190
+ */
191
+ async assertSharedStorage(principal, pluginId) {
192
+ const r = await this.assertInstalled(principal, pluginId);
193
+ if (!r.sharedStorage) {
194
+ throw TroveError.forbidden(
195
+ `Plugin "${pluginId}" did not declare shared (domain) storage, so it cannot open it`,
196
+ );
197
+ }
198
+ return r;
199
+ }
200
+
201
+ async assertCapability(principal, pluginId, cap) {
202
+ const r = await this.installs.get(accountOf(principal), pluginId);
203
+ if (!r) {
204
+ if (this.strict) throw TroveError.forbidden(`Plugin "${pluginId}" is not installed on this account`);
205
+ return; // transitional allow
206
+ }
207
+ // `|| []` denies rather than throws on a record with no grants field. A record that
208
+ // cannot say what it was granted was granted nothing.
209
+ if (!(r.grants || []).includes(cap)) {
210
+ throw TroveError.forbidden(`Plugin "${pluginId}" was not granted the "${cap}" capability`);
211
+ }
212
+ }
213
+
214
+ /** Does any remaining install still point at this exact blob? */
215
+ async #refIsShared(packageRef, exceptAccount) {
216
+ if (!this.installs.all) return false;
217
+ const all = await this.installs.all();
218
+ return all.some((r) => r.packageRef === packageRef && r.account !== exceptAccount);
219
+ }
220
+
221
+ #publicRecord(r) {
222
+ const { secrets, ...rest } = r; // never expose secrets over the API
223
+ return rest;
224
+ }
225
+ }
@@ -0,0 +1,142 @@
1
+ // PluginIndexers — bridges installed plugin *indexer sub-packages* into the Vfs
2
+ // indexing pipeline. When an account installs (or, at startup, re-activates) a plugin
3
+ // that ships server indexers, this:
4
+ // 1. resolves each indexer's bundle from the PackageStore blob,
5
+ // 2. registers it into the Vfs IndexerRegistry (so it auto-runs on every upload),
6
+ // 3. backfills it over existing files.
7
+ // On uninstall it unregisters and purges the indexer's contributions.
8
+ //
9
+ // Execution goes through the injected IndexerRuntime, which is the isolation seam:
10
+ // tests/first-party use the in-process runtime; a deployment can swap an isolate one.
11
+
12
+ import { unzipSync } from 'fflate';
13
+ import { readAll, selectorMatches } from '../util.js';
14
+
15
+ export class PluginIndexers {
16
+ /**
17
+ * @param {object} deps
18
+ * @param {import('../vfs.js').Vfs} deps.vfs
19
+ * @param {import('./runtime.js').IndexerRuntime} deps.runtime
20
+ * @param {import('./packageStore.js').PackageStore} deps.packages
21
+ */
22
+ constructor({ vfs, runtime, packages }) {
23
+ this.vfs = vfs;
24
+ this.runtime = runtime;
25
+ this.packages = packages;
26
+ this._bundles = new Map(); // packageRef -> Promise<Record<path, Uint8Array>>
27
+ this._active = new Map(); // `${account}\0${indexerId}` -> unregister fn
28
+ }
29
+
30
+ /**
31
+ * Register + backfill every indexer a record declares. Idempotent per (account,
32
+ * indexer id): a re-activate replaces the prior registration. Records without server
33
+ * indexers are a no-op.
34
+ */
35
+ async activate(record, { backfill = true } = {}) {
36
+ const specs = record.indexers || [];
37
+ // Retire anything this plugin used to declare and no longer does. Both this and
38
+ // deactivate() iterated only the record in hand, so an indexer dropped by an
39
+ // upgrade stayed registered — running code the user upgraded away from on every
40
+ // upload, served from the in-memory bundle cache even after the blob was deleted —
41
+ // and its contributions were orphaned in the index for good.
42
+ const keep = new Set(specs.map((s) => s?.id).filter(Boolean));
43
+ for (const id of this.#idsFor(record.account, record.pluginId)) {
44
+ if (keep.has(id)) continue;
45
+ this._active.get(this.#key(record.account, id))?.();
46
+ this._active.delete(this.#key(record.account, id));
47
+ try { await this.vfs.purgeIndexer(id); }
48
+ catch (err) { console.error(`purge for retired indexer ${id} failed:`, err.message); }
49
+ }
50
+ for (const spec of specs) {
51
+ if (!spec?.id) continue;
52
+ const key = this.#key(record.account, spec.id);
53
+ // Build BEFORE dropping the previous registration. Dropping first meant a
54
+ // transient package-read failure left a previously-working indexer unregistered
55
+ // while the install still reported success — and #loadPackage caches the rejected
56
+ // promise, so it never recovered without a restart.
57
+ const indexer = await this.#buildIndexer(record, spec);
58
+ this._active.get(key)?.();
59
+ const unregister = this.vfs.indexers.register(indexer);
60
+ this._active.set(key, unregister);
61
+ if (backfill) {
62
+ try { await this.vfs.backfillIndexer(indexer); }
63
+ catch (err) { console.error(`backfill for indexer ${spec.id} failed:`, err.message); }
64
+ }
65
+ }
66
+ return specs.length;
67
+ }
68
+
69
+ /** Unregister + purge every indexer a record declared. */
70
+ async deactivate(record) {
71
+ for (const spec of record.indexers || []) {
72
+ if (!spec?.id) continue;
73
+ const key = this.#key(record.account, spec.id);
74
+ this._active.get(key)?.();
75
+ this._active.delete(key);
76
+ try { await this.vfs.purgeIndexer(spec.id); }
77
+ catch (err) { console.error(`purge for indexer ${spec.id} failed:`, err.message); }
78
+ }
79
+ }
80
+
81
+ /** Which indexer ids this account currently has registered for one plugin. */
82
+ #idsFor(account, pluginId) {
83
+ const prefix = `${account}\0`;
84
+ const out = [];
85
+ for (const key of this._active.keys()) {
86
+ if (!key.startsWith(prefix)) continue;
87
+ const id = key.slice(prefix.length);
88
+ // Contribution URIs are `trove+contrib:<domain>/<name>/<contribution>`.
89
+ if (pluginId && !id.includes(`:${pluginId}/`)) continue;
90
+ out.push(id);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Re-activate all installed indexers across accounts at startup (no backfill). */
96
+ async activateAll(records, opts = {}) {
97
+ let n = 0;
98
+ for (const record of records) n += await this.activate(record, { backfill: false, ...opts });
99
+ return n;
100
+ }
101
+
102
+ #key(account, id) { return `${account}\0${id}`; }
103
+
104
+ /**
105
+ * An indexer is an ENTRY MODULE inside the plugin's one package — not a nested
106
+ * sub-package — so it shares code with the rest of the plugin. The bundle handed to
107
+ * the runtime is therefore the whole package, and `spec.entry` names which module to
108
+ * run (e.g. "src/indexers/pdf.js").
109
+ */
110
+ async #buildIndexer(record, spec) {
111
+ const files = await this.#loadPackage(record.packageRef);
112
+ const entry = spec.entry;
113
+ const match = matchFromSelector(spec.match);
114
+ const runtime = this.runtime;
115
+ const runSpec = { id: spec.id, entry, files, cacheKey: `${record.digest || record.packageRef}\0${spec.id}` };
116
+ return {
117
+ id: spec.id,
118
+ displayName: spec.title || spec.name || spec.id,
119
+ match,
120
+ index: (node, ctx) => runtime.run(runSpec, node, { ...ctx, config: record.config || {}, secrets: record.secrets || {} }),
121
+ };
122
+ }
123
+
124
+ #loadPackage(ref) {
125
+ let p = this._bundles.get(ref);
126
+ if (!p) {
127
+ p = this.packages.get(ref).then(async ({ stream }) => unzipSync(await readAll(stream)));
128
+ // Never cache a REJECTION. A transient read failure otherwise poisoned this ref
129
+ // for the life of the process: every later activate got the same rejected promise
130
+ // back and the indexer could not be brought up again without a restart.
131
+ p.catch(() => this._bundles.delete(ref));
132
+ this._bundles.set(ref, p);
133
+ }
134
+ return p;
135
+ }
136
+ }
137
+
138
+ /** Turn an indexer `match` selector into a node predicate (shared matcher). */
139
+ export function matchFromSelector(sel = {}) {
140
+ return (node) => selectorMatches(sel, node);
141
+ }
142
+
@@ -0,0 +1,134 @@
1
+ // PluginInstallStore — the bookkeeping half of server plugin installs: which
2
+ // packages an account has installed, at what version, with which capabilities
3
+ // granted, plus config/secrets and the PackageStore ref. Small and queryable, so it
4
+ // lives in the shared SQLite provider (a `plugin_installs` table) rather than the
5
+ // bulk blob store. A memory impl backs tests / provider-less use.
6
+
7
+ import { TroveError } from '../errors.js';
8
+
9
+ const COLS = ['account', 'pluginId', 'version', 'scope', 'grants', 'indexers', 'config', 'secrets', 'installedBy', 'adminApprovedBy', 'packageRef', 'digest', 'createdAt', 'updatedAt'];
10
+
11
+ export class PluginInstallStore {
12
+ async init() {}
13
+ /** Upsert a record by (account, pluginId). */
14
+ async put(record) { throw TroveError.unsupported('PluginInstallStore.put'); }
15
+ /** @returns {Promise<object|null>} */
16
+ async get(account, pluginId) { throw TroveError.unsupported('PluginInstallStore.get'); }
17
+ /** @returns {Promise<object[]>} an account's installs. */
18
+ async list(account) { throw TroveError.unsupported('PluginInstallStore.list'); }
19
+ /** @returns {Promise<object[]>} every install across all accounts (startup sweep). */
20
+ async all() { throw TroveError.unsupported('PluginInstallStore.all'); }
21
+ async delete(account, pluginId) { throw TroveError.unsupported('PluginInstallStore.delete'); }
22
+ /** How many installs reference a blob digest (for dedupe on blob delete). */
23
+ async countByDigest(digest) { throw TroveError.unsupported('PluginInstallStore.countByDigest'); }
24
+ }
25
+
26
+ // --- SQLite (shared provider) ------------------------------------------------
27
+
28
+ const JSON_FIELDS = new Set(['grants', 'indexers', 'config', 'secrets']);
29
+
30
+ export class SqlitePluginInstallStore extends PluginInstallStore {
31
+ constructor({ provider, key = 'plugins' } = {}) {
32
+ super();
33
+ this._opts = { provider, key };
34
+ this.db = null;
35
+ }
36
+ async init() {
37
+ if (this.db) return;
38
+ if (!this._opts.provider) throw TroveError.invalid('SqlitePluginInstallStore needs a provider');
39
+ this.db = await this._opts.provider.obtain({ key: this._opts.key });
40
+ await this.db.exec(`
41
+ CREATE TABLE IF NOT EXISTS plugin_installs (
42
+ account TEXT NOT NULL,
43
+ pluginId TEXT NOT NULL,
44
+ version TEXT,
45
+ scope TEXT,
46
+ grants TEXT NOT NULL DEFAULT '[]',
47
+ indexers TEXT NOT NULL DEFAULT '[]',
48
+ config TEXT NOT NULL DEFAULT '{}',
49
+ secrets TEXT NOT NULL DEFAULT '{}',
50
+ installedBy TEXT,
51
+ adminApprovedBy TEXT,
52
+ packageRef TEXT,
53
+ digest TEXT,
54
+ createdAt INTEGER NOT NULL,
55
+ updatedAt INTEGER NOT NULL,
56
+ PRIMARY KEY (account, pluginId)
57
+ );
58
+ CREATE INDEX IF NOT EXISTS idx_plugin_installs_digest ON plugin_installs(digest);
59
+ `);
60
+ }
61
+ async put(record) {
62
+ const r = normalize(record);
63
+ await this.db.run(
64
+ `INSERT INTO plugin_installs (${COLS.join(',')}) VALUES (${COLS.map(() => '?').join(',')})
65
+ ON CONFLICT(account, pluginId) DO UPDATE SET
66
+ version=excluded.version, scope=excluded.scope, grants=excluded.grants, indexers=excluded.indexers,
67
+ config=excluded.config, secrets=excluded.secrets, installedBy=excluded.installedBy,
68
+ adminApprovedBy=excluded.adminApprovedBy, packageRef=excluded.packageRef, digest=excluded.digest,
69
+ updatedAt=excluded.updatedAt`,
70
+ ...COLS.map((c) => (JSON_FIELDS.has(c) ? JSON.stringify(r[c] ?? (c === 'config' || c === 'secrets' ? {} : [])) : r[c] ?? null)),
71
+ );
72
+ return r;
73
+ }
74
+ async get(account, pluginId) {
75
+ return hydrate(await this.db.get('SELECT * FROM plugin_installs WHERE account=? AND pluginId=?', account, pluginId));
76
+ }
77
+ async list(account) {
78
+ const rows = await this.db.all('SELECT * FROM plugin_installs WHERE account=? ORDER BY updatedAt DESC', account);
79
+ return rows.map(hydrate);
80
+ }
81
+ async all() {
82
+ const rows = await this.db.all('SELECT * FROM plugin_installs ORDER BY account ASC, pluginId ASC');
83
+ return rows.map(hydrate);
84
+ }
85
+ async delete(account, pluginId) {
86
+ await this.db.run('DELETE FROM plugin_installs WHERE account=? AND pluginId=?', account, pluginId);
87
+ }
88
+ async countByDigest(digest) {
89
+ const r = await this.db.get('SELECT COUNT(*) AS n FROM plugin_installs WHERE digest=?', digest);
90
+ return r?.n ?? 0;
91
+ }
92
+ }
93
+
94
+ // --- Memory ------------------------------------------------------------------
95
+
96
+ export class MemoryPluginInstallStore extends PluginInstallStore {
97
+ constructor() { super(); this.map = new Map(); }
98
+ #key(a, p) { return `${a}\0${p}`; }
99
+ async put(record) { const r = normalize(record); this.map.set(this.#key(r.account, r.pluginId), r); return r; }
100
+ async get(account, pluginId) { const r = this.map.get(this.#key(account, pluginId)); return r ? { ...r } : null; }
101
+ async list(account) { return [...this.map.values()].filter((r) => r.account === account).sort((a, b) => b.updatedAt - a.updatedAt).map((r) => ({ ...r })); }
102
+ async all() { return [...this.map.values()].map((r) => ({ ...r })); }
103
+ async delete(account, pluginId) { this.map.delete(this.#key(account, pluginId)); }
104
+ async countByDigest(digest) { return [...this.map.values()].filter((r) => r.digest === digest).length; }
105
+ }
106
+
107
+ function normalize(record) {
108
+ const now = record.updatedAt ?? Date.now();
109
+ return {
110
+ account: record.account, pluginId: record.pluginId, version: record.version ?? null,
111
+ scope: record.scope ?? 'account', grants: record.grants ?? [], indexers: record.indexers ?? [],
112
+ config: record.config ?? {}, secrets: record.secrets ?? {}, installedBy: record.installedBy ?? null,
113
+ adminApprovedBy: record.adminApprovedBy ?? null, packageRef: record.packageRef ?? null, digest: record.digest ?? null,
114
+ createdAt: record.createdAt ?? now, updatedAt: now,
115
+ };
116
+ }
117
+ // A corrupt JSON column is a real possibility (a partial write, a hand-edited db), and
118
+ // it threw a bare SyntaxError straight out to the router — a 500 with a parser message
119
+ // rather than something a caller can act on.
120
+ function parseJson(text, fallback, what) {
121
+ if (text == null || text === '') return fallback;
122
+ try {
123
+ return JSON.parse(text);
124
+ } catch (err) {
125
+ throw TroveError.internal(`This plugin's stored ${what} is corrupt and could not be read`, { cause: err });
126
+ }
127
+ }
128
+
129
+ function hydrate(row) {
130
+ if (!row) return null;
131
+ const out = { ...row };
132
+ for (const f of JSON_FIELDS) out[f] = parseJson(row[f], (f === 'config' || f === 'secrets' ? {} : []), f);
133
+ return out;
134
+ }
@@ -0,0 +1,102 @@
1
+ // Server-side plugin package parsing. The server independently re-parses an uploaded
2
+ // package (never trusting the client that produced it), pulling out the manifest, the
3
+ // verified identity, the declared capabilities and contributions, and a content digest
4
+ // used for dedupe/integrity.
5
+
6
+ import { unzipSync, strFromU8 } from 'fflate';
7
+ import { TroveError } from '../errors.js';
8
+ import { assertIdentity, pluginId } from './identity.js';
9
+ import { declaredContributions, serverIndexers, declaredOpeners } from './contributions.js';
10
+
11
+ export const ALL_CAPABILITIES = ['files', 'storage', 'ui', 'commands', 'indexer', 'opener', 'network', 'media', 'dock'];
12
+
13
+ export { serverIndexers, declaredOpeners, declaredContributions };
14
+
15
+ /** SHA-256 hex digest of the raw package bytes (content address). */
16
+ export async function digestBytes(bytes) {
17
+ const buf = await crypto.subtle.digest('SHA-256', bytes);
18
+ return 'sha256:' + [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
19
+ }
20
+
21
+ /** Declared capability ids from a manifest (object `{cap: opts}` or array form). */
22
+ export function capabilityList(manifest) {
23
+ const caps = manifest && manifest.capabilities;
24
+ if (Array.isArray(caps)) return caps.filter(Boolean);
25
+ if (caps && typeof caps === 'object') {
26
+ return Object.entries(caps).filter(([, v]) => v !== false && v != null).map(([k]) => k);
27
+ }
28
+ return [];
29
+ }
30
+
31
+ /** Whether the granted storage includes the shared `domain` scope. */
32
+ export function usesSharedStorage(manifest) {
33
+ const opt = manifest?.capabilities?.storage;
34
+ return !!(opt && typeof opt === 'object' && opt.domain);
35
+ }
36
+
37
+ // A package's COMPRESSED size is capped at the route; its INFLATED size was not, and a
38
+ // zip will happily turn 1 MB into 1 GB. `unzipSync` is synchronous, so that is a
39
+ // gigabyte of RSS and thirteen seconds of blocked event loop for one request — from an
40
+ // unauthenticated caller on the zero-config drive, since the install route only asks for
41
+ // a principal and the shared anonymous one satisfies it.
42
+ //
43
+ // fflate's `filter` runs against the central directory BEFORE any entry is inflated, so
44
+ // the declared sizes are the cheapest possible place to refuse. 64 MiB and 2,000 files
45
+ // are far beyond any real plugin (the largest thing in one is a wasm blob) and far below
46
+ // what hurts.
47
+ const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
48
+ const MAX_ENTRIES = 2000;
49
+
50
+ export function boundedUnzip(bytes) {
51
+ let total = 0;
52
+ let count = 0;
53
+ try {
54
+ return unzipSync(bytes, {
55
+ filter(file) {
56
+ if (++count > MAX_ENTRIES) throw TroveError.tooLarge(`Package has more than ${MAX_ENTRIES} files`);
57
+ total += file.originalSize || 0;
58
+ if (total > MAX_INFLATED_BYTES) {
59
+ throw TroveError.tooLarge(`Package expands to more than ${Math.round(MAX_INFLATED_BYTES / 1024 / 1024)} MB`);
60
+ }
61
+ return true;
62
+ },
63
+ });
64
+ } catch (err) {
65
+ if (err instanceof TroveError) throw err;
66
+ throw TroveError.invalid('Package is not a valid zip', { cause: err });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Parse an uploaded package zip. Returns { manifest, pluginId, files, capabilities,
72
+ * contributions, indexers, openers, digest }. Throws INVALID on a missing/malformed
73
+ * manifest, an unverifiable identity, or a bad contribution declaration.
74
+ * @param {Uint8Array} bytes
75
+ */
76
+ export async function parsePluginPackage(bytes) {
77
+ const files = boundedUnzip(bytes);
78
+ const manifestRaw = files['manifest.json'];
79
+ if (!manifestRaw) throw TroveError.invalid('Package is missing manifest.json');
80
+ let manifest;
81
+ try {
82
+ manifest = JSON.parse(strFromU8(manifestRaw));
83
+ } catch (err) {
84
+ throw TroveError.invalid('manifest.json is not valid JSON', { cause: err });
85
+ }
86
+ // Identity first: everything else (contribution URIs, install records, storage
87
+ // scopes) is addressed under `<domain>/<name>`, so an anonymous package has no
88
+ // address space to live in and is rejected outright.
89
+ assertIdentity(manifest);
90
+ const capabilities = capabilityList(manifest).filter((c) => ALL_CAPABILITIES.includes(c));
91
+ return {
92
+ manifest,
93
+ pluginId: pluginId(manifest),
94
+ files,
95
+ capabilities,
96
+ contributions: declaredContributions(manifest),
97
+ indexers: serverIndexers(manifest),
98
+ openers: declaredOpeners(manifest),
99
+ sharedStorage: usesSharedStorage(manifest),
100
+ digest: await digestBytes(bytes),
101
+ };
102
+ }
@@ -0,0 +1,61 @@
1
+ // PackageStore — pluggable bulk storage for installed plugin package blobs (the
2
+ // zips). Kept separate from install *records* (which live in SQLite): the blobs are
3
+ // bulk data an operator usually wants on S3/R2 or a filesystem, and possibly on a
4
+ // different backend than user files. The default just wraps the primary
5
+ // StorageBackend under a prefix; inject any other StorageBackend (or a bespoke
6
+ // PackageStore) to point packages elsewhere.
7
+ //
8
+ // Blobs are addressed by an opaque `ref` (see PluginService — `account/plugin/ver.zip`).
9
+ // Content is deduped by digest at the service layer, so `put` is effectively idempotent.
10
+
11
+ import { PrefixedStorage } from '../storage/prefixed.js';
12
+ import { TroveError } from '../errors.js';
13
+
14
+ export class PackageStore {
15
+ /** @param {string} ref @param {Uint8Array} bytes */
16
+ async put(ref, bytes) { throw TroveError.unsupported('PackageStore.put'); }
17
+ /** @returns {Promise<{stream: ReadableStream, size: number}>} */
18
+ async get(ref) { throw TroveError.unsupported('PackageStore.get'); }
19
+ /** @returns {Promise<boolean>} */
20
+ async has(ref) { throw TroveError.unsupported('PackageStore.has'); }
21
+ async delete(ref) {}
22
+ /** Optional: a URL a device can GET the package from directly. */
23
+ async presignGet(ref, opts) { throw TroveError.unsupported('This package store cannot presign'); }
24
+ }
25
+
26
+ /**
27
+ * The key space plugin packages live in.
28
+ *
29
+ * Exported because the collection SCANNER has to skip it, and the two drifted: the
30
+ * scanner's reserved list said `plugins/` while this said `_plugins/`, so every
31
+ * account's package was adopted into the default collection as an ordinary file.
32
+ * One constant, one source of truth.
33
+ */
34
+ export const PACKAGE_PREFIX = '_plugins/';
35
+
36
+ /** Default PackageStore: a namespaced view over any StorageBackend. */
37
+ export class StoragePackageStore extends PackageStore {
38
+ /** @param {import('../storage/interface.js').StorageBackend} storage */
39
+ constructor(storage, { prefix = PACKAGE_PREFIX } = {}) {
40
+ super();
41
+ this.storage = prefix ? new PrefixedStorage(storage, prefix) : storage;
42
+ }
43
+ async put(ref, bytes) {
44
+ return this.storage.put(ref, bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), { contentType: 'application/zip' });
45
+ }
46
+ async get(ref) {
47
+ return this.storage.get(ref);
48
+ }
49
+ async has(ref) {
50
+ try { await this.storage.head(ref); return true; } catch (err) {
51
+ if (err?.code === 'not_found') return false;
52
+ throw err;
53
+ }
54
+ }
55
+ async delete(ref) {
56
+ return this.storage.delete(ref).catch(() => {});
57
+ }
58
+ async presignGet(ref, opts) {
59
+ return this.storage.presignGet(ref, opts);
60
+ }
61
+ }