feat: add privileged same-origin plugin tier + crypto API surface
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||||
|
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||||
|
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||||
|
// approval) is enforced host-side before this route is ever framed; the page
|
||||||
|
// itself carries no extra privilege.
|
||||||
|
//
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PrivilegedPluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: manifest.author as string,
|
author: manifest.author as string,
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: manifest.type as string,
|
type: manifest.type as string,
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export async function GET() {
|
|||||||
author: p.author,
|
author: p.author,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
// Requested execution tier; clients gate the same-origin privileged
|
||||||
|
// sandbox on this (plus signature + approval + consent).
|
||||||
|
tier: p.tier,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
// Policy is the canonical source for force-enable. The per-plugin field
|
// Policy is the canonical source for force-enable. The per-plugin field
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
|||||||
slot,
|
slot,
|
||||||
code: active.code,
|
code: active.code,
|
||||||
locale,
|
locale,
|
||||||
|
tier: active.tier,
|
||||||
extraProps: extraProps ?? {},
|
extraProps: extraProps ?? {},
|
||||||
hostContainer: wrapperRef.current,
|
hostContainer: wrapperRef.current,
|
||||||
onResize: (h) => setHeight(h),
|
onResize: (h) => setHeight(h),
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export interface ServerPlugin {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
/** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
|
||||||
|
* run in a same-origin sandbox and require admin approval + consent. */
|
||||||
|
tier?: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -249,6 +249,16 @@ export const emailHooks = {
|
|||||||
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
||||||
// assistants, grammar checkers, etc.
|
// assistants, grammar checkers, etc.
|
||||||
onDraftChange: new HookBus(),
|
onDraftChange: new HookBus(),
|
||||||
|
// Intercept hook - fires at the very TOP of the composer send path, before
|
||||||
|
// the host builds and submits the message. Handler receives a ComposeSend
|
||||||
|
// request (draft fields, recipients, identityId, attachments, and the user's
|
||||||
|
// sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME
|
||||||
|
// message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning
|
||||||
|
// false signals "I handled the send" and the host SKIPS its default
|
||||||
|
// submission. Returning anything else (incl. undefined) lets the host send
|
||||||
|
// normally. This is the send-takeover hook used by the S/MIME plugin to
|
||||||
|
// replace the former native sign+encrypt+sendRaw pipeline.
|
||||||
|
onComposeSend: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// §7.2 Calendar Hooks
|
// §7.2 Calendar Hooks
|
||||||
@@ -520,6 +530,18 @@ export const renderHooks = {
|
|||||||
// Handlers return a new (or extended) badges array.
|
// Handlers return a new (or extended) badges array.
|
||||||
// Rendered by the email list row component next to the subject line.
|
// Rendered by the email list row component next to the subject line.
|
||||||
onEmailListItemRender: new HookBus(),
|
onEmailListItemRender: new HookBus(),
|
||||||
|
// Transform hook - runs when an email is opened, BEFORE the viewer computes
|
||||||
|
// the body it will render. Initial value: RenderableBody { html, text,
|
||||||
|
// attachments, handledBy? }. Second argument: MessageContext { id,
|
||||||
|
// bodyStructure, attachments, blobId, contentType, from }. A handler may
|
||||||
|
// inspect the message (e.g. detect S/MIME), fetch the raw blob via
|
||||||
|
// `api.jmap.fetchBlob`, decrypt/verify in-frame, and return a REPLACED body
|
||||||
|
// with `handledBy` set plus optional `verification` status. Return undefined
|
||||||
|
// (or the unchanged value) to pass through. The host still runs the returned
|
||||||
|
// HTML through its sanitizer — plugin output is not trusted blindly. This is
|
||||||
|
// the render-takeover hook used by the S/MIME plugin to replace the former
|
||||||
|
// native detect/decrypt/verify path in the viewer.
|
||||||
|
onRenderEmailBody: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
|
|||||||
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
|
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
|
||||||
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
|
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
|
||||||
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
|
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
|
||||||
|
'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' },
|
||||||
|
'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' },
|
||||||
|
'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' },
|
||||||
|
'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' },
|
||||||
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
||||||
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
||||||
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
|
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
|
||||||
|
|||||||
@@ -6,9 +6,21 @@ import type { InstalledPlugin, Permission } from '../plugin-types';
|
|||||||
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { apiFetch } from '../browser-navigation';
|
import { apiFetch } from '../browser-navigation';
|
||||||
import { awaitDialog } from './host-dialog';
|
import { awaitDialog } from './host-dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||||
|
* raw message bytes and raw submission, which an untrusted null-origin plugin
|
||||||
|
* must never reach. Enforced in `dispatchApiCall` IN ADDITION to the per-method
|
||||||
|
* permission gate.
|
||||||
|
*/
|
||||||
|
const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
||||||
|
'jmap.fetchBlob',
|
||||||
|
'jmap.sendRaw',
|
||||||
|
]);
|
||||||
|
|
||||||
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||||
// storage is unscoped by the manifest - implicit.
|
// storage is unscoped by the manifest - implicit.
|
||||||
'storage.get': null,
|
'storage.get': null,
|
||||||
@@ -23,6 +35,9 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
// http
|
// http
|
||||||
'http.post': 'http:post',
|
'http.post': 'http:post',
|
||||||
'http.fetch': 'http:fetch',
|
'http.fetch': 'http:fetch',
|
||||||
|
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
|
||||||
|
'jmap.fetchBlob': 'email:blob-read',
|
||||||
|
'jmap.sendRaw': 'email:raw-send',
|
||||||
// admin
|
// admin
|
||||||
'admin.getConfig': 'admin:config',
|
'admin.getConfig': 'admin:config',
|
||||||
'admin.getAllConfig': 'admin:config',
|
'admin.getAllConfig': 'admin:config',
|
||||||
@@ -201,6 +216,53 @@ async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: Plugi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── jmap (privileged tier) ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the raw bytes of a blob by id, using the host's authenticated JMAP
|
||||||
|
* client. The plugin decides WHICH blobId to fetch (e.g. a pkcs7-mime part, or
|
||||||
|
* the full RFC822 message blob) and runs its own detection; the host only
|
||||||
|
* exposes the byte-fetch primitive. Returns a Uint8Array (structured-cloneable
|
||||||
|
* across the postMessage boundary).
|
||||||
|
*/
|
||||||
|
async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string }): Promise<Uint8Array> {
|
||||||
|
if (typeof blobId !== 'string' || !blobId) throw new Error('jmap.fetchBlob: blobId required');
|
||||||
|
const { client } = useAuthStore.getState();
|
||||||
|
if (!client) throw new Error('jmap.fetchBlob: no active session');
|
||||||
|
const buf = await client.fetchBlobArrayBuffer(blobId, opts?.name, opts?.type);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a fully-formed raw RFC822 message (e.g. one a plugin has signed and/or
|
||||||
|
* encrypted) via the host's raw-send path, which also files it into Sent. The
|
||||||
|
* plugin passes raw bytes; the host wraps them in a Blob.
|
||||||
|
*/
|
||||||
|
async function doJmapSendRaw(
|
||||||
|
rawBytes: ArrayBuffer | ArrayBufferView,
|
||||||
|
identityId: string,
|
||||||
|
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
|
||||||
|
const { client } = useAuthStore.getState();
|
||||||
|
if (!client) throw new Error('jmap.sendRaw: no active session');
|
||||||
|
const view = rawBytes instanceof ArrayBuffer
|
||||||
|
? new Uint8Array(rawBytes)
|
||||||
|
: new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
|
||||||
|
// Copy into a fresh ArrayBuffer-backed array so the Blob part is definitely
|
||||||
|
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
|
||||||
|
const copy = new Uint8Array(view.byteLength);
|
||||||
|
copy.set(view);
|
||||||
|
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
|
||||||
|
return useEmailStore.getState().sendRawEmail(
|
||||||
|
client,
|
||||||
|
blob,
|
||||||
|
identityId,
|
||||||
|
opts?.delayedUntil,
|
||||||
|
opts?.envelopeRecipients,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── admin config (same as before) ────────────────────────────
|
// ─── admin config (same as before) ────────────────────────────
|
||||||
|
|
||||||
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
|
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
|
||||||
@@ -234,7 +296,15 @@ export async function dispatchApiCall(
|
|||||||
plugin: InstalledPlugin,
|
plugin: InstalledPlugin,
|
||||||
method: string,
|
method: string,
|
||||||
args: unknown[],
|
args: unknown[],
|
||||||
|
opts?: { privileged?: boolean },
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
|
// Tier gate: privileged-only methods are refused for untrusted (null-origin)
|
||||||
|
// instances even if the permission is somehow present. Defence-in-depth on
|
||||||
|
// top of the load-time tier resolution.
|
||||||
|
if (PRIVILEGED_ONLY_METHODS.has(method) && !opts?.privileged) {
|
||||||
|
throw new Error(`Method "${method}" requires the privileged plugin tier`);
|
||||||
|
}
|
||||||
|
|
||||||
// Permission gate
|
// Permission gate
|
||||||
const requiredPerm = PERM_PER_METHOD[method];
|
const requiredPerm = PERM_PER_METHOD[method];
|
||||||
if (requiredPerm !== undefined && requiredPerm !== null) {
|
if (requiredPerm !== undefined && requiredPerm !== null) {
|
||||||
@@ -259,6 +329,13 @@ export async function dispatchApiCall(
|
|||||||
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
|
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
|
||||||
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
|
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
|
||||||
|
|
||||||
|
case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined);
|
||||||
|
case 'jmap.sendRaw': return doJmapSendRaw(
|
||||||
|
args[0] as ArrayBuffer | ArrayBufferView,
|
||||||
|
args[1] as string,
|
||||||
|
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
||||||
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
||||||
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
|
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
|
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
|
||||||
// parent on the first inbound message.
|
// parent on the first inbound message.
|
||||||
|
|
||||||
import type { InstalledPlugin, SlotName } from '../plugin-types';
|
import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
|
||||||
import { dispatchApiCall } from './host-api';
|
import { dispatchApiCall } from './host-api';
|
||||||
import { SANDBOX_PATH } from './protocol';
|
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol';
|
||||||
import { withBasePath } from '../browser-navigation';
|
import { withBasePath } from '../browser-navigation';
|
||||||
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||||
import type {
|
import type {
|
||||||
@@ -55,6 +55,8 @@ export interface BackgroundOptions {
|
|||||||
plugin: InstalledPlugin;
|
plugin: InstalledPlugin;
|
||||||
code: string;
|
code: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
/** Resolved execution tier (from `resolvePluginTier`). */
|
||||||
|
tier: PluginTier;
|
||||||
/** Where the hidden iframe should attach. Defaults to document.body. */
|
/** Where the hidden iframe should attach. Defaults to document.body. */
|
||||||
hostContainer?: HTMLElement;
|
hostContainer?: HTMLElement;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,8 @@ export interface SlotOptions {
|
|||||||
slot: SlotName;
|
slot: SlotName;
|
||||||
code: string;
|
code: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
/** Resolved execution tier (from `resolvePluginTier`). */
|
||||||
|
tier: PluginTier;
|
||||||
extraProps: Record<string, unknown>;
|
extraProps: Record<string, unknown>;
|
||||||
/** Container element the visible slot iframe is mounted into. */
|
/** Container element the visible slot iframe is mounted into. */
|
||||||
hostContainer: HTMLElement;
|
hostContainer: HTMLElement;
|
||||||
@@ -83,6 +87,8 @@ export class SandboxInstance {
|
|||||||
readonly iframe: HTMLIFrameElement;
|
readonly iframe: HTMLIFrameElement;
|
||||||
readonly pluginId: string;
|
readonly pluginId: string;
|
||||||
readonly mode: 'background' | 'slot';
|
readonly mode: 'background' | 'slot';
|
||||||
|
/** True for the same-origin privileged tier; gates the origin assertion. */
|
||||||
|
readonly privileged: boolean;
|
||||||
|
|
||||||
readyPromise: Promise<void>;
|
readyPromise: Promise<void>;
|
||||||
initPromise: Promise<InitDoneInfo>;
|
initPromise: Promise<InitDoneInfo>;
|
||||||
@@ -106,6 +112,7 @@ export class SandboxInstance {
|
|||||||
) {
|
) {
|
||||||
this.pluginId = plugin.id;
|
this.pluginId = plugin.id;
|
||||||
this.mode = initPayload.mode;
|
this.mode = initPayload.mode;
|
||||||
|
this.privileged = initPayload.tier === 'privileged';
|
||||||
|
|
||||||
// Slot iframes get `extraProps`; encode any function values now so the
|
// Slot iframes get `extraProps`; encode any function values now so the
|
||||||
// structured-clone send doesn't drop them.
|
// structured-clone send doesn't drop them.
|
||||||
@@ -120,12 +127,15 @@ export class SandboxInstance {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.iframe = document.createElement('iframe');
|
this.iframe = document.createElement('iframe');
|
||||||
// Dev-only: Next's HMR/dev runtime refuses requests from the opaque
|
// Privileged tier: same-origin in BOTH dev and prod so the iframe gets real
|
||||||
// ("null") origin a strict sandbox produces, so the iframe never
|
// `crypto.subtle` + IndexedDB and can run its own bundled crypto libs. The
|
||||||
// hydrates and `sandbox-ready` is never posted. Add allow-same-origin
|
// postMessage RPC membrane still applies; the trust gate is enforced
|
||||||
// in dev so the iframe shares the host's origin and HMR works.
|
// host-side (signature + admin approval) BEFORE this instance is created.
|
||||||
// Production keeps the strict opaque-origin sandbox.
|
// Untrusted tier: null-origin in prod; dev adds allow-same-origin only
|
||||||
const sandboxFlags = process.env.NODE_ENV === 'development'
|
// because Next's HMR/dev runtime refuses requests from the opaque ("null")
|
||||||
|
// origin a strict sandbox produces (the iframe would never hydrate and
|
||||||
|
// `sandbox-ready` would never post).
|
||||||
|
const sandboxFlags = this.privileged || process.env.NODE_ENV === 'development'
|
||||||
? 'allow-scripts allow-same-origin'
|
? 'allow-scripts allow-same-origin'
|
||||||
: 'allow-scripts';
|
: 'allow-scripts';
|
||||||
this.iframe.setAttribute('sandbox', sandboxFlags);
|
this.iframe.setAttribute('sandbox', sandboxFlags);
|
||||||
@@ -148,7 +158,9 @@ export class SandboxInstance {
|
|||||||
// Prefix with the mount path so the sandbox route resolves under a
|
// Prefix with the mount path so the sandbox route resolves under a
|
||||||
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
|
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
|
||||||
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
|
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
|
||||||
this.iframe.src = withBasePath(SANDBOX_PATH);
|
// Privileged plugins load the same-origin route so the CSP/allow-same-origin
|
||||||
|
// pairing is consistent.
|
||||||
|
this.iframe.src = withBasePath(this.privileged ? SANDBOX_PRIVILEGED_PATH : SANDBOX_PATH);
|
||||||
|
|
||||||
this.listener = (ev) => this.onMessage(ev);
|
this.listener = (ev) => this.onMessage(ev);
|
||||||
window.addEventListener('message', this.listener);
|
window.addEventListener('message', this.listener);
|
||||||
@@ -174,6 +186,11 @@ export class SandboxInstance {
|
|||||||
private onMessage(ev: MessageEvent): void {
|
private onMessage(ev: MessageEvent): void {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
if (ev.source !== this.iframe.contentWindow) return;
|
if (ev.source !== this.iframe.contentWindow) return;
|
||||||
|
// Privileged iframes are same-origin, so we can additionally pin the origin
|
||||||
|
// (defence-in-depth on top of the contentWindow check). Untrusted iframes
|
||||||
|
// are null-origin (event.origin === "null") in prod and can't be pinned
|
||||||
|
// this way, so the contentWindow check above is the sole gate for them.
|
||||||
|
if (this.privileged && ev.origin !== window.location.origin) return;
|
||||||
const msg = ev.data as SandboxToHost;
|
const msg = ev.data as SandboxToHost;
|
||||||
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
|
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
|
||||||
|
|
||||||
@@ -194,7 +211,7 @@ export class SandboxInstance {
|
|||||||
const { id, method, args } = msg;
|
const { id, method, args } = msg;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dispatchApiCall(this.plugin, method, args ?? []);
|
const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged });
|
||||||
this.send({ type: 'api-response', id, ok: true, result });
|
this.send({ type: 'api-response', id, ok: true, result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
|
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
|
||||||
@@ -318,6 +335,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
|
|||||||
const payload: InitPayload = {
|
const payload: InitPayload = {
|
||||||
mode: 'background',
|
mode: 'background',
|
||||||
pluginId: opts.plugin.id,
|
pluginId: opts.plugin.id,
|
||||||
|
tier: opts.tier,
|
||||||
manifest: {
|
manifest: {
|
||||||
id: opts.plugin.id,
|
id: opts.plugin.id,
|
||||||
version: opts.plugin.version,
|
version: opts.plugin.version,
|
||||||
@@ -341,6 +359,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
|||||||
const payload: InitPayload = {
|
const payload: InitPayload = {
|
||||||
mode: 'slot',
|
mode: 'slot',
|
||||||
pluginId: opts.plugin.id,
|
pluginId: opts.plugin.id,
|
||||||
|
tier: opts.tier,
|
||||||
slot: opts.slot,
|
slot: opts.slot,
|
||||||
code: opts.code,
|
code: opts.code,
|
||||||
manifest: {
|
manifest: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../plugin-hooks';
|
} from '../plugin-hooks';
|
||||||
import { verifyBundle } from './bundle-integrity';
|
import { verifyBundle } from './bundle-integrity';
|
||||||
import { createBackgroundInstance } from './host-bridge';
|
import { createBackgroundInstance } from './host-bridge';
|
||||||
|
import { resolvePluginTier } from './tier';
|
||||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
||||||
import { cancelPluginDialogs } from './host-api';
|
import { cancelPluginDialogs } from './host-api';
|
||||||
import { registerShortcuts } from './shortcuts';
|
import { registerShortcuts } from './shortcuts';
|
||||||
@@ -93,11 +94,22 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
|||||||
|
|
||||||
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
||||||
try {
|
try {
|
||||||
|
// Decide the execution tier BEFORE creating any iframe. A refused privileged
|
||||||
|
// request is a hard error (never silently downgraded to null-origin).
|
||||||
|
const resolution = resolvePluginTier(plugin);
|
||||||
|
if (resolution.tier === null) {
|
||||||
|
storeAccessor?.setPluginStatus(plugin.id, 'error', resolution.error);
|
||||||
|
console.error(`[plugin-sandbox] "${plugin.id}" tier refused: ${resolution.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tier = resolution.tier;
|
||||||
|
|
||||||
const code = await getBundleCode(plugin);
|
const code = await getBundleCode(plugin);
|
||||||
background = createBackgroundInstance({
|
background = createBackgroundInstance({
|
||||||
plugin,
|
plugin,
|
||||||
code,
|
code,
|
||||||
locale: currentLocale,
|
locale: currentLocale,
|
||||||
|
tier,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the background runtime to evaluate the bundle, register hooks,
|
// Wait for the background runtime to evaluate the bundle, register hooks,
|
||||||
@@ -138,6 +150,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
|||||||
registerActive({
|
registerActive({
|
||||||
plugin,
|
plugin,
|
||||||
code,
|
code,
|
||||||
|
tier,
|
||||||
background: bg,
|
background: bg,
|
||||||
slotOffers: info.slots,
|
slotOffers: info.slots,
|
||||||
hookDisposables,
|
hookDisposables,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
|
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
|
||||||
// class instances.
|
// class instances.
|
||||||
|
|
||||||
import type { SlotName } from '../plugin-types';
|
import type { SlotName, PluginTier } from '../plugin-types';
|
||||||
import type { ThemeSnapshot } from './host-theme';
|
import type { ThemeSnapshot } from './host-theme';
|
||||||
|
|
||||||
// ─── Sandbox mode ────────────────────────────────────────────
|
// ─── Sandbox mode ────────────────────────────────────────────
|
||||||
@@ -19,6 +19,12 @@ export type SandboxMode = 'background' | 'slot';
|
|||||||
export interface BackgroundInit {
|
export interface BackgroundInit {
|
||||||
mode: 'background';
|
mode: 'background';
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
/**
|
||||||
|
* Execution tier. 'privileged' iframes are same-origin (real WebCrypto +
|
||||||
|
* IndexedDB); 'untrusted' iframes are null-origin. Decided host-side by
|
||||||
|
* `resolvePluginTier`; the sandbox itself does not act on this field.
|
||||||
|
*/
|
||||||
|
tier: PluginTier;
|
||||||
/** Trimmed manifest visible to the plugin. No host secrets. */
|
/** Trimmed manifest visible to the plugin. No host secrets. */
|
||||||
manifest: {
|
manifest: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,6 +44,8 @@ export interface BackgroundInit {
|
|||||||
export interface SlotInit {
|
export interface SlotInit {
|
||||||
mode: 'slot';
|
mode: 'slot';
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
/** Execution tier (mirrors `BackgroundInit.tier`). */
|
||||||
|
tier: PluginTier;
|
||||||
/** Slot name the iframe should render a component for. */
|
/** Slot name the iframe should render a component for. */
|
||||||
slot: SlotName;
|
slot: SlotName;
|
||||||
/** Same bundle code as the background instance. */
|
/** Same bundle code as the background instance. */
|
||||||
@@ -217,13 +225,23 @@ export function isSandboxMessage(value: unknown): value is SandboxToHost {
|
|||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────
|
||||||
|
|
||||||
/** Path used for the sandbox iframe `src`. Matched in `proxy.ts` for CSP. */
|
/** Path used for the untrusted (null-origin) sandbox iframe `src`. Matched in
|
||||||
|
* `proxy.ts` for CSP. */
|
||||||
export const SANDBOX_PATH = '/plugin-sandbox';
|
export const SANDBOX_PATH = '/plugin-sandbox';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path used for the privileged (same-origin) sandbox iframe `src`. A distinct
|
||||||
|
* route so the iframe gets `allow-same-origin` (real WebCrypto + IndexedDB)
|
||||||
|
* while keeping the same CSP relaxations as the untrusted sandbox. Matched in
|
||||||
|
* `proxy.ts`. Renders the identical `SandboxRuntime`.
|
||||||
|
*/
|
||||||
|
export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
|
||||||
|
|
||||||
/** Methods callable by a plugin via api-request. Host enforces permissions. */
|
/** Methods callable by a plugin via api-request. Host enforces permissions. */
|
||||||
export const API_METHODS = [
|
export const API_METHODS = [
|
||||||
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
|
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
|
||||||
'http.post', 'http.fetch',
|
'http.post', 'http.fetch',
|
||||||
|
'jmap.fetchBlob', 'jmap.sendRaw',
|
||||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||||
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// `useSyncExternalStore` sees a stable reference between unrelated renders.
|
// `useSyncExternalStore` sees a stable reference between unrelated renders.
|
||||||
// The cache is invalidated whenever the set of active plugins changes.
|
// The cache is invalidated whenever the set of active plugins changes.
|
||||||
|
|
||||||
import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
|
import type { Disposable, InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
|
||||||
import type { SandboxInstance } from './host-bridge';
|
import type { SandboxInstance } from './host-bridge';
|
||||||
|
|
||||||
export interface SlotOffer {
|
export interface SlotOffer {
|
||||||
@@ -19,6 +19,9 @@ export interface ActivePlugin {
|
|||||||
plugin: InstalledPlugin;
|
plugin: InstalledPlugin;
|
||||||
/** Verified bundle source. Reused when spinning up slot iframes. */
|
/** Verified bundle source. Reused when spinning up slot iframes. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** Resolved execution tier. Slot iframes must use the SAME tier as the
|
||||||
|
* background instance, so `PluginIframeSlot` reads it from here. */
|
||||||
|
tier: PluginTier;
|
||||||
background: SandboxInstance;
|
background: SandboxInstance;
|
||||||
slotOffers: SlotOffer[];
|
slotOffers: SlotOffer[];
|
||||||
hookDisposables: Disposable[];
|
hookDisposables: Disposable[];
|
||||||
|
|||||||
@@ -175,6 +175,20 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
||||||
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
|
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
|
||||||
},
|
},
|
||||||
|
// Privileged-tier only (same-origin plugins). Calls throw for untrusted
|
||||||
|
// plugins (the host refuses the method) — these power crypto plugins that
|
||||||
|
// need raw message bytes and raw submission.
|
||||||
|
jmap: {
|
||||||
|
/** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */
|
||||||
|
fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) =>
|
||||||
|
callApi('jmap.fetchBlob', [blobId, opts]) as Promise<Uint8Array>,
|
||||||
|
/** Submit a fully-formed raw RFC822 message (already signed/encrypted). */
|
||||||
|
sendRaw: (
|
||||||
|
rawBytes: ArrayBuffer | ArrayBufferView,
|
||||||
|
identityId: string,
|
||||||
|
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
||||||
|
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
success: (m: string) => { void callApi('toast.success', [m]); },
|
success: (m: string) => { void callApi('toast.success', [m]); },
|
||||||
error: (m: string) => { void callApi('toast.error', [m]); },
|
error: (m: string) => { void callApi('toast.error', [m]); },
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Single source of truth for which execution tier a plugin runs in.
|
||||||
|
//
|
||||||
|
// The decision is security-critical: granting 'privileged' creates a
|
||||||
|
// same-origin iframe (full WebCrypto + IndexedDB + access to the host origin),
|
||||||
|
// so it must NEVER be granted to an unsigned or unapproved bundle. This helper
|
||||||
|
// is called by BOTH the loader (load gate, before the same-origin iframe is
|
||||||
|
// created) and the plugin store (enable gate), so the rules live in one place.
|
||||||
|
//
|
||||||
|
// A plugin that *requests* privileged but fails any gate is REFUSED (returns
|
||||||
|
// `{ tier: null, error }`), never silently downgraded — a crypto plugin cannot
|
||||||
|
// run in a null-origin sandbox, and a silent downgrade would mask tampering.
|
||||||
|
|
||||||
|
import type { InstalledPlugin, PluginTier } from '../plugin-types';
|
||||||
|
|
||||||
|
export type TierResolution =
|
||||||
|
| { tier: PluginTier; error?: undefined }
|
||||||
|
| { tier: null; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the execution tier for a plugin. Returns `{ tier }` on success or
|
||||||
|
* `{ tier: null, error }` when a requested tier cannot be granted (the caller
|
||||||
|
* should put the plugin into an error state and NOT create an iframe).
|
||||||
|
*
|
||||||
|
* Privileged tier gates (ALL required):
|
||||||
|
* 1. Manifest declares the umbrella high-risk permission `crypto:full`.
|
||||||
|
* 2. Signed bundle: only bundles delivered through the admin/server channel
|
||||||
|
* are Ed25519-signed (verified at download time — see `verifySignature`
|
||||||
|
* usage in the plugin store). Self-uploaded bundles are unsigned and can
|
||||||
|
* therefore never reach the privileged tier. `managed` is the signal that
|
||||||
|
* the bundle came through that signed channel.
|
||||||
|
* 3. Admin approval pins operator trust in this specific bundle.
|
||||||
|
* 4. Explicit high-risk consent for `crypto:full` (granted via the consent
|
||||||
|
* dialog at enable time; admin-managed plugins are pre-approved).
|
||||||
|
*/
|
||||||
|
export function resolvePluginTier(plugin: InstalledPlugin): TierResolution {
|
||||||
|
if (plugin.tier !== 'privileged') {
|
||||||
|
return { tier: 'untrusted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Must declare the umbrella high-risk permission.
|
||||||
|
if (!plugin.permissions.includes('crypto:full')) {
|
||||||
|
return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle
|
||||||
|
// uploaded by the user directly carries no signature, so it cannot be
|
||||||
|
// privileged regardless of what its manifest claims.
|
||||||
|
if (!plugin.managed) {
|
||||||
|
return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' };
|
||||||
|
}
|
||||||
|
if (!(plugin.adminApproved || plugin.managed)) {
|
||||||
|
return { tier: null, error: 'Privileged tier requires administrator approval' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by
|
||||||
|
// the operator and skip the per-user prompt (mirrors the existing consent
|
||||||
|
// gate in the plugin store); otherwise the user must have granted crypto:full.
|
||||||
|
const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full');
|
||||||
|
if (!consented) {
|
||||||
|
return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tier: 'privileged' };
|
||||||
|
}
|
||||||
@@ -7,6 +7,18 @@ export type MaybePromise<T> = T | Promise<T>;
|
|||||||
|
|
||||||
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
|
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
|
||||||
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
|
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
|
||||||
|
/**
|
||||||
|
* Execution tier a plugin runs in.
|
||||||
|
* - 'untrusted' (default): null-origin sandbox iframe. No `crypto.subtle`,
|
||||||
|
* IndexedDB, or localStorage in-frame; all capabilities go through the host
|
||||||
|
* RPC. This is the only tier most plugins ever need.
|
||||||
|
* - 'privileged': same-origin sandbox iframe (full WebCrypto + IndexedDB) so a
|
||||||
|
* plugin can bundle its own crypto libs (e.g. pkijs for S/MIME, openpgp for
|
||||||
|
* PGP). Because same-origin == full host access, entering this tier is gated
|
||||||
|
* by a signed bundle + admin approval + high-risk consent — see
|
||||||
|
* `lib/plugin-sandbox/tier.ts` `resolvePluginTier`.
|
||||||
|
*/
|
||||||
|
export type PluginTier = 'untrusted' | 'privileged';
|
||||||
export type ThemeVariant = 'light' | 'dark';
|
export type ThemeVariant = 'light' | 'dark';
|
||||||
|
|
||||||
// ─── Manifests ───────────────────────────────────────────────
|
// ─── Manifests ───────────────────────────────────────────────
|
||||||
@@ -97,6 +109,13 @@ export interface PluginManifest {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: Exclude<PluginType, 'theme'>;
|
type: Exclude<PluginType, 'theme'>;
|
||||||
|
/**
|
||||||
|
* Execution tier the plugin requests. Defaults to 'untrusted' when omitted.
|
||||||
|
* Declaring 'privileged' opts into the same-origin tier and requires the
|
||||||
|
* `crypto:full` permission, a signed bundle, and admin approval (enforced by
|
||||||
|
* `resolvePluginTier`). Most plugins should omit this.
|
||||||
|
*/
|
||||||
|
tier?: PluginTier;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
minAppVersion?: string;
|
minAppVersion?: string;
|
||||||
@@ -208,6 +227,9 @@ export interface InstalledPlugin {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: Exclude<PluginType, 'theme'>;
|
type: Exclude<PluginType, 'theme'>;
|
||||||
|
/** Execution tier carried over from the manifest at install time. Defaults
|
||||||
|
* to 'untrusted'. See `PluginTier` and `resolvePluginTier`. */
|
||||||
|
tier?: PluginTier;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -852,6 +874,19 @@ export interface PluginI18n {
|
|||||||
|
|
||||||
export const ALL_PERMISSIONS = [
|
export const ALL_PERMISSIONS = [
|
||||||
'email:read', 'email:write', 'email:send',
|
'email:read', 'email:write', 'email:send',
|
||||||
|
// ─── Privileged-tier capabilities (require tier: 'privileged') ───
|
||||||
|
// Umbrella high-risk permission gating same-origin crypto execution. A
|
||||||
|
// plugin holding this runs with full cryptographic access and can read
|
||||||
|
// message bodies and private keys; only granted to a signed, admin-approved
|
||||||
|
// privileged bundle after explicit high-risk consent.
|
||||||
|
'crypto:full',
|
||||||
|
// Submit a fully-formed raw RFC822 message via JMAP (used after a plugin
|
||||||
|
// signs/encrypts an outgoing message itself).
|
||||||
|
'email:raw-send',
|
||||||
|
// Fetch a message blob's raw bytes by blobId (for decrypt/verify).
|
||||||
|
'email:blob-read',
|
||||||
|
// Replace the rendered body of an opened email (render-takeover).
|
||||||
|
'email:render-takeover',
|
||||||
'calendar:read', 'calendar:write',
|
'calendar:read', 'calendar:write',
|
||||||
'contacts:read', 'contacts:write',
|
'contacts:read', 'contacts:write',
|
||||||
'files:read', 'files:write',
|
'files:read', 'files:write',
|
||||||
|
|||||||
@@ -75,10 +75,17 @@ export async function proxy(request: NextRequest) {
|
|||||||
const nonce = crypto.randomUUID();
|
const nonce = crypto.randomUUID();
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
// The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
|
// The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
|
||||||
// bundles via `new Function`. It is null-origin (sandbox="allow-scripts"),
|
// bundles via `new Function`. The untrusted route is null-origin
|
||||||
// so the relaxation is scoped strictly to that document and never reaches
|
// (sandbox="allow-scripts"); the privileged route is same-origin
|
||||||
// the main app, plus it must be embeddable from `'self'`.
|
// (allow-same-origin) so a vetted plugin gets real WebCrypto + IndexedDB.
|
||||||
const isSandboxPath = pathname === "/plugin-sandbox" || pathname.startsWith("/plugin-sandbox/");
|
// Both get the SAME CSP relaxations (unsafe-eval, frame-ancestors 'self');
|
||||||
|
// the privileged route's extra power comes from the iframe sandbox flag the
|
||||||
|
// host sets, gated by signature + admin approval, NOT from a wider CSP.
|
||||||
|
const isSandboxPath =
|
||||||
|
pathname === "/plugin-sandbox" ||
|
||||||
|
pathname.startsWith("/plugin-sandbox/") ||
|
||||||
|
pathname === "/plugin-sandbox-privileged" ||
|
||||||
|
pathname.startsWith("/plugin-sandbox-privileged/");
|
||||||
|
|
||||||
const scriptSrc = isSandboxPath
|
const scriptSrc = isSandboxPath
|
||||||
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types';
|
||||||
import { pluginStorage } from '@/lib/plugin-storage';
|
import { pluginStorage } from '@/lib/plugin-storage';
|
||||||
import { extractPlugin } from '@/lib/plugin-validator';
|
import { extractPlugin } from '@/lib/plugin-validator';
|
||||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
|
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
|
||||||
@@ -76,6 +76,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
author: manifest.author,
|
author: manifest.author,
|
||||||
description: manifest.description,
|
description: manifest.description,
|
||||||
type: manifest.type,
|
type: manifest.type,
|
||||||
|
...(manifest.tier ? { tier: manifest.tier } : {}),
|
||||||
permissions: manifest.permissions,
|
permissions: manifest.permissions,
|
||||||
entrypoint: manifest.entrypoint,
|
entrypoint: manifest.entrypoint,
|
||||||
enabled: false, // Start disabled, user must enable
|
enabled: false, // Start disabled, user must enable
|
||||||
@@ -324,6 +325,8 @@ interface ServerPluginInfo {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
/** Requested execution tier (privileged plugins run same-origin). */
|
||||||
|
tier?: PluginTier;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
forceEnabled: boolean;
|
forceEnabled: boolean;
|
||||||
@@ -357,6 +360,7 @@ function serverMeta(sp: ServerPluginInfo) {
|
|||||||
description: sp.description,
|
description: sp.description,
|
||||||
permissions: sp.permissions,
|
permissions: sp.permissions,
|
||||||
entrypoint: sp.entrypoint,
|
entrypoint: sp.entrypoint,
|
||||||
|
...(sp.tier ? { tier: sp.tier } : {}),
|
||||||
managed: true as const,
|
managed: true as const,
|
||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
bundleHash: sp.bundleHash,
|
bundleHash: sp.bundleHash,
|
||||||
|
|||||||
Reference in New Issue
Block a user