diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md
index 40bbd91a..77f1fecf 100644
--- a/impro-plugin/docs/docs.md
+++ b/impro-plugin/docs/docs.md
@@ -2699,8 +2699,9 @@ One token in a rich-text stream — `text`, `facet`, `inline`, or `block`.
> **fetch**(`url`, `init?`): `Promise`\<[`PluginResponse`](#pluginresponse)\>
-Proxied fetch through the host. Requires the `"networkRequest"` permission
-scope and the target URL must be covered by the plugin's manifest allowlist.
+Proxied fetch through the host. The target URL must match one of the
+`permissions.fetch` URL patterns declared in the plugin's manifest, which
+the user grants at install.
`init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or
`[name, value]` iterable), and a string `body`. Resolves to a
[PluginResponse](#pluginresponse).
@@ -2733,3 +2734,46 @@ Convenience wrapper that returns a new [FlattenedTokens](#flattenedtokens) for `
#### Returns
[`FlattenedTokens`](#flattenedtokens)
+
+***
+
+### getUserGrantedFetchOrigins()
+
+> **getUserGrantedFetchOrigins**(): `Promise`\<`string`[]\>
+
+The origins the user has granted this plugin, as fetch patterns
+(`https://example.com/*`). Manifest-declared permissions are not included.
+
+#### Returns
+
+`Promise`\<`string`[]\>
+
+***
+
+### requestFetchPermission()
+
+> **requestFetchPermission**(`url`): `Promise`\<`boolean`\>
+
+Asks the user to grant this plugin network access to `url`'s origin — for
+endpoints the plugin can't know in advance, such as a user-supplied API
+provider. Requires `permissions.userFetch` in the manifest.
+
+Grants are origin-scoped (scheme, host, and port; the path is ignored) and
+persist until the user revokes them in the app's plugin settings. Resolves
+`true` without prompting if the origin is already permitted, so it is safe
+to call before every request. Resolves `false` if the user declines, if
+`url` can't be an origin, or if a prompt for this plugin is already open.
+
+Any credential for the endpoint is the plugin's to hold — use
+[Plugin.saveLocalData](#savelocaldata) rather than [Plugin.saveData](#savedata), which
+syncs through the user's account preferences.
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `url` | `string` |
+
+#### Returns
+
+`Promise`\<`boolean`\>
diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts
index d2713ca4..54d48cb1 100644
--- a/impro-plugin/main.d.ts
+++ b/impro-plugin/main.d.ts
@@ -1,6 +1,7 @@
/**
- * Proxied fetch through the host. Requires the `"networkRequest"` permission
- * scope and the target URL must be covered by the plugin's manifest allowlist.
+ * Proxied fetch through the host. The target URL must match one of the
+ * `permissions.fetch` URL patterns declared in the plugin's manifest, which
+ * the user grants at install.
* `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or
* `[name, value]` iterable), and a string `body`. Resolves to a
* {@link PluginResponse}.
@@ -9,6 +10,30 @@
* @returns {Promise}
*/
export function fetch(url: string, init?: PluginFetchInit): Promise;
+/**
+ * Asks the user to grant this plugin network access to `url`'s origin — for
+ * endpoints the plugin can't know in advance, such as a user-supplied API
+ * provider. Requires `permissions.userFetch` in the manifest.
+ *
+ * Grants are origin-scoped (scheme, host, and port; the path is ignored) and
+ * persist until the user revokes them in the app's plugin settings. Resolves
+ * `true` without prompting if the origin is already permitted, so it is safe
+ * to call before every request. Resolves `false` if the user declines, if
+ * `url` can't be an origin, or if a prompt for this plugin is already open.
+ *
+ * Any credential for the endpoint is the plugin's to hold — use
+ * {@link Plugin.saveLocalData} rather than {@link Plugin.saveData}, which
+ * syncs through the user's account preferences.
+ * @param {string} url
+ * @returns {Promise}
+ */
+export function requestFetchPermission(url: string): Promise;
+/**
+ * The origins the user has granted this plugin, as fetch patterns
+ * (`https://example.com/*`). Manifest-declared permissions are not included.
+ * @returns {Promise}
+ */
+export function getUserGrantedFetchOrigins(): Promise;
/**
* Convenience wrapper that returns a new {@link FlattenedTokens} for `tokens`.
* @param {RichTextToken[]} tokens
diff --git a/impro-plugin/main.js b/impro-plugin/main.js
index 84e43327..7a4b7214 100644
--- a/impro-plugin/main.js
+++ b/impro-plugin/main.js
@@ -539,8 +539,9 @@ export class App {
}
/**
- * Proxied fetch through the host. Requires the `"networkRequest"` permission
- * scope and the target URL must be covered by the plugin's manifest allowlist.
+ * Proxied fetch through the host. The target URL must match one of the
+ * `permissions.fetch` URL patterns declared in the plugin's manifest, which
+ * the user grants at install.
* `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or
* `[name, value]` iterable), and a string `body`. Resolves to a
* {@link PluginResponse}.
@@ -555,6 +556,40 @@ export async function fetch(url, init = {}) {
return new PluginResponse(result);
}
+/**
+ * Asks the user to grant this plugin network access to `url`'s origin — for
+ * endpoints the plugin can't know in advance, such as a user-supplied API
+ * provider. Requires `permissions.userFetch` in the manifest.
+ *
+ * Grants are origin-scoped (scheme, host, and port; the path is ignored) and
+ * persist until the user revokes them in the app's plugin settings. Resolves
+ * `true` without prompting if the origin is already permitted, so it is safe
+ * to call before every request. Resolves `false` if the user declines, if
+ * `url` can't be an origin, or if a prompt for this plugin is already open.
+ *
+ * Any credential for the endpoint is the plugin's to hold — use
+ * {@link Plugin.saveLocalData} rather than {@link Plugin.saveData}, which
+ * syncs through the user's account preferences.
+ * @param {string} url
+ * @returns {Promise}
+ */
+export async function requestFetchPermission(url) {
+ return /** @type {boolean} */ (
+ await hostCall("requestFetchPermission", { url })
+ );
+}
+
+/**
+ * The origins the user has granted this plugin, as fetch patterns
+ * (`https://example.com/*`). Manifest-declared permissions are not included.
+ * @returns {Promise}
+ */
+export async function getUserGrantedFetchOrigins() {
+ return /** @type {string[]} */ (
+ await hostCall("getUserGrantedFetchOrigins", {})
+ );
+}
+
/**
* @param {unknown} value
* @returns {value is { forEach: (callback: (value: string, name: string) => void) => void }}
diff --git a/src/css/style.css b/src/css/style.css
index e2b6c029..c98b8bcb 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -7350,6 +7350,13 @@ context-menu-item-group > context-menu-item > button {
word-break: break-all;
}
+.permission-prompt-note {
+ display: block;
+ margin-top: 12px;
+ font-size: 13px;
+ color: var(--text-color-muted);
+}
+
@media (hover: hover) {
.modal-dialog-message a:hover {
text-decoration: underline;
@@ -10638,6 +10645,55 @@ rendered-markdown ul:has(> li > input[type="checkbox"]) {
color: var(--text-color);
}
+.plugin-system-settings {
+ display: block;
+ margin-top: 16px;
+}
+
+.plugin-system-settings-bar {
+ padding: 8px 16px;
+ background-color: var(--background-color-secondary);
+ border-top: 1px solid var(--generic-border-color);
+ border-bottom: 1px solid var(--generic-border-color);
+ color: var(--text-color-muted);
+}
+
+.plugin-system-settings-body {
+ padding: 16px;
+}
+
+.plugin-network-access-title {
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.plugin-network-access-description {
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--text-color-muted);
+}
+
+.plugin-network-access-list {
+ margin-top: 12px;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.plugin-network-access-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.plugin-network-access-item code {
+ font-family: var(--monospace-font, monospace);
+ font-size: 13px;
+ word-break: break-all;
+}
+
.plugin-content {
--plugin-prose-inset: 0px;
}
diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js
index 89aff693..be29de55 100644
--- a/src/js/plugins/pluginModal.js
+++ b/src/js/plugins/pluginModal.js
@@ -118,13 +118,15 @@ const ACTION_LABELS = {
'Send feed feedback (e.g. "show fewer/more like this") on your behalf',
};
-function permissionsSectionTemplate({ title, items }) {
+function permissionsSectionTemplate({ title, items = [] }) {
return html`
${title}
-
- ${items.map((item) => html`${item} `)}
-
+ ${items.length > 0
+ ? html`
+ ${items.map((item) => html`${item} `)}
+ `
+ : ""}
`;
}
@@ -140,6 +142,13 @@ function permissionsListTemplate({ permissions }) {
}),
);
}
+ if (permissions.userFetch) {
+ sections.push(
+ permissionsSectionTemplate({
+ title: "Send network requests to user-specified domains",
+ }),
+ );
+ }
const actionScopes = permissions.actions ?? [];
if (actionScopes.length > 0) {
sections.push(
@@ -169,6 +178,25 @@ export async function showPluginInstallPermissionsModal({
);
}
+export async function showPluginFetchPermissionModal({ pluginName, origin }) {
+ const name = pluginName ?? "This plugin";
+ return confirmModal(
+ html`
+ ${name} wants permission to:
+
+ ${permissionsSectionTemplate({
+ title: "Send network requests to:",
+ items: [html`${origin}`],
+ })}
+
`,
+ {
+ title: "Allow network access?",
+ confirmButtonText: "Allow",
+ },
+ );
+}
+
export async function showPluginUpdatePermissionsModal({
pluginName,
pluginVersion,
diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js
index 0b3ddbfd..771bfaa8 100644
--- a/src/js/plugins/pluginPermissions.js
+++ b/src/js/plugins/pluginPermissions.js
@@ -17,6 +17,7 @@ export function parsePermissions(permissions) {
);
if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns;
}
+ if (permissions.userFetch === true) parsed.userFetch = true;
if (permissions.actions) {
const actionsArray = Array.isArray(permissions.actions)
? permissions.actions
@@ -39,8 +40,17 @@ export function diffPermissions(current, next) {
const diff = {};
let hasAny = false;
for (const key of Object.keys(next)) {
- const have = new Set(current[key] ?? []);
- const added = (next[key] ?? []).filter((entry) => !have.has(entry));
+ const nextValue = next[key];
+ // Scope flags (userFetch) are booleans, not pattern lists
+ if (!Array.isArray(nextValue)) {
+ if (nextValue && !current[key]) {
+ diff[key] = nextValue;
+ hasAny = true;
+ }
+ continue;
+ }
+ const have = new Set(Array.isArray(current[key]) ? current[key] : []);
+ const added = nextValue.filter((entry) => !have.has(entry));
if (added.length > 0) {
diff[key] = added;
hasAny = true;
@@ -50,11 +60,45 @@ export function diffPermissions(current, next) {
}
export function isEmptyPermissions(obj) {
- return Object.values(obj).every(
- (entries) => !Array.isArray(entries) || entries.length === 0,
+ return Object.values(obj).every((value) =>
+ Array.isArray(value) ? value.length === 0 : !value,
);
}
+export function isUserFetchAllowed(permissions) {
+ return permissions.userFetch === true;
+}
+
+// Canonicalizes a plugin-supplied URL into an origin-scoped fetch pattern,
+// or null if it can't be one. User grants cover a whole origin: path is
+// discarded, port kept.
+export function normalizeFetchOrigin(url) {
+ let parsedUrl = null;
+ try {
+ parsedUrl = new URL(url);
+ } catch {
+ return null;
+ }
+ if (parsedUrl.username || parsedUrl.password) return null;
+ const host = parsedUrl.hostname.toLowerCase();
+ if (!host || host.includes("*")) return null;
+ if (parsedUrl.protocol === "http:") {
+ if (!isLoopbackHost(host)) return null;
+ } else if (parsedUrl.protocol !== "https:") {
+ return null;
+ }
+ const port = parsedUrl.port ? `:${parsedUrl.port}` : "";
+ return `${parsedUrl.protocol}//${host}${port}/*`;
+}
+
+// Sanitizes stored user-granted origins into canonical fetch patterns. The
+// installed-plugins list lives in the user's preferences record, we need to
+// sanitize before using it.
+export function parseUserGrantedFetchOrigins(origins) {
+ if (!Array.isArray(origins)) return [];
+ return unique(origins.map(normalizeFetchOrigin).filter(Boolean));
+}
+
export function isFetchAllowed(url, permissions) {
let parsedUrl = null;
try {
diff --git a/src/js/plugins/pluginPreferencesManager.js b/src/js/plugins/pluginPreferencesManager.js
index 63a8829c..770aac7f 100644
--- a/src/js/plugins/pluginPreferencesManager.js
+++ b/src/js/plugins/pluginPreferencesManager.js
@@ -1,4 +1,5 @@
import { Signal, ReactiveStore, ComputedMap } from "/js/signals.js";
+import { parseUserGrantedFetchOrigins } from "/js/plugins/pluginPermissions.js";
// Handles persisting plugin settings in user preferences
export class PluginPreferencesManager extends ReactiveStore {
@@ -81,6 +82,28 @@ export class PluginPreferencesManager extends ReactiveStore {
}));
}
+ // User-granted fetch origins are kept apart from the manifest permissions.
+ async addUserGrantedFetchOrigin(pluginId, origin) {
+ await this.updateInstalledPlugin(pluginId, (entry) => {
+ const granted = parseUserGrantedFetchOrigins(
+ entry.userGrantedFetchOrigins,
+ );
+ if (granted.includes(origin)) {
+ return { ...entry, userGrantedFetchOrigins: granted };
+ }
+ return { ...entry, userGrantedFetchOrigins: [...granted, origin] };
+ });
+ }
+
+ async removeUserGrantedFetchOrigin(pluginId, origin) {
+ await this.updateInstalledPlugin(pluginId, (entry) => ({
+ ...entry,
+ userGrantedFetchOrigins: parseUserGrantedFetchOrigins(
+ entry.userGrantedFetchOrigins,
+ ).filter((granted) => granted !== origin),
+ }));
+ }
+
readSettingsForPlugin(pluginId) {
return this.preferencesProvider
.requirePreferences()
diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js
index 8dc4416b..7263f2e4 100644
--- a/src/js/plugins/pluginService.js
+++ b/src/js/plugins/pluginService.js
@@ -5,6 +5,7 @@ import {
hidePluginModal,
showPluginInstallPermissionsModal,
showPluginUpdatePermissionsModal,
+ showPluginFetchPermissionModal,
} from "/js/plugins/pluginModal.js";
import { showPluginToast, hidePluginToast, showToast } from "/js/toasts.js";
import { PluginRenderer } from "/js/plugins/pluginRendering.js";
@@ -31,6 +32,9 @@ import {
diffPermissions,
isEmptyPermissions,
isActionAllowed,
+ isUserFetchAllowed,
+ parseUserGrantedFetchOrigins,
+ normalizeFetchOrigin,
} from "/js/plugins/pluginPermissions.js";
import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js";
import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js";
@@ -48,6 +52,17 @@ function requireHostMethodArg(method, name, value) {
export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview";
+function getGrantedOriginsForPlugin(entry) {
+ return parseUserGrantedFetchOrigins(entry?.userGrantedFetchOrigins);
+}
+
+// Whether the plugin's settings page has a host-owned section to render.
+// Keyed off the same normalization the section uses, so the settings link
+// can't point at an empty section.
+function hasSystemSettings(entry) {
+ return getGrantedOriginsForPlugin(entry).length > 0;
+}
+
// Page id must also be a valid URL segment
const PAGE_ID_PATTERN = /^[a-z0-9-]+$/;
@@ -173,6 +188,7 @@ export class PluginService extends ReactiveStore {
author: entry.author,
enabled: entry.enabled,
hasSettings: this.$settingTabs.get(entry.id) !== null,
+ hasSystemSettings: hasSystemSettings(entry),
}));
});
this.$settingTabs = new SignalMap();
@@ -205,6 +221,7 @@ export class PluginService extends ReactiveStore {
? new PluginLocalDataStore(session.did)
: new PluginMemoryDataStore();
this.isPreviewMode = false;
+ this._pendingFetchPermissionRequests = new Set();
this._dataLayer = dataLayer;
this._hiddenFeedItemsStore = hiddenFeedItemsStore;
this._setupRegistries();
@@ -521,6 +538,15 @@ export class PluginService extends ReactiveStore {
return pluginFetch(permissions, url, init);
});
+ this.pluginBridge.addHostMethod(
+ "requestFetchPermission",
+ (plugin, { url }) => this._requestFetchPermission(plugin.pluginId, url),
+ );
+
+ this.pluginBridge.addHostMethod("getUserGrantedFetchOrigins", (plugin) =>
+ this.getUserGrantedFetchOrigins(plugin.pluginId),
+ );
+
this.pluginBridge.addHostMethod("getPost", async (plugin, { uri }) => {
try {
return await this._dataLayer.declarative.ensurePost(uri);
@@ -671,7 +697,50 @@ export class PluginService extends ReactiveStore {
_getPermissionsForPlugin(pluginId) {
const entry = this.prefManager.$installedPlugin.get(pluginId);
- return parsePermissions(entry?.permissions ?? {});
+ const permissions = parsePermissions(entry?.permissions ?? {});
+ if (!isUserFetchAllowed(permissions)) return permissions;
+ const granted = this.getUserGrantedFetchOrigins(pluginId);
+ if (granted.length === 0) return permissions;
+ return {
+ ...permissions,
+ fetch: [...(permissions.fetch ?? []), ...granted],
+ };
+ }
+
+ async _requestFetchPermission(pluginId, url) {
+ const entry = this.prefManager.$installedPlugin.get(pluginId);
+ if (!isUserFetchAllowed(parsePermissions(entry?.permissions ?? {}))) {
+ throw new Error(`"${pluginId}" does not have the "userFetch" permission`);
+ }
+ const origin = normalizeFetchOrigin(url);
+ if (!origin) return false;
+ if (this._getPermissionsForPlugin(pluginId).fetch?.includes(origin)) {
+ return true;
+ }
+ if (this._pendingFetchPermissionRequests.has(pluginId)) return false;
+ this._pendingFetchPermissionRequests.add(pluginId);
+ let granted = false;
+ try {
+ granted = await showPluginFetchPermissionModal({
+ pluginName: entry?.name ?? null,
+ origin,
+ });
+ } finally {
+ this._pendingFetchPermissionRequests.delete(pluginId);
+ }
+ if (!granted) return false;
+ await this.prefManager.addUserGrantedFetchOrigin(pluginId, origin);
+ return true;
+ }
+
+ async revokeUserGrantedFetchOrigin(pluginId, origin) {
+ await this.prefManager.removeUserGrantedFetchOrigin(pluginId, origin);
+ }
+
+ getUserGrantedFetchOrigins(pluginId) {
+ return getGrantedOriginsForPlugin(
+ this.prefManager.$installedPlugin.get(pluginId),
+ );
}
_requireActionPermission(plugin, action) {
@@ -1022,14 +1091,23 @@ export class PluginService extends ReactiveStore {
if (!accepted) throw new PermissionsDeclinedError();
}
const { name, version, author, description } = liveManifest;
- await this.prefManager.updateInstalledPlugin(pluginId, (entry) => ({
- ...entry,
- name,
- version,
- author,
- description,
- permissions,
- }));
+ const keepUserGrantedFetch = isUserFetchAllowed(permissions);
+ await this.prefManager.updateInstalledPlugin(pluginId, (entry) => {
+ const next = {
+ ...entry,
+ name,
+ version,
+ author,
+ description,
+ permissions,
+ };
+ // Drop stored grants when the new manifest no longer requests
+ // userFetch, so a later manifest that re-adds the scope starts fresh.
+ if (!keepUserGrantedFetch && next.userGrantedFetchOrigins) {
+ delete next.userGrantedFetchOrigins;
+ }
+ return next;
+ });
await this.pluginBridge.reloadPlugin(
pluginId,
version,
diff --git a/src/js/views/installedPlugins.view.js b/src/js/views/installedPlugins.view.js
index d5f572b1..510bbcea 100644
--- a/src/js/views/installedPlugins.view.js
+++ b/src/js/views/installedPlugins.view.js
@@ -304,7 +304,8 @@ class InstalledPluginsView extends View {
: "Update"}
`
: ""}
- ${plugin.enabled && plugin.hasSettings
+ ${plugin.enabled &&
+ (plugin.hasSettings || plugin.hasSystemSettings)
? html`
+
+ `;
+}
+
+function systemSettingsTemplate({ origins, onRevoke }) {
+ return html`
+ System settings
+
+
Network access
+
+ You granted this plugin permission to send requests to these addresses.
+
+
+ ${origins.map(
+ (origin) =>
+ html`
+ ${origin}
+ onRevoke(origin)}
+ >
+ Revoke
+
+ `,
+ )}
+
+
+ `;
+}
class PluginSettingsView extends View {
async render({ root, router, layout, params, context: { pluginService } }) {
@@ -74,25 +123,40 @@ class PluginSettingsView extends View {
${pluginLoadError.message ?? "This plugin failed to load."}
`;
}
+ const grantedOrigins =
+ pluginService.getUserGrantedFetchOrigins(pluginId);
if (!settingTab) {
if (pluginLoading) {
return html``;
}
- return html`
- This plugin has no settings.
-
`;
+ if (grantedOrigins.length === 0) {
+ return html`
+ This plugin has no settings.
+
`;
+ }
}
- return html``;
+ return html`${settingTab
+ ? pluginOwnedSettingsTemplate({ pluginService, settingTab })
+ : ""}
+ ${grantedOrigins.length > 0
+ ? systemSettingsTemplate({
+ origins: grantedOrigins,
+ onRevoke: (origin) =>
+ pluginService
+ .revokeUserGrantedFetchOrigin(pluginId, origin)
+ .catch((e) => {
+ console.error(e);
+ showToast("Failed to revoke access", {
+ style: "error",
+ });
+ }),
+ })
+ : ""}`;
})()}
`,
diff --git a/tests/e2e/specs/views/installedPlugins.view.test.js b/tests/e2e/specs/views/installedPlugins.view.test.js
index 758ee692..10b6b3cb 100644
--- a/tests/e2e/specs/views/installedPlugins.view.test.js
+++ b/tests/e2e/specs/views/installedPlugins.view.test.js
@@ -1,7 +1,11 @@
import { test, expect } from "../../base.js";
import { login } from "../../helpers.js";
import { MockServer } from "../../mockServer.js";
-import { TEST_PLUGIN_ID, TEST_PLUGIN_MANIFEST } from "../../testPlugins.js";
+import {
+ TEST_PLUGIN_ID,
+ TEST_PLUGIN_MANIFEST,
+ getNoSettingsPluginSource,
+} from "../../testPlugins.js";
function seedInstalled(mockServer) {
mockServer.installedPlugins = [{ ...TEST_PLUGIN_MANIFEST, enabled: false }];
@@ -94,6 +98,31 @@ test.describe("Installed plugins view", () => {
});
});
+ test("shows the Settings link for a plugin whose only settings are system-owned", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.localPluginSource = getNoSettingsPluginSource();
+ await mockServer.setup(page);
+ await login(page);
+ mockServer.installedPlugins = [
+ {
+ ...TEST_PLUGIN_MANIFEST,
+ enabled: true,
+ permissions: { userFetch: true },
+ userGrantedFetchOrigins: ["https://api.example.com/*"],
+ },
+ ];
+
+ await page.goto("/plugins/installed");
+ const sampleItem = page.locator(".plugin-list-item", {
+ hasText: "Test Plugin",
+ });
+ await expect(sampleItem.locator(".plugin-settings-link")).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
test("uninstall button removes the plugin after confirmation", async ({
page,
}) => {
diff --git a/tests/e2e/specs/views/pluginSettings.view.test.js b/tests/e2e/specs/views/pluginSettings.view.test.js
index f5b4811d..34514598 100644
--- a/tests/e2e/specs/views/pluginSettings.view.test.js
+++ b/tests/e2e/specs/views/pluginSettings.view.test.js
@@ -12,6 +12,8 @@ import {
getFailingPluginSource,
getNoSettingsPluginSource,
getTestPluginSource,
+ getFetchPermissionPluginSource,
+ REQUESTED_FETCH_ORIGIN,
} from "../../testPlugins.js";
const PLUGIN_ID = TEST_PLUGIN_ID;
@@ -337,6 +339,121 @@ test.describe("Plugin settings view", () => {
).toBeVisible({ timeout: 10000 });
});
+ test.describe("User-granted fetch origins", () => {
+ function seedPromptingPlugin(mockServer) {
+ mockServer.installedPlugins = [
+ {
+ ...TEST_PLUGIN_MANIFEST,
+ enabled: true,
+ permissions: { userFetch: true },
+ },
+ ];
+ }
+
+ test("grants an origin from the prompt and lists it in settings", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.localPluginSource = getFetchPermissionPluginSource();
+ await mockServer.setup(page);
+ await login(page);
+ seedPromptingPlugin(mockServer);
+
+ await page.goto(`/plugin/${PLUGIN_ID}/settings`);
+ await expect(
+ page.locator('[data-testid="fetch-permission-prompt"]'),
+ ).toBeVisible({ timeout: 10000 });
+ await page.locator('[data-testid="modal-confirm-button"]').click();
+
+ const networkAccess = page.locator(
+ '[data-testid="plugin-network-access"]',
+ );
+ await expect(networkAccess).toBeVisible({ timeout: 10000 });
+ await expect(networkAccess).toContainText(REQUESTED_FETCH_ORIGIN);
+ });
+
+ test("revoking removes the origin", async ({ page }) => {
+ const mockServer = new MockServer();
+ mockServer.localPluginSource = getFetchPermissionPluginSource();
+ await mockServer.setup(page);
+ await login(page);
+ seedPromptingPlugin(mockServer);
+
+ await page.goto(`/plugin/${PLUGIN_ID}/settings`);
+ await page
+ .locator('[data-testid="modal-confirm-button"]')
+ .click({ timeout: 10000 });
+ const networkAccess = page.locator(
+ '[data-testid="plugin-network-access"]',
+ );
+ await expect(networkAccess).toBeVisible({ timeout: 10000 });
+
+ await networkAccess
+ .locator('[data-testid="revoke-fetch-origin"]')
+ .click();
+ await expect(networkAccess).toBeHidden({ timeout: 10000 });
+ });
+
+ test("renders plugin-owned and system-owned settings as separate sections", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.localPluginSource = getTestPluginSource();
+ await mockServer.setup(page);
+ await login(page);
+ mockServer.installedPlugins = [
+ {
+ ...TEST_PLUGIN_MANIFEST,
+ enabled: true,
+ permissions: { userFetch: true },
+ userGrantedFetchOrigins: [REQUESTED_FETCH_ORIGIN],
+ },
+ ];
+
+ await page.goto(`/plugin/${PLUGIN_ID}/settings`);
+ await expect(
+ page.locator('[data-testid="plugin-owned-settings"]'),
+ ).toBeVisible({ timeout: 10000 });
+ await expect(
+ page.locator('[data-testid="plugin-system-settings"]'),
+ ).toBeVisible();
+ });
+
+ test("omits the system section when nothing is granted", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.localPluginSource = getTestPluginSource();
+ await mockServer.setup(page);
+ await login(page);
+ seedEnabled(mockServer);
+
+ await page.goto(`/plugin/${PLUGIN_ID}/settings`);
+ await expect(
+ page.locator('[data-testid="plugin-owned-settings"]'),
+ ).toBeVisible({ timeout: 10000 });
+ await expect(
+ page.locator('[data-testid="plugin-system-settings"]'),
+ ).toBeHidden();
+ });
+
+ test("declining leaves nothing granted", async ({ page }) => {
+ const mockServer = new MockServer();
+ mockServer.localPluginSource = getFetchPermissionPluginSource();
+ await mockServer.setup(page);
+ await login(page);
+ seedPromptingPlugin(mockServer);
+
+ await page.goto(`/plugin/${PLUGIN_ID}/settings`);
+ await page
+ .locator('[data-testid="modal-cancel-button"]')
+ .click({ timeout: 10000 });
+ await expect(
+ page.locator('[data-testid="plugin-network-access"]'),
+ ).toBeHidden();
+ });
+ });
+
test.describe("Logged-out behavior", () => {
test("redirects to /login when not authenticated", async ({ page }) => {
await page.goto(`/plugin/${PLUGIN_ID}/settings`);
diff --git a/tests/e2e/testPlugins.js b/tests/e2e/testPlugins.js
index c92b6299..1d6bcacd 100644
--- a/tests/e2e/testPlugins.js
+++ b/tests/e2e/testPlugins.js
@@ -256,6 +256,20 @@ class TestPlugin extends Plugin {
TestPlugin.register();
`;
+// Asks for a user-granted fetch origin as soon as it loads, so the host's
+// permission prompt appears without needing plugin-rendered UI to trigger it.
+export const REQUESTED_FETCH_ORIGIN = "https://api.example.com/*";
+
+const FETCH_PERMISSION_PLUGIN_BODY = /* js */ `
+class TestPlugin extends Plugin {
+ async onload() {
+ await requestFetchPermission("https://api.example.com/v1/chat");
+ }
+}
+
+TestPlugin.register();
+`;
+
// A plugin that seeds the composer with a signature string on every open
// (post and reply). Used by composer-init e2e tests.
const POST_COMPOSER_INIT_PLUGIN_BODY = /* js */ `
@@ -348,6 +362,10 @@ export function getNoSettingsPluginSource() {
return getWorkerSource() + "\n" + NO_SETTINGS_PLUGIN_BODY;
}
+export function getFetchPermissionPluginSource() {
+ return getWorkerSource() + "\n" + FETCH_PERMISSION_PLUGIN_BODY;
+}
+
export function getPostComposerInitPluginSource() {
return getWorkerSource() + "\n" + POST_COMPOSER_INIT_PLUGIN_BODY;
}
diff --git a/tests/unit/specs/plugins/pluginModal.test.js b/tests/unit/specs/plugins/pluginModal.test.js
index 61b9afec..3b90267d 100644
--- a/tests/unit/specs/plugins/pluginModal.test.js
+++ b/tests/unit/specs/plugins/pluginModal.test.js
@@ -4,8 +4,11 @@ import {
showPluginModal as _showPluginModal,
updatePluginModal as _updatePluginModal,
hidePluginModal,
+ showPluginInstallPermissionsModal,
+ showPluginFetchPermissionModal,
} from "/js/plugins/pluginModal.js";
import { PluginRenderer } from "/js/plugins/pluginRendering.js";
+import { respondToConfirm, waitFor } from "../../testHelpers.js";
function showPluginModal(opts) {
const pluginRenderer = new PluginRenderer(null, opts.pluginId);
@@ -442,3 +445,39 @@ describe("hidePluginModal", () => {
assert.deepEqual(onDismiss.mock.callCount(), 0);
});
});
+
+describe("permission prompts", () => {
+ it("describes a userFetch scope in the install prompt", async () => {
+ const prompting = showPluginInstallPermissionsModal({
+ pluginName: "Alpha",
+ permissions: { userFetch: true },
+ });
+ await waitFor(() =>
+ document.querySelector('[data-testid="permission-prompt"]'),
+ );
+ const prompt = document.querySelector('[data-testid="permission-prompt"]');
+ // A userFetch-only manifest must not render an empty permission list
+ assert(prompt.querySelector(".permission-prompt-section"));
+ await respondToConfirm(false);
+ await prompting;
+ });
+
+ it("renders the requested origin in the fetch prompt", async () => {
+ const prompting = showPluginFetchPermissionModal({
+ pluginName: "Alpha",
+ origin: "https://api.example.com/*",
+ });
+ await waitFor(() =>
+ document.querySelector('[data-testid="fetch-permission-prompt"]'),
+ );
+ const prompt = document.querySelector(
+ '[data-testid="fetch-permission-prompt"]',
+ );
+ assert.equal(
+ prompt.querySelector("code").textContent,
+ "https://api.example.com/*",
+ );
+ await respondToConfirm(false);
+ assert.equal(await prompting, false);
+ });
+});
diff --git a/tests/unit/specs/plugins/pluginPermissions.test.js b/tests/unit/specs/plugins/pluginPermissions.test.js
index 904cf119..afc20c8b 100644
--- a/tests/unit/specs/plugins/pluginPermissions.test.js
+++ b/tests/unit/specs/plugins/pluginPermissions.test.js
@@ -6,6 +6,9 @@ import {
isEmptyPermissions,
isFetchAllowed,
isActionAllowed,
+ isUserFetchAllowed,
+ normalizeFetchOrigin,
+ parseUserGrantedFetchOrigins,
} from "/js/plugins/pluginPermissions.js";
describe("parsePermissions", () => {
@@ -137,6 +140,38 @@ describe("diffPermissions", () => {
});
});
+describe("diffPermissions (boolean scopes)", () => {
+ it("reports a newly declared userFetch", () => {
+ assert.deepEqual(diffPermissions({}, { userFetch: true }), {
+ userFetch: true,
+ });
+ });
+
+ it("does not report a userFetch that was already granted", () => {
+ assert.equal(
+ diffPermissions({ userFetch: true }, { userFetch: true }),
+ null,
+ );
+ });
+
+ it("diffs patterns alongside a boolean scope", () => {
+ assert.deepEqual(
+ diffPermissions(
+ { fetch: ["https://a.com/*"] },
+ { fetch: ["https://a.com/*", "https://b.com/*"], userFetch: true },
+ ),
+ { fetch: ["https://b.com/*"], userFetch: true },
+ );
+ });
+
+ it("survives a stored value whose shape doesn't match the manifest", () => {
+ assert.deepEqual(
+ diffPermissions({ fetch: true }, { fetch: ["https://a.com/*"] }),
+ { fetch: ["https://a.com/*"] },
+ );
+ });
+});
+
describe("isEmptyPermissions (missing-key shape)", () => {
it("returns true for an empty object", () => {
assert(isEmptyPermissions({}));
@@ -151,6 +186,140 @@ describe("isEmptyPermissions", () => {
it("returns false when any array is non-empty", () => {
assert(!isEmptyPermissions({ fetch: ["https://a.com/*"] }));
});
+
+ // Load-bearing: this is what keeps a prompting plugin out of preview
+ // installs, and userFetch is a boolean rather than an array.
+ it("returns false for a userFetch grant", () => {
+ assert(!isEmptyPermissions({ userFetch: true }));
+ });
+
+ it("returns true for a falsy userFetch", () => {
+ assert(isEmptyPermissions({ userFetch: false }));
+ });
+});
+
+describe("parsePermissions (userFetch)", () => {
+ it("keeps a literal true", () => {
+ assert.deepEqual(parsePermissions({ userFetch: true }), {
+ userFetch: true,
+ });
+ });
+
+ it("drops truthy non-boolean values", () => {
+ assert.deepEqual(parsePermissions({ userFetch: "yes" }), {});
+ assert.deepEqual(parsePermissions({ userFetch: 1 }), {});
+ assert.deepEqual(parsePermissions({ userFetch: false }), {});
+ });
+});
+
+describe("isUserFetchAllowed", () => {
+ it("requires the parsed flag", () => {
+ assert(isUserFetchAllowed({ userFetch: true }));
+ assert(!isUserFetchAllowed({}));
+ assert(!isUserFetchAllowed({ fetch: ["https://a.com/*"] }));
+ });
+});
+
+describe("parseUserGrantedFetchOrigins", () => {
+ it("normalizes and de-duplicates stored origins", () => {
+ assert.deepEqual(
+ parseUserGrantedFetchOrigins([
+ "https://api.example.com/v1",
+ "https://api.example.com/v2",
+ "http://localhost:11434/api",
+ ]),
+ ["https://api.example.com/*", "http://localhost:11434/*"],
+ );
+ });
+
+ // The preferences record is writable by anything holding the account's
+ // credentials, so stored values are untrusted input
+ it("drops anything that isn't a normalizable origin", () => {
+ assert.deepEqual(
+ parseUserGrantedFetchOrigins([
+ "https://*/*",
+ "http://evil.example.com/*",
+ "not a url",
+ 42,
+ null,
+ ]),
+ [],
+ );
+ });
+
+ it("returns an empty array for a non-array value", () => {
+ assert.deepEqual(parseUserGrantedFetchOrigins(undefined), []);
+ assert.deepEqual(parseUserGrantedFetchOrigins("https://a.com/*"), []);
+ assert.deepEqual(parseUserGrantedFetchOrigins(null), []);
+ });
+});
+
+describe("normalizeFetchOrigin", () => {
+ it("discards the path and keeps the origin", () => {
+ assert.equal(
+ normalizeFetchOrigin("https://api.example.com/v1/chat?key=1#x"),
+ "https://api.example.com/*",
+ );
+ });
+
+ it("preserves an explicit port", () => {
+ assert.equal(
+ normalizeFetchOrigin("http://localhost:11434/api/generate"),
+ "http://localhost:11434/*",
+ );
+ });
+
+ it("drops a default port, matching URL normalization", () => {
+ assert.equal(
+ normalizeFetchOrigin("https://example.com:443/foo"),
+ "https://example.com/*",
+ );
+ });
+
+ it("lowercases the host", () => {
+ assert.equal(
+ normalizeFetchOrigin("https://API.Example.COM/foo"),
+ "https://api.example.com/*",
+ );
+ });
+
+ it("allows http only for loopback", () => {
+ assert.equal(
+ normalizeFetchOrigin("http://127.0.0.1:8080/"),
+ "http://127.0.0.1:8080/*",
+ );
+ assert.equal(
+ normalizeFetchOrigin("http://[::1]:8080/"),
+ "http://[::1]:8080/*",
+ );
+ assert.equal(normalizeFetchOrigin("http://example.com/"), null);
+ });
+
+ it("rejects embedded credentials", () => {
+ assert.equal(normalizeFetchOrigin("https://user:pass@example.com/"), null);
+ assert.equal(normalizeFetchOrigin("https://user@example.com/"), null);
+ });
+
+ it("rejects non-http(s) schemes", () => {
+ assert.equal(normalizeFetchOrigin("ftp://example.com/"), null);
+ assert.equal(normalizeFetchOrigin("javascript:alert(1)"), null);
+ assert.equal(normalizeFetchOrigin("data:text/plain,hi"), null);
+ });
+
+ it("rejects unparseable input", () => {
+ assert.equal(normalizeFetchOrigin("not a url"), null);
+ assert.equal(normalizeFetchOrigin(""), null);
+ assert.equal(normalizeFetchOrigin(null), null);
+ });
+
+ it("produces a pattern that isFetchAllowed accepts for that origin only", () => {
+ const permissions = {
+ fetch: [normalizeFetchOrigin("https://api.example.com/v1")],
+ };
+ assert(isFetchAllowed("https://api.example.com/other", permissions));
+ assert(!isFetchAllowed("https://evil.example.com/", permissions));
+ assert(!isFetchAllowed("http://api.example.com/", permissions));
+ });
});
describe("isFetchAllowed", () => {
diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js
index 951725d9..4f34b34b 100644
--- a/tests/unit/specs/plugins/pluginService.test.js
+++ b/tests/unit/specs/plugins/pluginService.test.js
@@ -9,6 +9,7 @@ import { EventEmitter } from "/js/eventEmitter.js";
import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";
import { Constellation } from "/js/constellation.js";
import { respondToConfirm } from "../../testHelpers.js";
+import { isFetchAllowed } from "/js/plugins/pluginPermissions.js";
function emptyDataLayer() {
const dataLayer = new EventEmitter();
@@ -822,6 +823,44 @@ describe("$pluginsInfo", () => {
assert.deepEqual(info[0].id, "alpha");
});
+ it("flags hasSystemSettings from granted fetch origins", () => {
+ const { service, state } = makeService({});
+ state.installedPlugins = [
+ { id: "alpha", name: "Alpha", version: "1.0.0", enabled: true },
+ {
+ id: "beta",
+ name: "Beta",
+ version: "1.0.0",
+ enabled: true,
+ userGrantedFetchOrigins: ["https://api.example.com/*"],
+ },
+ ];
+ const info = service.$pluginsInfo.get();
+ assert.equal(
+ info.find((plugin) => plugin.id === "alpha").hasSystemSettings,
+ false,
+ );
+ assert.equal(
+ info.find((plugin) => plugin.id === "beta").hasSystemSettings,
+ true,
+ );
+ });
+
+ it("does not flag hasSystemSettings for stored origins that can't be normalized", () => {
+ const { service, state } = makeService({});
+ state.installedPlugins = [
+ {
+ id: "alpha",
+ name: "Alpha",
+ version: "1.0.0",
+ enabled: true,
+ userGrantedFetchOrigins: ["https://*/*", 42],
+ },
+ ];
+ // Otherwise the settings link would point at an empty system section
+ assert.equal(service.$pluginsInfo.get()[0].hasSystemSettings, false);
+ });
+
it("includes __LOCAL plugins when localPluginsEnabled is true", () => {
const { service, state } = makeService({ localListings: [] });
state.installedPlugins = [
@@ -2590,3 +2629,226 @@ describe("getPostComposerInit", () => {
});
});
});
+
+describe("user-granted fetch origins", () => {
+ async function makeInstalledService({
+ permissions = { userFetch: true },
+ } = {}) {
+ const { state, provider } = makeProvider();
+ const service = makeServiceWithRealBridge({ provider });
+ await service.prefManager.addInstalledPlugin({
+ id: "alpha",
+ name: "Alpha",
+ version: "1.0.0",
+ author: "Someone",
+ repo: "ow/alpha",
+ enabled: true,
+ permissions,
+ });
+ return { service, state };
+ }
+
+ function callRequest(service, url) {
+ return service.pluginBridge._hostCallHandlers.get("requestFetchPermission")(
+ { pluginId: "alpha" },
+ { url },
+ );
+ }
+
+ function storedEntry(state) {
+ return state.installedPlugins.find((plugin) => plugin.id === "alpha");
+ }
+
+ it("throws for a plugin without the userFetch permission", async () => {
+ const { service } = await makeInstalledService({ permissions: {} });
+ await assert.rejects(
+ () => callRequest(service, "https://api.example.com/v1"),
+ /userFetch/,
+ );
+ });
+
+ it("grants the origin when the user confirms", async () => {
+ const { service, state } = await makeInstalledService();
+ const requesting = callRequest(service, "https://api.example.com/v1/chat");
+ await respondToConfirm(true);
+ assert.equal(await requesting, true);
+ assert.deepEqual(storedEntry(state).userGrantedFetchOrigins, [
+ "https://api.example.com/*",
+ ]);
+ assert(
+ isFetchAllowed(
+ "https://api.example.com/anything",
+ service._getPermissionsForPlugin("alpha"),
+ ),
+ );
+ });
+
+ it("does not write the grant into the manifest permissions", async () => {
+ const { service, state } = await makeInstalledService();
+ const requesting = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(true);
+ await requesting;
+ // Laundering a user grant into entry.permissions would make a later
+ // manifest claiming the same host diff to nothing.
+ assert.deepEqual(storedEntry(state).permissions, { userFetch: true });
+ });
+
+ it("resolves true without prompting when already granted", async () => {
+ const { service } = await makeInstalledService();
+ const requesting = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(true);
+ await requesting;
+ assert.equal(
+ await callRequest(service, "https://api.example.com/other"),
+ true,
+ );
+ assert.equal(
+ document.querySelector('[data-testid="fetch-permission-prompt"]'),
+ null,
+ );
+ });
+
+ it("resolves false without prompting for a url that can't be an origin", async () => {
+ const { service } = await makeInstalledService();
+ assert.equal(await callRequest(service, "http://example.com/"), false);
+ assert.equal(await callRequest(service, "not a url"), false);
+ assert.equal(
+ document.querySelector('[data-testid="fetch-permission-prompt"]'),
+ null,
+ );
+ });
+
+ it("grants nothing when the user declines", async () => {
+ const { service, state } = await makeInstalledService();
+ const requesting = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(false);
+ assert.equal(await requesting, false);
+ assert.equal(storedEntry(state).userGrantedFetchOrigins, undefined);
+ });
+
+ it("prompts again after a decline", async () => {
+ const { service, state } = await makeInstalledService();
+ const declining = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(false);
+ await declining;
+
+ const retrying = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(true);
+ assert.equal(await retrying, true);
+ assert.deepEqual(storedEntry(state).userGrantedFetchOrigins, [
+ "https://api.example.com/*",
+ ]);
+ });
+
+ it("refuses a second request while a prompt is open", async () => {
+ const { service } = await makeInstalledService();
+ const first = callRequest(service, "https://api.example.com/v1");
+ const second = await callRequest(service, "https://other.example.com/v1");
+ assert.equal(second, false);
+ await respondToConfirm(true);
+ assert.equal(await first, true);
+ });
+
+ it("ignores a stored grant that isn't a normalizable origin", async () => {
+ const { service } = await makeInstalledService();
+ await service.prefManager.updateInstalledPlugin("alpha", (entry) => ({
+ ...entry,
+ userGrantedFetchOrigins: ["https://*/*", "http://evil.example.com/*", 42],
+ }));
+ const permissions = service._getPermissionsForPlugin("alpha");
+ assert.deepEqual(permissions.fetch ?? [], []);
+ assert(!isFetchAllowed("https://evil.example.com/", permissions));
+ });
+
+ it("merges user grants with manifest patterns", async () => {
+ const { service } = await makeInstalledService({
+ permissions: { userFetch: true, fetch: ["https://declared.example/*"] },
+ });
+ const requesting = callRequest(service, "https://granted.example/v1");
+ await respondToConfirm(true);
+ await requesting;
+ const permissions = service._getPermissionsForPlugin("alpha");
+ assert(isFetchAllowed("https://declared.example/x", permissions));
+ assert(isFetchAllowed("https://granted.example/x", permissions));
+ });
+
+ it("revokes a granted origin", async () => {
+ const { service } = await makeInstalledService();
+ const requesting = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(true);
+ await requesting;
+ await service.revokeUserGrantedFetchOrigin(
+ "alpha",
+ "https://api.example.com/*",
+ );
+ assert.deepEqual(service.getUserGrantedFetchOrigins("alpha"), []);
+ assert(
+ !isFetchAllowed(
+ "https://api.example.com/x",
+ service._getPermissionsForPlugin("alpha"),
+ ),
+ );
+ });
+
+ it("exposes granted origins to the plugin", async () => {
+ const { service } = await makeInstalledService();
+ const requesting = callRequest(service, "https://api.example.com/v1");
+ await respondToConfirm(true);
+ await requesting;
+ const origins = await service.pluginBridge._hostCallHandlers.get(
+ "getUserGrantedFetchOrigins",
+ )({ pluginId: "alpha" }, {});
+ assert.deepEqual(origins, ["https://api.example.com/*"]);
+ });
+});
+
+describe("preview installs and userFetch", () => {
+ // Preview's guarantee is "runs with nothing granted"; a plugin that can
+ // prompt for origins at runtime must not slip through it.
+ it("refuses to preview a plugin declaring userFetch", async () => {
+ const { state, provider } = makeProvider();
+ const service = makeServiceWithRealBridge({ provider });
+ service.remoteRegistry = {
+ getListing: async () => ({ id: "alpha", repo: "ow/alpha" }),
+ getListings: async () => [{ id: "alpha", repo: "ow/alpha" }],
+ };
+ service.sourceProvider = {
+ getLiveManifest: async () => ({
+ id: "alpha",
+ name: "Alpha",
+ version: "1.0.0",
+ permissions: { userFetch: true },
+ }),
+ };
+
+ await service._installPreviewPlugin("alpha");
+
+ assert.deepEqual(state.installedPlugins, []);
+ });
+});
+
+describe("updating a plugin that adds userFetch", () => {
+ it("prompts with the new scope and stores it", async () => {
+ const { service, state } = makeService({
+ remoteListings: [{ id: "alpha", repo: "ow/alpha" }],
+ liveManifests: {
+ alpha: { id: "alpha", name: "Alpha", version: "1.0.0" },
+ },
+ });
+ await service.installPlugin("alpha");
+ service.sourceProvider.getLiveManifest = async () => ({
+ id: "alpha",
+ name: "Alpha",
+ version: "1.1.0",
+ permissions: { userFetch: true },
+ });
+
+ const updating = service.updatePlugin("alpha");
+ await respondToConfirm(true);
+ assert.deepEqual(await updating, { updated: true, version: "1.1.0" });
+ const entry = state.installedPlugins.find(
+ (plugin) => plugin.id === "alpha",
+ );
+ assert.deepEqual(entry.permissions, { userFetch: true });
+ });
+});