- host plugins.rs: PERMISSIONS, parse_permissions, ScriptPlugin.permissions/problems (None = legacy, full API), validate_plugin (file name, 2 MiB, UTF-8 text without binary bytes, header with @name and @version, valid @permissions, network:none refused when the code calls network APIs), source_findings, PluginStore::install (atomic rename, never enables, keeps the enabled state on update) / remove / is_enabled / file_hash. start/stop scripts run the module body, construction, start() and stop() inside window.<global>.withPlugin(id, grants, fn) when the runtime has it. - runtime permissions.ts: parsePermissions, scopePluginApi (undeclared gated members throw a PluginPermissionError; scope().ui gated like api.ui), createPluginContext (the view of the plugin whose code runs; legacy = full API). A declaration and guard rail, not a sandbox. - The `commands` permission name comes from the parallel palette-commands work (its API lands there). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
5.7 KiB
TypeScript
92 lines
5.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts';
|
|
import { CORE_GATES, PluginPermissionError, createPluginContext, parsePermissions, scopePluginApi } from '../src/permissions.ts';
|
|
|
|
const SITE: PluginApiSite = {
|
|
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
|
|
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'],
|
|
};
|
|
|
|
function fakeApi() {
|
|
const noop = () => {};
|
|
const doc = {
|
|
activeElement: null, documentElement: {}, addEventListener: noop, removeEventListener: noop,
|
|
head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } },
|
|
querySelectorAll: () => [],
|
|
createElement: () => ({ dataset: {}, textContent: '', isConnected: false, remove() {} }),
|
|
};
|
|
const store = new Map<string, string>();
|
|
const win = {
|
|
document: doc, location: { href: 'https://site.test/' }, addEventListener: noop, removeEventListener: noop,
|
|
localStorage: { get length() { return store.size; }, key: (i: number) => [...store.keys()][i] ?? null, getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => store.set(k, v), removeItem: (k: string) => store.delete(k) },
|
|
navigator: { clipboard: { writeText: async () => {} } },
|
|
requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: noop,
|
|
MutationObserver: class { observe() {} disconnect() {} },
|
|
};
|
|
const core = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
|
|
// A site adds its own gated members, like X's `account()` and `users`.
|
|
const users = Object.freeze({ get: () => null, onUpdate: () => () => {} });
|
|
return Object.freeze(Object.assign(core, { account: () => ({ handle: 'me' }), users }));
|
|
}
|
|
const GATES = { ...CORE_GATES, account: 'account', users: 'users' } as const;
|
|
|
|
test('parsePermissions mirrors the native header rules', () => {
|
|
assert.deepEqual(parsePermissions('storage, ui clipboard,UI'), { permissions: ['storage', 'ui', 'clipboard'] });
|
|
assert.deepEqual(parsePermissions('network:none'), { permissions: ['network:none'] });
|
|
assert.deepEqual(parsePermissions('none'), { permissions: [] });
|
|
assert.match((parsePermissions('storage, cookies') as { error: string }).error, /unknown permission "cookies"/);
|
|
assert.match((parsePermissions('network, network:none') as { error: string }).error, /contradict/);
|
|
});
|
|
|
|
test('undeclared members throw a clear PluginPermissionError; declared ones work', async () => {
|
|
const api = scopePluginApi(fakeApi(), ['storage'], 'notes.plugin.js', GATES);
|
|
assert.equal(api.storage('n').set('a', 1), true, 'storage granted');
|
|
assert.equal(typeof api.injectCSS, 'function', 'DOM helpers need no permission');
|
|
assert.equal(typeof api.theme.current, 'function', 'theme needs no permission');
|
|
assert.throws(() => api.copyText('x'), (error: unknown) => error instanceof PluginPermissionError && error.permission === 'clipboard' && /notes\.plugin\.js: api\.copyText needs the "clipboard" permission/.test(error.message));
|
|
assert.throws(() => api.toast('hi'), /api\.toast needs the "ui" permission/);
|
|
assert.throws(() => api.ui.confirm({ title: 'x' } as never), /api\.ui\.confirm needs the "ui" permission/);
|
|
assert.throws(() => (api as unknown as { account(): unknown }).account(), /"account" permission/);
|
|
assert.throws(() => (api as unknown as { users: { get(h: string): unknown } }).users.get('x'), /api\.users\.get needs the "users" permission/);
|
|
assert.ok(Object.isFrozen(api), 'a plugin cannot patch its view');
|
|
});
|
|
|
|
test('scope().ui is gated like api.ui; the rest of the scope works and disposes', () => {
|
|
const api = scopePluginApi(fakeApi(), [], 'plain.plugin.js', GATES);
|
|
const scope = api.scope();
|
|
let disposed = 0;
|
|
scope.defer(() => { disposed += 1; });
|
|
scope.css('x', 'a{}');
|
|
assert.throws(() => scope.ui.toast('hi'), /scope\(\)\.ui\.toast needs the "ui" permission/);
|
|
scope.dispose();
|
|
assert.equal(disposed, 1);
|
|
const withUi = scopePluginApi(fakeApi(), ['ui'], 'ui.plugin.js', GATES).scope();
|
|
assert.equal(typeof withUi.ui.toast, 'function');
|
|
});
|
|
|
|
test('the context hands each plugin its own view while its code runs; legacy plugins get the full API', () => {
|
|
const full = fakeApi();
|
|
const context = createPluginContext(full, GATES);
|
|
assert.equal(context.current(), full, 'outside any plugin: the full API');
|
|
const seen = context.run('a.plugin.js', ['clipboard'], () => context.current());
|
|
assert.notEqual(seen, full);
|
|
assert.throws(() => seen.storage('x'), /a\.plugin\.js: api\.storage needs the "storage" permission/);
|
|
// stop() runs later without grants: the registered view is reused.
|
|
assert.equal(context.run('a.plugin.js', undefined, () => context.current()), seen);
|
|
assert.deepEqual(context.grants('a.plugin.js'), ['clipboard']);
|
|
// Nested: a plugin started from inside another's code gets its own view, then the outer one returns.
|
|
context.run('outer.plugin.js', ['ui'], () => {
|
|
const outer = context.current();
|
|
assert.equal(context.run('legacy.plugin.js', null, () => context.current()), full, 'legacy = full API');
|
|
assert.equal(context.current(), outer);
|
|
});
|
|
assert.equal(context.grants('legacy.plugin.js'), null);
|
|
assert.equal(context.current(), full);
|
|
// An exception inside a plugin never leaves its view installed.
|
|
assert.throws(() => context.run('boom.plugin.js', [], () => { throw new Error('boom'); }), /boom/);
|
|
assert.equal(context.current(), full);
|
|
// Unknown grant names are dropped rather than trusted.
|
|
context.run('odd.plugin.js', ['storage', 'root'], () => {});
|
|
assert.deepEqual(context.grants('odd.plugin.js'), ['storage']);
|
|
});
|