mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
The AI assistant's "more actions" need host calls today's plugin API lacks. Area c of lib/plugin-sandbox/assistant adds them: - `calendar.respond(emailId, status)`, new permission calendar:respond: answers an invitation of the user's own account through lib/calendar-rsvp.ts, like the reader's banner - `calendar.deleteEvent` now returns an undo token for a single event; `calendar.undoDelete` creates it again from the host's copy - `filters.list/create/undo` over Settings > Filters' model; forward, redirect, reject, discard and keep are refused, and so are folders that are shared or not the account's own - `email.batch/undoBatch`: up to 200 messages through the store's batch functions, restore points first, one entry in the undo history - `vacation.get` also says whether the out-of-office forwards mail - `app.describeSettings`, `app.setSettings` and `app.undoSettings`, new permission settings:change, over the allow-list in lib/settings-schema.ts, refusing locked, hidden, restricted and gated settings when the change is made Undo tokens stay in the host for 30 minutes, bound to the plugin and the account. app.getSettings now shows every setting setSettings may change. settings:write's text is reworded to what it gates (tags). plugin-host-api-app-info gains a test; its other assertions are as they were.
99 lines
3.9 KiB
TypeScript
99 lines
3.9 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types';
|
|
import { HOOK_CATALOG } from '../plugin-hook-catalog';
|
|
import { SLOT_CATALOG } from '../plugin-slot-catalog';
|
|
|
|
vi.mock('@/stores/toast-store', () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
|
}));
|
|
|
|
import { PERM_PER_METHOD } from '../plugin-sandbox/host-api';
|
|
|
|
const root = path.resolve(__dirname, '../..');
|
|
|
|
/**
|
|
* Gates that live outside the three tables, each with the call that
|
|
* enforces it. The test reads the file, so a gate that is removed there
|
|
* no longer counts here.
|
|
*/
|
|
const OTHER_GATES: Record<string, { file: string; call: string }> = {
|
|
'ui:keyboard': { file: 'lib/plugin-sandbox/loader.ts', call: "pluginHasPermission(plugin, 'ui:keyboard')" },
|
|
};
|
|
|
|
/**
|
|
* Permissions in ALL_PERMISSIONS that nothing checks. A plugin could ask
|
|
* for one and the user would be asked to allow it, but it would grant
|
|
* nothing. None today; one added without a gate shows up here.
|
|
*/
|
|
const GATES_NOTHING: string[] = [];
|
|
|
|
function gatedPermissions(): Map<string, string[]> {
|
|
const where = new Map<string, string[]>();
|
|
const add = (perm: string | null | undefined, by: string) => {
|
|
if (!perm) return;
|
|
where.set(perm, [...(where.get(perm) ?? []), by]);
|
|
};
|
|
for (const [method, perm] of Object.entries(PERM_PER_METHOD)) add(perm, `method ${method}`);
|
|
for (const [hook, spec] of Object.entries(HOOK_CATALOG)) add(spec.permission, `hook ${hook}`);
|
|
for (const [slot, spec] of Object.entries(SLOT_CATALOG)) {
|
|
for (const perm of spec.permissions) add(perm, `slot ${slot}`);
|
|
if (spec.props === '*') continue;
|
|
for (const [prop, perm] of Object.entries(spec.props)) add(perm, `slot prop ${slot}.${prop}`);
|
|
}
|
|
for (const perm of IMPLICIT_PERMISSIONS) add(perm, 'implicit');
|
|
for (const [perm, gate] of Object.entries(OTHER_GATES)) {
|
|
if (readFileSync(path.join(root, gate.file), 'utf8').includes(gate.call)) add(perm, gate.file);
|
|
}
|
|
return where;
|
|
}
|
|
|
|
describe('plugin permissions', () => {
|
|
it('every permission gates a method, hook or slot (or is a known gap)', () => {
|
|
const gated = gatedPermissions();
|
|
const ungated = ALL_PERMISSIONS.filter((perm) => !gated.has(perm));
|
|
expect(ungated).toEqual(GATES_NOTHING);
|
|
});
|
|
|
|
it('the tables only use permissions that exist', () => {
|
|
const known = new Set<string>(ALL_PERMISSIONS);
|
|
expect([...gatedPermissions().keys()].filter((perm) => !known.has(perm))).toEqual([]);
|
|
});
|
|
|
|
it('the new host APIs are behind the permissions they were given', () => {
|
|
expect(PERM_PER_METHOD).toMatchObject({
|
|
'calendar.getEvents': 'calendar:read',
|
|
'calendar.createEvent': 'calendar:write',
|
|
'tasks.list': 'tasks:read',
|
|
'tasks.setComplete': 'tasks:write',
|
|
'tasks.acknowledgeAlert': 'tasks:write',
|
|
'templates.list': 'templates:read',
|
|
'templates.create': 'templates:write',
|
|
'identities.update': 'identity:write',
|
|
'vacation.get': 'vacation:read',
|
|
'vacation.set': 'vacation:write',
|
|
'email.get': 'email:read',
|
|
'email.list': 'email:read',
|
|
'mailboxes.list': 'email:read',
|
|
'email.getAttachment': 'email:blob-read',
|
|
'email.getRaw': 'email:blob-read',
|
|
'compose.open': 'compose:open',
|
|
'drafts.create': 'email:write',
|
|
'email.forward': 'email:forward',
|
|
'notifications.show': 'ui:notifications',
|
|
// The AI assistant's actions (lib/plugin-sandbox/assistant/c.ts).
|
|
'calendar.respond': 'calendar:respond',
|
|
'calendar.undoDelete': 'calendar:write',
|
|
'filters.list': 'filters:read',
|
|
'filters.create': 'filters:write',
|
|
'filters.undo': 'filters:write',
|
|
'email.batch': 'email:write',
|
|
'email.undoBatch': 'email:write',
|
|
'app.describeSettings': 'settings:read',
|
|
'app.setSettings': 'settings:change',
|
|
'app.undoSettings': 'settings:change',
|
|
});
|
|
});
|
|
});
|