@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,217 @@
1
+ // Web Push over VAPID (RFC 8292), built on Web Crypto (crypto.subtle) so it runs
2
+ // unchanged on Node ≥20, Bun, Deno, and Cloudflare Workers — no Node crypto, no
3
+ // external deps (web-push et al. all pull in Node crypto).
4
+ //
5
+ // DESIGN — bodyless (no-payload) pushes:
6
+ // We deliberately send DATA-LESS web pushes: a VAPID-authenticated POST to the
7
+ // subscription endpoint carrying only a TTL (and optional urgency/topic), with
8
+ // an empty body. This AVOIDS the whole RFC 8291 payload-encryption stack — the
9
+ // ECDH key agreement over the subscription's p256dh key, the HKDF salt/IKM
10
+ // derivation, and aes128gcm content encoding. When the browser wakes the
11
+ // service worker for a data-less push, the worker calls back to our server to
12
+ // fetch whatever notifications are pending. So the subscription's `keys`
13
+ // (p256dh/auth) are never used here, and there is NO message encryption in this
14
+ // file — only VAPID (RFC 8292) request authorization.
15
+ //
16
+ // VAPID auth = a P-256 ECDSA (ES256) signed JWT identifying our application
17
+ // server, sent as `Authorization: vapid t=<jwt>, k=<appServerPublicKey>`.
18
+
19
+ import { TroveError } from '../errors.js';
20
+ import { withRetry } from '../retry.js';
21
+
22
+ const enc = new TextEncoder();
23
+
24
+ // --- base64url (no padding) ---------------------------------------------------
25
+
26
+ /** Encode bytes as base64url without padding. */
27
+ export function base64urlEncode(bytes) {
28
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
29
+ let bin = '';
30
+ for (let i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]);
31
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
32
+ }
33
+
34
+ /** Decode a base64url (padded or not) string to bytes. */
35
+ export function base64urlDecode(str) {
36
+ const b64 = String(str).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4);
37
+ const bin = atob(b64);
38
+ const out = new Uint8Array(bin.length);
39
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
40
+ return out;
41
+ }
42
+
43
+ // --- key generation / import --------------------------------------------------
44
+
45
+ /**
46
+ * Generate a VAPID application-server keypair (P-256 / ES256).
47
+ * @returns {Promise<{publicKey: string, privateKey: string}>}
48
+ * `publicKey` — the uncompressed EC point (65 bytes: 0x04 || X || Y),
49
+ * base64url-encoded. This is the value browsers expect as
50
+ * `applicationServerKey` in PushManager.subscribe().
51
+ * `privateKey` — the raw 32-byte scalar `d`, base64url-encoded.
52
+ */
53
+ export async function generateVapidKeys() {
54
+ const pair = await crypto.subtle.generateKey(
55
+ { name: 'ECDSA', namedCurve: 'P-256' },
56
+ true,
57
+ ['sign', 'verify'],
58
+ );
59
+ const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
60
+ // Reassemble the uncompressed point from the JWK's x/y coordinates.
61
+ const x = base64urlDecode(jwk.x);
62
+ const y = base64urlDecode(jwk.y);
63
+ const point = new Uint8Array(65);
64
+ point[0] = 0x04;
65
+ point.set(x, 1);
66
+ point.set(y, 33);
67
+ return {
68
+ publicKey: base64urlEncode(point),
69
+ privateKey: jwk.d, // JWK `d` is already raw base64url of the 32-byte scalar.
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Import a stored `{publicKey, privateKey}` (base64url) as a signing CryptoKey.
75
+ * The private JWK needs the matching public coordinates x/y, which we recover
76
+ * from the stored uncompressed public point.
77
+ * @returns {Promise<CryptoKey>}
78
+ */
79
+ export async function importVapidPrivateKey({ publicKey, privateKey }) {
80
+ const point = base64urlDecode(publicKey);
81
+ if (point.length !== 65 || point[0] !== 0x04) {
82
+ throw TroveError.invalid('VAPID publicKey must be a 65-byte uncompressed P-256 point');
83
+ }
84
+ const x = base64urlEncode(point.subarray(1, 33));
85
+ const y = base64urlEncode(point.subarray(33, 65));
86
+ return crypto.subtle.importKey(
87
+ 'jwk',
88
+ { kty: 'EC', crv: 'P-256', d: privateKey, x, y, ext: true },
89
+ { name: 'ECDSA', namedCurve: 'P-256' },
90
+ false,
91
+ ['sign'],
92
+ );
93
+ }
94
+
95
+ // --- service ------------------------------------------------------------------
96
+
97
+ const GONE_STATUSES = new Set([404, 410]);
98
+
99
+ export class WebPushService {
100
+ /**
101
+ * @param {object} o
102
+ * @param {string} o.publicKey base64url uncompressed P-256 point (65 bytes)
103
+ * @param {string} o.privateKey base64url raw 32-byte scalar `d`
104
+ * @param {string} o.subject VAPID `sub` claim — a `mailto:` or https URL
105
+ */
106
+ constructor({ publicKey, privateKey, subject } = {}) {
107
+ if (!publicKey || !privateKey) {
108
+ throw TroveError.invalid('WebPushService requires publicKey and privateKey');
109
+ }
110
+ if (!subject || !/^(mailto:|https:\/\/)/.test(subject)) {
111
+ throw TroveError.invalid('WebPushService subject must be a mailto: or https URL');
112
+ }
113
+ this.publicKey = publicKey;
114
+ this.privateKey = privateKey;
115
+ this.subject = subject;
116
+ this._signingKey = null; // lazily imported, then cached
117
+ }
118
+
119
+ async _key() {
120
+ if (!this._signingKey) {
121
+ this._signingKey = await importVapidPrivateKey({
122
+ publicKey: this.publicKey,
123
+ privateKey: this.privateKey,
124
+ });
125
+ }
126
+ return this._signingKey;
127
+ }
128
+
129
+ /**
130
+ * Build a VAPID JWT for a given endpoint audience.
131
+ * @param {string} aud origin of the push endpoint (scheme + host)
132
+ * @param {number} now epoch millis; exp = now + 12h
133
+ * @returns {Promise<string>} the compact JWS (header.payload.signature)
134
+ */
135
+ async _signJwt(aud, now) {
136
+ const header = base64urlEncode(enc.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' })));
137
+ const payload = base64urlEncode(
138
+ enc.encode(
139
+ JSON.stringify({
140
+ aud,
141
+ exp: Math.floor(now / 1000) + 12 * 60 * 60, // 12 hours
142
+ sub: this.subject,
143
+ }),
144
+ ),
145
+ );
146
+ const signingInput = `${header}.${payload}`;
147
+ // Web Crypto returns the raw r||s (64 bytes) that JWS ES256 wants — NOT DER.
148
+ const sig = await crypto.subtle.sign(
149
+ { name: 'ECDSA', hash: 'SHA-256' },
150
+ await this._key(),
151
+ enc.encode(signingInput),
152
+ );
153
+ return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`;
154
+ }
155
+
156
+ /**
157
+ * Send a bodyless web push to a subscription.
158
+ * @param {{endpoint: string, keys?: object}} subscription standard PushSubscription JSON
159
+ * @param {object} [o]
160
+ * @param {number} [o.ttl=2419200] seconds the push service holds the message (default 28d)
161
+ * @param {string} [o.urgency='normal'] very-low | low | normal | high
162
+ * @param {string} [o.topic] optional collapse key
163
+ * @param {number} [o.now=Date.now()] epoch millis (for JWT exp; injectable for sandboxes/tests)
164
+ * @param {AbortSignal} [o.signal]
165
+ * @returns {Promise<{ok: boolean, status: number, gone?: boolean}>}
166
+ * `{ ok: false, gone: true }` when the subscription is dead (404/410) — the
167
+ * caller should delete it. Never throws for `gone`.
168
+ */
169
+ async send(subscription, { ttl = 2419200, urgency = 'normal', topic, now = Date.now(), signal } = {}) {
170
+ const endpoint = subscription?.endpoint;
171
+ if (!endpoint) throw TroveError.invalid('subscription.endpoint is required');
172
+
173
+ let aud;
174
+ try {
175
+ aud = new URL(endpoint).origin;
176
+ } catch (e) {
177
+ throw TroveError.invalid('subscription.endpoint is not a valid URL', { cause: e });
178
+ }
179
+
180
+ const jwt = await this._signJwt(aud, now);
181
+ const headers = {
182
+ Authorization: `vapid t=${jwt}, k=${this.publicKey}`,
183
+ TTL: String(ttl),
184
+ Urgency: urgency,
185
+ 'Content-Length': '0',
186
+ };
187
+ if (topic) headers.Topic = topic;
188
+
189
+ return withRetry(
190
+ async () => {
191
+ let res;
192
+ try {
193
+ // `redirect: 'error'` because a push service that 302s us somewhere is either
194
+ // broken or walking us onto an internal host the subscribe-time check refused.
195
+ res = await fetch(endpoint, { method: 'POST', headers, body: '', signal, redirect: 'error' });
196
+ } catch (e) {
197
+ // Network failure — let withRetry classify it (wrapError → transient).
198
+ throw e;
199
+ }
200
+
201
+ const status = res.status;
202
+ // Dead subscription: signal the caller to delete it. Never throw.
203
+ if (GONE_STATUSES.has(status)) return { ok: false, gone: true, status };
204
+ // Throttling / server errors are retryable.
205
+ if (status === 429 || status >= 500) {
206
+ throw TroveError.transient(`Push endpoint returned ${status}`, { details: { status } });
207
+ }
208
+ // Any other non-2xx is a permanent client error (bad JWT, bad request…).
209
+ if (status < 200 || status >= 300) {
210
+ throw TroveError.invalid(`Push endpoint returned ${status}`, { details: { status } });
211
+ }
212
+ return { ok: true, status };
213
+ },
214
+ { signal },
215
+ );
216
+ }
217
+ }
@@ -0,0 +1,177 @@
1
+ // What a package contributes — declared in its manifest as ONE map of
2
+ // `name -> contribution`, where each contribution states its own `type` and that
3
+ // type's options:
4
+ //
5
+ // "contributes": {
6
+ // "player": { "type": "opener", "title": "Demo Player",
7
+ // "match": { "ext": [".demo"] }, "entry": "src/openers/player.js" },
8
+ // "pdf": { "type": "indexer", "match": { "ext": [".pdf"] }, "entry": "src/indexers/pdf.js" },
9
+ // "tap": { "type": "command", "title": "Demo: Tap" },
10
+ // "status": { "type": "statusItem", "slot": "right" },
11
+ // "busy": { "type": "register", "default": false },
12
+ // "keys": { "type": "keymap", "path": "keymaps/default.json" }
13
+ // }
14
+ //
15
+ // One map, not a collection per kind, because the kinds never needed separate
16
+ // namespaces — they needed a shared one. Here "status" is a single name under this
17
+ // plugin, so its status item, its register and its command can't shadow each other,
18
+ // and `trove+contrib:acme.com/docs/status` names exactly one thing in the world.
19
+ //
20
+ // Each contribution's `entry` (openers, indexers) points into the plugin's ONE module
21
+ // tree — they are not nested sub-packages, so everything in a plugin shares modules
22
+ // and code. What gets opened or indexed depends only on which entry module runs.
23
+
24
+ import { TroveError } from '../errors.js';
25
+ import { contribUri, isValidName, pluginId } from './identity.js';
26
+
27
+ /** Every contribution type a package may declare, with how its options are normalized. */
28
+ const TYPES = {
29
+ // A command in the palette. Its handler lives in the plugin's primary frame.
30
+ command: {
31
+ normalize: (c) => ({
32
+ title: c.title || null, category: c.category || null, icon: c.icon || null,
33
+ when: c.when || null, palette: c.palette !== false, offline: !!c.offline,
34
+ }),
35
+ },
36
+ // A viewer for matching files, rendered by `entry` in its own sandboxed frame.
37
+ opener: {
38
+ needsEntry: true,
39
+ normalize: (c) => ({
40
+ title: c.title || null, match: normalizeMatch(c.match), priority: c.priority ?? 50,
41
+ when: c.when || null, offline: !!c.offline, dock: c.dock || null,
42
+ }),
43
+ },
44
+ // Indexes matching files. ALWAYS runs on the server (see serverIndexers).
45
+ indexer: {
46
+ needsEntry: true,
47
+ normalize: (c) => ({ title: c.title || null, match: normalizeMatch(c.match) }),
48
+ },
49
+ // A slot in the status bar. The plugin pushes content into it at runtime; for now
50
+ // the only content type is sanitized HTML.
51
+ statusItem: {
52
+ normalize: (c) => {
53
+ const slot = c.slot === 'left' ? 'left' : 'right';
54
+ const render = c.render || 'html';
55
+ if (render !== 'html') throw TroveError.invalid(`statusItem "render" must be "html" (got ${JSON.stringify(render)})`);
56
+ return { slot, render, order: c.order ?? 0, when: c.when || null, offline: !!c.offline, command: c.command || null };
57
+ },
58
+ },
59
+ // A context value slot the plugin drives at runtime, referenceable from when-clauses
60
+ // in keymaps, commands and other contributions (the equivalent of a VS Code context key).
61
+ register: {
62
+ normalize: (c) => ({ default: c.default ?? null, description: c.description || null }),
63
+ },
64
+ // A keymap JSON file in the package: [{ key, command, when?, args? }, …].
65
+ keymap: {
66
+ normalize: (c) => {
67
+ if (!c.path || typeof c.path !== 'string') throw TroveError.invalid('keymap contribution needs a "path" to a JSON file in the package');
68
+ return { path: c.path };
69
+ },
70
+ },
71
+ };
72
+
73
+ export const CONTRIBUTION_TYPES = Object.keys(TYPES);
74
+
75
+ /**
76
+ * Every contribution a manifest declares, normalized and addressed by URI.
77
+ * Throws INVALID on a malformed declaration — a package that lies about what it
78
+ * contributes must not install, because the install review is what the user approved.
79
+ * @returns {Array<{name:string, uri:string, type:string, entry?:string}>}
80
+ */
81
+ export function declaredContributions(manifest) {
82
+ const map = manifest?.contributes;
83
+ if (map == null) return [];
84
+ if (typeof map !== 'object' || Array.isArray(map)) {
85
+ throw TroveError.invalid('manifest "contributes" must be a map of name -> contribution');
86
+ }
87
+ const out = [];
88
+ for (const [name, raw] of Object.entries(map)) {
89
+ if (!raw || typeof raw !== 'object') throw TroveError.invalid(`Contribution "${name}" must be an object`);
90
+ if (!isValidName(name)) throw TroveError.invalid(`Invalid contribution name "${name}"`);
91
+ const spec = TYPES[raw.type];
92
+ if (!spec) {
93
+ throw TroveError.invalid(`Contribution "${name}" has unknown type ${JSON.stringify(raw.type)} (expected one of ${CONTRIBUTION_TYPES.join(', ')})`);
94
+ }
95
+ const entry = raw.entry || manifest.entry;
96
+ if (spec.needsEntry && !entry) throw TroveError.invalid(`Contribution "${name}" (${raw.type}) needs an "entry" module`);
97
+ out.push({
98
+ name,
99
+ uri: contribUri(manifest, name),
100
+ pluginId: pluginId(manifest),
101
+ type: raw.type,
102
+ ...spec.normalize(raw),
103
+ ...(spec.needsEntry ? { entry } : {}),
104
+ });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Just the declared contributions of one type. */
110
+ export function contributionsOfType(manifest, type) {
111
+ return declaredContributions(manifest).filter((c) => c.type === type);
112
+ }
113
+
114
+ /**
115
+ * The indexers the server will run. Indexing is a property of the DRIVE, not of
116
+ * whoever happens to have a tab open: it must happen once per upload regardless of
117
+ * which client did the uploading, so an indexer contribution is always server-side.
118
+ * (Not to be confused with the `indexer` capability, which lets a plugin push its own
119
+ * contributions for a node it's looking at — that one is a client action.)
120
+ */
121
+ export function serverIndexers(manifest) {
122
+ return contributionsOfType(manifest, 'indexer').map((i) => ({
123
+ id: i.uri, name: i.name, title: i.title || i.name, match: i.match, entry: i.entry,
124
+ }));
125
+ }
126
+
127
+ /** Openers a package declares, each pointing at the entry module that renders it. */
128
+ export function declaredOpeners(manifest) {
129
+ return contributionsOfType(manifest, 'opener');
130
+ }
131
+
132
+ /**
133
+ * Parse a keymap file's contents: a JSON array of `{ key, command, when?, args? }`
134
+ * (an object with a `bindings` array is also accepted). Entries missing a key or a
135
+ * command are dropped rather than failing the whole map — one bad line shouldn't cost
136
+ * a plugin every other shortcut it ships.
137
+ */
138
+ export function parseKeymap(text) {
139
+ let doc;
140
+ try {
141
+ doc = JSON.parse(text);
142
+ } catch (err) {
143
+ throw TroveError.invalid('Keymap file is not valid JSON', { cause: err });
144
+ }
145
+ const list = Array.isArray(doc) ? doc : Array.isArray(doc?.bindings) ? doc.bindings : null;
146
+ if (!list) throw TroveError.invalid('Keymap file must be a JSON array of bindings');
147
+ return list
148
+ .filter((b) => b && typeof b.key === 'string' && typeof b.command === 'string')
149
+ .map((b) => ({
150
+ key: b.key, command: b.command,
151
+ when: typeof b.when === 'string' ? b.when : null,
152
+ args: Array.isArray(b.args) ? b.args : undefined,
153
+ }));
154
+ }
155
+
156
+ /**
157
+ * A file-match selector, coerced into the shape `selectorMatches` actually indexes.
158
+ *
159
+ * It was passed through verbatim, so `"match": { "ext": ".demo" }` — a string where an
160
+ * array belongs, which no validation rejected — installed cleanly and then threw
161
+ * `(selector.ext || []).some is not a function` at selection time. Server-side the
162
+ * throw was swallowed, so the plugin backfilled nothing and indexed nothing with no
163
+ * error anywhere; in the browser `openersFor` is unguarded, so ONE bad declaration
164
+ * broke opener selection for every file in the drive.
165
+ */
166
+ function normalizeMatch(match) {
167
+ if (!match || typeof match !== 'object' || Array.isArray(match)) return {};
168
+ const list = (v) => (v == null ? undefined : (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string'));
169
+ const out = {};
170
+ const ext = list(match.ext);
171
+ const mime = list(match.mime ?? match.contentType);
172
+ if (ext) out.ext = ext;
173
+ if (mime) out.mime = mime;
174
+ // A `match` function can only come from in-process code, never from a manifest.
175
+ if (typeof match.match === 'function') out.match = match.match;
176
+ return out;
177
+ }
@@ -0,0 +1,98 @@
1
+ // Plugin identity and contribution URIs.
2
+ //
3
+ // A plugin is identified by its DOMAIN plus its NAME — never by a self-chosen opaque
4
+ // id. The domain is proven (the package is signed, and the domain publishes the
5
+ // signing key via assetlinks), so identity is verifiable rather than claimed. That
6
+ // gives us three things at once:
7
+ //
8
+ // • no squatting: only acme.com can publish acme.com/docs
9
+ // • versioning: acme.com/docs@2 is the same plugin as acme.com/docs@1, and a
10
+ // different plugin from acme.com/sheets — which a self-chosen id can't express
11
+ // • no cross-kind collisions: every contribution lives under its plugin, so an
12
+ // opener, a status slot, a register and a command can all be called "status"
13
+ // without colliding with each other or with another plugin's "status".
14
+ //
15
+ // Every contribution is addressed by a URI:
16
+ //
17
+ // trove+contrib:<domain>/<plugin>/<contribution>
18
+ // e.g. trove+contrib:acme.com/docs/pdfViewer
19
+ //
20
+ // The host's own built-in contributions use the reserved `core` domain, so the
21
+ // address space has exactly one shape: trove+contrib:core/workbench/explorer.delete
22
+
23
+ import { TroveError } from '../errors.js';
24
+
25
+ export const CONTRIB_SCHEME = 'trove+contrib:';
26
+ export const CORE_DOMAIN = 'core';
27
+
28
+ // A DNS-ish domain (lowercase labels, dots) — or the reserved `core`.
29
+ const DOMAIN_RE = /^(?:core|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)$/;
30
+ // A plugin or contribution name: lowercase-ish segment, no slashes or spaces.
31
+ const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
32
+
33
+ export function isValidDomain(domain) {
34
+ return typeof domain === 'string' && DOMAIN_RE.test(domain);
35
+ }
36
+ export function isValidName(name) {
37
+ return typeof name === 'string' && NAME_RE.test(name);
38
+ }
39
+
40
+ /** The plugin's identity: `<domain>/<name>`. Stable across versions. */
41
+ export function pluginId(manifest) {
42
+ assertIdentity(manifest);
43
+ return `${manifest.domain}/${manifest.name}`;
44
+ }
45
+
46
+ /** The full URI for one of a plugin's contributions. */
47
+ export function contribUri(manifest, contributionName) {
48
+ if (!isValidName(contributionName)) {
49
+ throw TroveError.invalid(`Invalid contribution name "${contributionName}"`);
50
+ }
51
+ return `${CONTRIB_SCHEME}${pluginId(manifest)}/${contributionName}`;
52
+ }
53
+
54
+ // The host's own contributions are addressed exactly like a plugin's: the reserved
55
+ // `core` domain, and `workbench` as the package within it. So the address space has
56
+ // one shape everywhere, and core names share a single namespace just as a plugin's do.
57
+ export const CORE_PACKAGE = 'workbench';
58
+
59
+ /** A URI for one of the host's own built-in contributions. */
60
+ export function coreUri(name) {
61
+ return `${CONTRIB_SCHEME}${CORE_DOMAIN}/${CORE_PACKAGE}/${name}`;
62
+ }
63
+
64
+ /** Parse a contribution URI into its parts, or null if it isn't one. */
65
+ export function parseContribUri(uri) {
66
+ if (typeof uri !== 'string' || !uri.startsWith(CONTRIB_SCHEME)) return null;
67
+ const rest = uri.slice(CONTRIB_SCHEME.length);
68
+ const i = rest.indexOf('/');
69
+ const j = rest.indexOf('/', i + 1);
70
+ if (i < 0 || j < 0) return null;
71
+ const domain = rest.slice(0, i);
72
+ const plugin = rest.slice(i + 1, j);
73
+ const name = rest.slice(j + 1);
74
+ if (!domain || !plugin || !name) return null;
75
+ return { domain, plugin, name, pluginId: `${domain}/${plugin}` };
76
+ }
77
+
78
+ /** Whether `uri` belongs to the plugin described by `manifest`. */
79
+ export function ownsUri(manifest, uri) {
80
+ const p = parseContribUri(uri);
81
+ return !!p && p.pluginId === `${manifest.domain}/${manifest.name}`;
82
+ }
83
+
84
+ /**
85
+ * Every package must carry a verifiable identity: a domain it belongs to and a name
86
+ * within that domain. Anonymous/self-identified packages are not installable.
87
+ */
88
+ export function assertIdentity(manifest) {
89
+ if (!isValidDomain(manifest?.domain)) {
90
+ throw TroveError.invalid(`Plugin manifest needs a valid "domain" (got ${JSON.stringify(manifest?.domain)})`);
91
+ }
92
+ if (!isValidName(manifest?.name)) {
93
+ throw TroveError.invalid(`Plugin manifest needs a valid "name" (got ${JSON.stringify(manifest?.name)})`);
94
+ }
95
+ if (manifest.domain === CORE_DOMAIN) {
96
+ throw TroveError.invalid('The "core" domain is reserved for built-in contributions');
97
+ }
98
+ }