diff --git a/app/(main)/[locale]/apps/[[...segments]]/page.tsx b/app/(main)/[locale]/apps/[[...segments]]/page.tsx new file mode 100644 index 000000000..bd91cb2f0 --- /dev/null +++ b/app/(main)/[locale]/apps/[[...segments]]/page.tsx @@ -0,0 +1,26 @@ +import { PluginPageApp } from "@/components/plugins/plugin-page-app"; +import { generateLiteSegmentParams } from "@/lib/lite-static-params"; + +// Pages plugins add with api.ui.setPages: /apps//[/]. +// Anything after the page id reaches the plugin's `app-page` slot as `path`. +// A catch-all rather than [pluginId]/[pageId] so the static Lite export can +// prerender the bare route; plugins never run in Lite, so it only ever shows +// the "not available" note there (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteSegmentParams; + +function decodeOnce(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +export default async function PluginPageRoute({ + params, +}: { + params: Promise<{ segments?: string[] }>; +}) { + const { segments } = await params; + return ; +} diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 5e6f64cac..b4e444621 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -9,8 +9,11 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { CommandPaletteHost } from "@/components/command-palette/command-palette"; +import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; +import { PluginAppEvents } from "@/components/providers/plugin-app-events"; import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -56,9 +59,12 @@ export default async function LocaleLayout({ {children} + + + diff --git a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx new file mode 100644 index 000000000..51fe96758 --- /dev/null +++ b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; + +interface ApprovalEntry { + pluginId: string; + bundleHash: string; + status: 'pending' | 'approved' | 'denied'; + manifest: { + name?: string; + version?: string; + author?: string; + description?: string; + permissions?: string[]; + httpOrigins?: string[]; + apiPostPaths?: string[]; + }; + requestedBy: string; + requestedAt: string; + decidedAt?: string; +} + +interface SourceView { + entry: ApprovalEntry; + code?: string; + warnings?: string[]; + error?: string; +} + +const STATUS_STYLE: Record = { + pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400', + approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400', + denied: 'bg-muted text-muted-foreground', +}; + +/** + * Plugins users installed themselves and asked to have approved. The admin + * decides on the package the user uploaded: its manifest and code are read + * on the server, and the approval is pinned to that exact package. + */ +export function PluginApprovalsPanel() { + const [entries, setEntries] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [source, setSource] = useState(null); + + const load = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/plugin-approvals'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { entries: ApprovalEntry[] }; + setEntries([...data.entries].sort((a, b) => + Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt))); + setError(null); + } catch (e) { + setError(`Could not load approval requests (${(e as Error).message})`); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') { + const key = `${entry.pluginId}:${entry.bundleHash}`; + setBusy(key); + try { + const res = decision === 'revoke' + ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' }) + : await apiFetch('/api/admin/plugin-approvals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); + } catch (e) { + setError(`Could not save the decision (${(e as Error).message})`); + } finally { + setBusy(null); + } + } + + async function viewSource(entry: ApprovalEntry) { + setSource({ entry }); + try { + const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + setSource({ entry, code: data.code, warnings: data.warnings }); + } catch (e) { + setSource({ entry, error: (e as Error).message }); + } + } + + const pending = entries?.filter((e) => e.status === 'pending').length ?? 0; + + return ( +
+
+
+ +

Approval Requests

+ {pending > 0 && ( + {pending} waiting + )} +
+

Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.

+
+ + {error &&
{error}
} + + {entries === null ? ( +
+ ) : entries.length === 0 ? ( +
No requests yet
+ ) : ( +
+ {entries.map((entry) => { + const key = `${entry.pluginId}:${entry.bundleHash}`; + const perms = entry.manifest.permissions ?? []; + return ( +
+
+ {entry.manifest.name ?? entry.pluginId} + v{entry.manifest.version} + {entry.status} +
+ {entry.manifest.description &&

{entry.manifest.description}

} +
+ {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()} +
+ {perms.length > 0 && ( +
+ {perms.map((p) => ( + {p} + ))} +
+ )} + {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && ( +
+ Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')} +
+ )} +
+ + {entry.status !== 'approved' && ( + + )} + {entry.status !== 'denied' && ( + + )} + {entry.status !== 'pending' && ( + + )} +
+
+ ); + })} +
+ )} + + {source && ( +
{ if (e.target === e.currentTarget) setSource(null); }}> +
+
+ {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version} + +
+ {source.warnings && source.warnings.length > 0 && ( +
+ {source.warnings.join('; ')} +
+ )} +
+              {source.error ?? source.code ?? 'Loading…'}
+            
+
+
+ )} +
+ ); +} diff --git a/app/(main)/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx index 55b25b642..908820b7e 100644 --- a/app/(main)/admin/_tabs/plugins.tsx +++ b/app/(main)/admin/_tabs/plugins.tsx @@ -6,6 +6,7 @@ import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; import { PluginConfigPanel } from './plugin-config-panel'; +import { PluginApprovalsPanel } from './plugin-approvals-panel'; interface PluginEntry { id: string; @@ -374,6 +375,8 @@ export function PluginsTab() { + {pluginsUploadEnabled && } +
diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 45dfe9386..032544b81 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -1,14 +1,15 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; +import './plugin-kit.css'; // The plugin sandbox iframe runs with an opaque origin (the `sandbox` // attribute in production excludes `allow-same-origin` for isolation). Any -// asset request from this layout - bundled fonts, globals.css, etc. - is then -// cross-origin from the "null" origin to the host origin and gets blocked -// (fonts in particular require CORS). So this layout is intentionally minimal: -// no font imports, no CSS imports. Plugins ship their own styles, and both the -// plugin bundle and all host API calls travel over the postMessage RPC bridge, -// so the sandbox never fetches same-origin assets itself. +// asset request from this layout is then cross-origin from the "null" origin +// to the host origin. Fonts need CORS, so there are no font imports. The one +// stylesheet is plugin-kit.css, the styles of the plugin UI kit: Next serves +// it from /_next/static, which answers with Access-Control-Allow-Origin: * +// (next.config.ts), and the sandbox CSP allows styles from 'self'. Plugin +// bundles and host API calls travel over the postMessage RPC bridge. export const metadata: Metadata = { title: 'Plugin sandbox', @@ -19,6 +20,11 @@ export default function PluginSandboxLayout({ children }: { children: ReactNode return ( + {/* next dev puts its indicator into every document, so each plugin + slot showed its own badge. Plugins have no use for it. */} + {process.env.NODE_ENV === 'development' && ( + + )} {children} diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css new file mode 100644 index 000000000..6265d9eae --- /dev/null +++ b/app/(sandbox)/plugin-kit.css @@ -0,0 +1,76 @@ +/* Styles for the plugin UI kit (lib/plugin-sandbox/ui-kit), loaded into + every plugin iframe. Built from the same Tailwind classes as the app's own + components, so a kit Button is the app's Button. Only the kit's sources + are scanned, which keeps the file small. + + No Tailwind preflight: plugins written before the kit style plain HTML + elements and rely on the browser's defaults. The kit's elements get the + same reset through the .bk class each kit component puts on its root. */ + +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities) source(none); +@import "../theme-inline.css"; + +@source "../../lib/plugin-sandbox/ui-kit"; +@source "../../components/ui/button.tsx"; +@source "../../components/ui/input.tsx"; +@source "../../components/files/file-icons.tsx"; + + +@layer base { + /* Tailwind's preflight, limited to the kit's own elements. :where() keeps + it at zero specificity, so every utility still wins. */ + :where(.bk, .bk *, .bk ::before, .bk ::after) { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid var(--color-border); + } + :where(button.bk, input.bk, select.bk, textarea.bk, .bk button, .bk input, .bk select, .bk textarea) { + font: inherit; + font-feature-settings: inherit; + letter-spacing: inherit; + color: inherit; + background-color: transparent; + border-radius: 0; + opacity: 1; + } + :where(button.bk, select.bk, [role="button"].bk, [role="switch"].bk, [role="checkbox"].bk, .bk button, .bk select, .bk [role="button"], .bk [role="tab"], .bk [role="switch"], .bk [role="checkbox"], .bk [role="option"]):not(:disabled, [aria-disabled="true"]) { + cursor: pointer; + } + :where(button.bk:disabled, select.bk:disabled, .bk[aria-disabled="true"], .bk button:disabled, .bk select:disabled, .bk [aria-disabled="true"]) { + cursor: not-allowed; + } + :where(.bk h1, .bk h2, .bk h3, .bk h4) { + font-size: inherit; + font-weight: inherit; + } + :where(a.bk, .bk a) { + color: inherit; + text-decoration: inherit; + } + :where(.bk ol, .bk ul) { + list-style: none; + } + :where(.bk img, .bk svg) { + display: block; + vertical-align: middle; + } + :where(.bk input::placeholder, .bk textarea::placeholder) { + color: var(--color-muted-foreground); + opacity: 1; + } + :where(.bk table) { + border-collapse: collapse; + text-indent: 0; + } + /* Same as globals.css: native option lists only follow these. */ + :where(.bk option, .bk optgroup) { + background-color: var(--color-popover); + color: var(--color-popover-foreground); + } + :where(.dark .bk select) { + color-scheme: dark; + } +} diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index d62bacf60..3896f05fd 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -5,8 +5,7 @@ import { getPluginRegistry, getThemeRegistry, } from '@/lib/admin/plugin-registry'; -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { openPackage, readManifest, readText } from '@/lib/extension-package'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -79,61 +78,28 @@ export async function GET( } else { const buffer = await bundleRes.arrayBuffer(); bundleSize = buffer.byteLength; - const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; - if (buffer.byteLength > maxSize) { - bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`; - } else { - const zip = await JSZip.loadAsync(buffer); + // Opening the preview must not inflate an arbitrary bundle in + // full: every read goes through the capped package reader. + const pkg = await openPackage(buffer, type === 'theme' ? 'theme' : 'plugin'); + try { + manifest = await readManifest(pkg); + } catch (err) { + bundleError = err instanceof Error ? err.message : 'Invalid manifest.json in bundle'; + } - // Detect optional root directory inside the ZIP. - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map((e) => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - bundleError = 'Bundle missing manifest.json'; - } else { - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - bundleError = 'Invalid manifest.json in bundle'; - } - } - - if (manifest) { - if (type === 'theme') { - const cssFile = zip.file(root + 'theme.css'); - if (cssFile) { - const css = await cssFile.async('string'); - sourcePreview = { - name: 'theme.css', - content: css.length > MAX_PREVIEW_SOURCE_LEN - ? css.slice(0, MAX_PREVIEW_SOURCE_LEN) - : css, - truncated: css.length > MAX_PREVIEW_SOURCE_LEN, - }; - } - } else { - const entrypoint = (manifest.entrypoint as string) || 'index.js'; - const jsFile = zip.file(root + entrypoint); - if (jsFile) { - const code = await jsFile.async('string'); - sourcePreview = { - name: entrypoint, - content: code.length > MAX_PREVIEW_SOURCE_LEN - ? code.slice(0, MAX_PREVIEW_SOURCE_LEN) - : code, - truncated: code.length > MAX_PREVIEW_SOURCE_LEN, - }; - } - } + if (manifest) { + const name = type === 'theme' + ? 'theme.css' + : (typeof manifest.entrypoint === 'string' && manifest.entrypoint) || 'index.js'; + const content = await readText(pkg, name); + if (content !== null) { + sourcePreview = { + name, + content: content.length > MAX_PREVIEW_SOURCE_LEN + ? content.slice(0, MAX_PREVIEW_SOURCE_LEN) + : content, + truncated: content.length > MAX_PREVIEW_SOURCE_LEN, + }; } } } diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts new file mode 100644 index 000000000..747e39e3c --- /dev/null +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(() => 'https://extensions.test'), + }, +})); + +const registry = new Map>(); +vi.mock('@/lib/admin/plugin-registry', () => ({ + savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }), + saveTheme: vi.fn(async () => {}), + getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null), + getTheme: vi.fn(async () => null), + getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })), + getThemeRegistry: vi.fn(async () => ({ themes: [] })), +})); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + invalidateFrameOriginsCache: vi.fn(), +})); + +import { POST } from '../route'; + +async function bundle(manifest: Record, code = 'module.exports = {};'): Promise { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest)); + zip.file('index.js', code); + const buf = await zip.generateAsync({ type: 'uint8array' }); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +function install(body: Record) { + return POST(new NextRequest('http://localhost/api/admin/marketplace', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + })); +} + +const MANIFEST = { + name: 'Pretty Dates', version: '1.0.0', author: 'x', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +beforeEach(() => { + registry.clear(); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('POST /api/admin/marketplace', () => { + it('installs a plugin under its directory slug', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pretty-dates' })))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(200); + expect(registry.has('pretty-dates')).toBe(true); + }); + + it('refuses a ZIP bomb from the directory', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ ...MANIFEST, id: 'pretty-dates' })); + zip.file('index.js', new Uint8Array(30 * 1024 * 1024)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes as BodyInit))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(400); + expect(registry.size).toBe(0); + }); + + it('refuses a bundle whose manifest id differs from the slug', async () => { + // Otherwise a directory entry can overwrite another installed plugin, + // inheriting its enabled and force-enabled state, while the admin UI + // (which keys install status by slug) still shows "not installed". + registry.set('pgp-plugin', { id: 'pgp-plugin', forceEnabled: true, version: '2.0.0' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pgp-plugin' })))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/does not match/); + expect(registry.get('pgp-plugin')?.version).toBe('2.0.0'); + }); +}); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index b218c4ea3..61abf34f6 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -9,18 +9,18 @@ import { getTheme, getPluginRegistry, getThemeRegistry, + themeCSSResolver, type ServerPlugin, type ServerTheme, } from '@/lib/admin/plugin-registry'; +import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { - sanitizeFrameOrigins, - sanitizeHttpOrigins, - sanitizeApiPostPaths, - invalidateFrameOriginsCache, -} from '@/lib/admin/csp-frame-origins'; -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types'; -import { sanitizeThemeCSS } from '@/lib/theme-loader'; + EXTENSION_ID_RE, + ExtensionPackageError, + readPluginPackage, + readThemePackage, + suspiciousCode, +} from '@/lib/extension-package'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -123,6 +123,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); } + // The slug becomes a file name and the registry key; it must be a plain id. + if (typeof slug !== 'string' || !EXTENSION_ID_RE.test(slug)) { + return NextResponse.json( + { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' }, + { status: 400 }, + ); + } + // Download the bundle from the directory const directoryUrl = await getDirectoryUrl(); const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl); @@ -138,92 +146,41 @@ export async function POST(request: NextRequest) { } const buffer = await bundleRes.arrayBuffer(); - const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; - - if (buffer.byteLength > maxSize) { - return NextResponse.json( - { error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` }, - { status: 400 } - ); - } - - // Parse the ZIP - let zip: JSZip; - try { - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 }); - } - - // Find root directory - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 }); - } - const now = new Date().toISOString(); - // Resolve and strictly validate the id used as a filename. Marketplace - // bundles are authored by a third-party publisher; without this an id - // like "../../foo" causes savePlugin/saveTheme to write outside the - // plugins/themes dir via path.join. - const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug; - if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) { - return NextResponse.json( - { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' }, - { status: 400 }, - ); - } - if (type === 'theme') { - // Read theme.css - const cssFile = zip.file(root + 'theme.css'); - if (!cssFile) { - return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); + const read = await readThemePackage(buffer, { resolveExtends: await themeCSSResolver() }); + if (!read.valid || !read.manifest) { + return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 }); } - - let css = await cssFile.async('string'); - - // Sanitize unconditionally: the safety check is advisory, and the - // sanitizer is also what drops rules outside :root and .dark. - const warnings: string[] = []; - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); + const manifest = read.manifest; + // The slug is what the admin picked and what install status is keyed + // on. A bundle that names another id would install over whatever + // already uses that id, keeping its enabled and force-enabled state. + if (manifest.id !== slug) { + return NextResponse.json( + { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, + { status: 400 }, + ); + } + const resolvedId = slug; + const { css, skin, warnings } = read; const existingTheme = await getTheme(resolvedId); const isUpdate = existingTheme !== null; const theme: ServerTheme = { id: resolvedId, - name: (manifest.name as string) || slug, + name: manifest.name || slug, // Prefer the directory-published version (what we requested) over // manifest.version. Publishers sometimes forget to bump the version // inside the bundle's manifest.json; trusting it would make the // update never appear to "stick" — the registry would keep showing // the older version even after a successful update. - version: version || (manifest.version as string), - author: (manifest.author as string) || 'Unknown', - description: (manifest.description as string) || '', - variants: (manifest.variants as string[]) || ['light', 'dark'], + version: version || manifest.version, + author: manifest.author || 'Unknown', + description: manifest.description || '', + variants: manifest.variants, enabled: existingTheme?.enabled ?? true, ...(existingTheme?.forceEnabled !== undefined ? { forceEnabled: existingTheme.forceEnabled } @@ -232,7 +189,7 @@ export async function POST(request: NextRequest) { updatedAt: now, }; - await saveTheme(theme, css); + await saveTheme(theme, css, skin); await auditLog( isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme', { @@ -246,148 +203,82 @@ export async function POST(request: NextRequest) { ); return NextResponse.json({ success: true, theme, warnings, updated: isUpdate }); - } else { - // Plugin installation - // Read entrypoint JS - const entrypoint = (manifest.entrypoint as string) || 'index.js'; - const jsFile = zip.file(root + entrypoint); - if (!jsFile) { - return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 }); - } - - const code = await jsFile.async('string'); - - // Block plugins with dangerous JS patterns - const DANGEROUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, - ]; - const dangerousFindings: string[] = []; - for (const { pattern, label } of DANGEROUS_JS_PATTERNS) { - if (pattern.test(code)) dangerousFindings.push(label); - pattern.lastIndex = 0; - } - if (dangerousFindings.length > 0) { - return NextResponse.json( - { error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` }, - { status: 400 }, - ); - } - - // Validate permissions - const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknownPerms = permissions.filter(p => !validPerms.has(p)); - - const warnings: string[] = []; - if (unknownPerms.length > 0) { - warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`); - } - - // Plugins may declare iframe origins they need for embedded content. - // Anything that doesn't pass strict origin validation is silently - // dropped - the plugin still installs, but those origins are not - // added to the host CSP. - const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const droppedFrameOrigins = Array.isArray(manifest.frameOrigins) - ? (manifest.frameOrigins as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v), - ) - : []; - if (droppedFrameOrigins.length > 0) { - warnings.push( - `Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`, - ); - } - - const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const droppedHttpOrigins = Array.isArray(manifest.httpOrigins) - ? (manifest.httpOrigins as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v), - ) - : []; - if (droppedHttpOrigins.length > 0) { - warnings.push( - `Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`, - ); - } - - const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths) - ? (manifest.apiPostPaths as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v), - ) - : []; - if (droppedApiPostPaths.length > 0) { - warnings.push( - `Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`, - ); - } - - const existingPlugin = await getPlugin(resolvedId); - const isUpdate = existingPlugin !== null; - - const plugin: ServerPlugin = { - id: resolvedId, - name: (manifest.name as string) || slug, - // See theme branch: trust the directory-published version, not - // manifest.version, so updates actually stick in the registry. - version: version || (manifest.version as string), - author: (manifest.author as string) || 'Unknown', - description: (manifest.description as string) || '', - type: (manifest.type as string) || 'hook', - ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), - permissions, - entrypoint, - enabled: existingPlugin?.enabled ?? true, - ...(existingPlugin?.forceEnabled !== undefined - ? { forceEnabled: existingPlugin.forceEnabled } - : {}), - installedAt: existingPlugin?.installedAt ?? now, - updatedAt: now, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(declaredFrameOrigins.length > 0 - ? { frameOrigins: declaredFrameOrigins } - : {}), - ...(declaredHttpOrigins.length > 0 - ? { httpOrigins: declaredHttpOrigins } - : {}), - ...(declaredApiPostPaths.length > 0 - ? { apiPostPaths: declaredApiPostPaths } - : {}), - ...(manifest.locales && typeof manifest.locales === 'object' - ? { locales: manifest.locales as ServerPlugin['locales'] } - : {}), - - }; - - await savePlugin(plugin, code); - invalidateFrameOriginsCache(); - await auditLog( - isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', - { - id: plugin.id, - name: plugin.name, - version: plugin.version, - slug, - frameOrigins: declaredFrameOrigins, - httpOrigins: declaredHttpOrigins, - apiPostPaths: declaredApiPostPaths, - ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), - }, - ip, - ); - - return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate }); } + + // Plugin installation + let pkg; + try { + pkg = await readPluginPackage(buffer, { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + expectedId: slug, + }); + } catch (err) { + if (err instanceof ExtensionPackageError) { + // Same wording the admin UI already shows for an id mismatch. + const message = err.message.replace(/Package id "([^"]*)" does not match "([^"]*)"/, 'Bundle id "$1" does not match the directory entry "$2"'); + return NextResponse.json({ error: message }, { status: 400 }); + } + throw err; + } + const { manifest, code } = pkg; + + // Block plugins with dangerous JS patterns + const suspicious = suspiciousCode(code); + if (suspicious.length > 0) { + return NextResponse.json( + { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` }, + { status: 400 }, + ); + } + + const existingPlugin = await getPlugin(slug); + const isUpdate = existingPlugin !== null; + + const plugin: ServerPlugin = { + id: slug, + name: manifest.name, + // See theme branch: trust the directory-published version, not + // manifest.version, so updates actually stick in the registry. + version: version || manifest.version, + author: manifest.author, + description: manifest.description, + type: manifest.type, + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), + permissions: manifest.permissions, + entrypoint: manifest.entrypoint, + enabled: existingPlugin?.enabled ?? true, + ...(existingPlugin?.forceEnabled !== undefined + ? { forceEnabled: existingPlugin.forceEnabled } + : {}), + installedAt: existingPlugin?.installedAt ?? now, + updatedAt: now, + ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), + ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), + ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), + ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), + ...(manifest.locales ? { locales: manifest.locales } : {}), + ...(manifest.credentials ? { credentials: manifest.credentials } : {}), + }; + + await savePlugin(plugin, code); + invalidateFrameOriginsCache(); + await auditLog( + isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', + { + id: plugin.id, + name: plugin.name, + version: plugin.version, + slug, + frameOrigins: plugin.frameOrigins ?? [], + httpOrigins: plugin.httpOrigins ?? [], + apiPostPaths: plugin.apiPostPaths ?? [], + ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), + }, + ip, + ); + + return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate }); } catch (error) { logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Installation failed' }, { status: 500 }); diff --git a/app/api/admin/plugin-approvals/source/route.ts b/app/api/admin/plugin-approvals/source/route.ts new file mode 100644 index 000000000..c966124e1 --- /dev/null +++ b/app/api/admin/plugin-approvals/source/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { logger } from '@/lib/logger'; +import { readApprovalBundle } from '@/lib/admin/plugin-approvals'; +import { readPluginPackage } from '@/lib/extension-package'; + +/** + * GET /api/admin/plugin-approvals/source?pluginId=…&bundleHash=… + * + * The manifest and code of a plugin a user asked to have approved, read + * from the ZIP they uploaded, so the admin decides on what will actually run. + */ +export async function GET(request: NextRequest) { + try { + const result = await requireAdminAuth(request); + if ('error' in result) return result.error; + + const pluginId = request.nextUrl.searchParams.get('pluginId') ?? ''; + const bundleHash = request.nextUrl.searchParams.get('bundleHash') ?? ''; + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(pluginId) || !/^[a-f0-9]{64}$/.test(bundleHash)) { + return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + } + const zip = await readApprovalBundle(pluginId, bundleHash); + if (!zip) return NextResponse.json({ error: 'No package stored for this request' }, { status: 404 }); + const pkg = await readPluginPackage(zip); + return NextResponse.json( + { manifest: pkg.manifest, code: pkg.code, warnings: pkg.warnings }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (err) { + logger.error('plugin-approvals source GET', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts new file mode 100644 index 000000000..7c73cd2a3 --- /dev/null +++ b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let signedIn = false; +vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => signedIn) })); +vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: vi.fn(async () => null), readDevBundle: vi.fn() })); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPlugin: vi.fn(async () => ({ id: 'demo', enabled: true, bundleHash: 'h' })), + getPluginBundle: vi.fn(async () => 'module.exports = {}'), + getPluginSignature: vi.fn(async () => 'sig'), +})); +vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: vi.fn(async () => 'sig') })); + +import { GET } from '../route'; + +function get() { + return GET(new NextRequest('http://localhost/api/admin/plugins/demo/bundle'), { params: Promise.resolve({ id: 'demo' }) }); +} + +beforeEach(() => { signedIn = false; }); + +describe('GET plugin bundle', () => { + it('does not hand plugin code to someone who is not signed in', async () => { + expect((await get()).status).toBe(401); + }); + + it('serves it to a signed-in user with its signature', async () => { + signedIn = true; + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.get('x-bundle-signature')).toBe('sig'); + }); +}); diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index f2a4fc687..06b398ae5 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; +import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { signBytes } from '@/lib/admin/plugin-signing'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; async function safeSign(code: string): Promise { try { return await signBytes(code); } catch { return null; } @@ -10,13 +11,18 @@ async function safeSign(code: string): Promise { /** * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle * - * Public endpoint so the client-side plugin loader can fetch bundles. + * Serves bundles to signed-in users so the client-side plugin loader can fetch them. * Only serves plugins that exist in the registry and are enabled. */ export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { + // Bundles are delivered to signed-in users only, like the plugin list + // that names them (/api/plugins). + if (!(await canSeeInstanceDetails(request))) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } try { const { id } = await params; @@ -65,7 +71,7 @@ export async function GET( }; if (etag) headers['ETag'] = etag; - const signature = await safeSign(code); + const signature = await getPluginSignature(id, code); if (signature) headers['X-Bundle-Signature'] = signature; if (etag && request.headers.get('if-none-match') === etag) { diff --git a/app/api/admin/plugins/[id]/config/__tests__/route.test.ts b/app/api/admin/plugins/[id]/config/__tests__/route.test.ts new file mode 100644 index 000000000..24e6c94b3 --- /dev/null +++ b/app/api/admin/plugins/[id]/config/__tests__/route.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let admin = true; +vi.mock('@/lib/admin/session', async () => { + const { NextResponse } = await import('next/server'); + return { requireAdminAuth: async () => (admin ? { ok: true } : { error: NextResponse.json({}, { status: 401 }) }) }; +}); +vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => ({ username: 'alice@example.org' }) })); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPlugin: async () => ({ id: 'paperless', configSchema: { serverUrl: { type: 'string' }, apiToken: { type: 'secret' } } }), +})); +vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: async () => null })); +vi.mock('@/lib/admin/plugin-config', () => ({ + getPluginConfig: async () => ({ serverUrl: 'https://paperless.example.org', apiToken: 'secret-token' }), + setPluginConfig: vi.fn(), deletePluginConfigKey: vi.fn(), +})); + +import { GET } from '../route'; + +function get(query = '') { + return GET(new NextRequest(`http://localhost/api/admin/plugins/paperless/config${query}`), { params: Promise.resolve({ id: 'paperless' }) }); +} + +beforeEach(() => { admin = true; }); + +describe('GET plugin config', () => { + it('gives an admin every field', async () => { + expect(await (await get()).json()).toEqual({ serverUrl: 'https://paperless.example.org', apiToken: 'secret-token' }); + }); + + it('never gives secrets with ?public=1, even to an admin (what plugins use)', async () => { + expect(await (await get('?public=1')).json()).toEqual({ serverUrl: 'https://paperless.example.org' }); + }); + + it('gives a user only the fields that aren\'t secrets', async () => { + admin = false; + expect(await (await get()).json()).toEqual({ serverUrl: 'https://paperless.example.org' }); + }); +}); diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index e8c5e9a00..7e75f1e61 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -34,8 +34,12 @@ export async function GET( return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); } + // ?public=1: only the fields any signed-in user may see, even for an + // admin. The plugin API uses it so a plugin never gets secrets just + // because the user is also signed in to the admin dashboard. + const publicOnly = request.nextUrl.searchParams.get('public') === '1'; const adminAuth = await requireAdminAuth(request); - const isAdmin = !('error' in adminAuth); + const isAdmin = !publicOnly && !('error' in adminAuth); if (!isAdmin) { const creds = await getStalwartCredentials(request); diff --git a/app/api/admin/plugins/__tests__/route.test.ts b/app/api/admin/plugins/__tests__/route.test.ts new file mode 100644 index 000000000..9d2ce079b --- /dev/null +++ b/app/api/admin/plugins/__tests__/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); + +let policy: Record; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(), + getPolicy: vi.fn(() => policy), + setPolicy: vi.fn(async (p: Record) => { policy = p; }), + }, +})); + +const registry = new Map>(); +const removedConfig: string[] = []; +vi.mock('@/lib/admin/plugin-registry', () => ({ + savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }), + getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null), + getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })), + deletePlugin: vi.fn(async (id: string) => registry.delete(id)), +})); +vi.mock('@/lib/admin/plugin-config', () => ({ + deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }), +})); +vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) })); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + invalidateFrameOriginsCache: vi.fn(), +})); + +import { DELETE, POST } from '../route'; + +const MANIFEST = { + id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function upload(files: Record) { + const zip = new JSZip(); + for (const [n, d] of Object.entries(files)) zip.file(n, d); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'p.zip', { type: 'application/zip' })); + return POST(new NextRequest('http://localhost/api/admin/plugins', { method: 'POST', body: form })); +} + +beforeEach(() => { + registry.clear(); + removedConfig.length = 0; + policy = { forceEnabledPlugins: [] }; +}); + +describe('POST /api/admin/plugins', () => { + it('installs a valid plugin with its locales', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, locales: { de: { hi: 'Hallo' } } }), + 'index.js': 'module.exports = {}', + }); + + expect(res.status).toBe(200); + expect(registry.get('demo-plugin')?.locales).toEqual({ de: { hi: 'Hallo' } }); + }); + + it('refuses permissions given as a string', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, permissions: 'crypto:full email:send', tier: 'privileged' }), + 'index.js': '', + }); + + expect(res.status).toBe(400); + expect(registry.size).toBe(0); + }); + + it('refuses a ZIP bomb instead of inflating it', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify(MANIFEST), + 'index.js': new Uint8Array(30 * 1024 * 1024), + }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/once unpacked/); + }); + + it('refuses an invalid settings schema', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, settingsSchema: { m: { type: 'select', label: 'M', default: 'a', options: 'a,b' } } }), + 'index.js': '', + }); + + expect(res.status).toBe(400); + }); +}); + +describe('re-uploading and deleting', () => { + it('keeps a plugin the admin disabled disabled when a new version is uploaded', async () => { + registry.set('demo-plugin', { id: 'demo-plugin', enabled: false, forceEnabled: true }); + + await upload({ 'manifest.json': JSON.stringify({ ...MANIFEST, version: '1.1.0' }), 'index.js': '' }); + + expect(registry.get('demo-plugin')).toMatchObject({ version: '1.1.0', enabled: false, forceEnabled: true }); + }); + + it('drops the config and the forced flag when a plugin is deleted', async () => { + registry.set('demo-plugin', { id: 'demo-plugin' }); + policy = { forceEnabledPlugins: ['demo-plugin', 'other'] }; + + const res = await DELETE(new NextRequest('http://localhost/api/admin/plugins', { + method: 'DELETE', + body: JSON.stringify({ id: 'demo-plugin' }), + headers: { 'content-type': 'application/json' }, + })); + + expect(res.status).toBe(200); + expect(removedConfig).toEqual(['demo-plugin']); + expect(policy.forceEnabledPlugins).toEqual(['other']); + }); +}); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index a62c4bf81..7e0306515 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -3,31 +3,17 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { + getPlugin, getPluginRegistry, savePlugin, deletePlugin as removePlugin, type ServerPlugin, } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; -import { - sanitizeFrameOrigins, - sanitizeHttpOrigins, - sanitizeApiPostPaths, - invalidateFrameOriginsCache, -} from '@/lib/admin/csp-frame-origins'; - -// Server-side extraction using the same validation logic -// ZIP parsing needs to happen on the server for admin-uploaded plugins -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; - -const SUSPICIOUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, -]; +import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; +import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup'; +import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; +import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; /** * GET /api/admin/plugins - List all admin-managed plugins @@ -79,141 +65,64 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 }); } - // Extract and validate ZIP - let zip: JSZip; + let pkg; try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); - } - - // Find root - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; + pkg = await readPluginPackage(await file.arrayBuffer(), { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + }); + } catch (err) { + if (err instanceof ExtensionPackageError) { + return NextResponse.json({ error: err.message }, { status: 400 }); } + throw err; } + const { manifest, code } = pkg; - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); - } - - // Validate manifest - const errors: string[] = []; - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"'); - - const validTypes = ['ui-extension', 'sidebar-app', 'hook']; - if (!validTypes.includes(manifest.type as string)) { - errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`); - } - - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - if (manifest.permissions && Array.isArray(manifest.permissions)) { - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); - if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`); - } - - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Check file extensions - for (const [filePath, entry] of Object.entries(zip.files)) { - if (entry.dir) continue; - const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : ''; - if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { - errors.push(`Disallowed file type: ${filePath}`); - } - } - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Read entrypoint code - const entryFile = zip.file(root + (manifest.entrypoint as string)); - if (!entryFile) { - return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 }); - } - const code = await entryFile.async('string'); - - // Security: block plugins containing dangerous JS patterns - const warnings: string[] = []; - for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { - if (pattern.test(code)) warnings.push(`Contains ${label}`); - pattern.lastIndex = 0; - } - if (warnings.length > 0) { + // Admin uploads reach every user, so the patterns a user upload only + // warns about are refused here. + const suspicious = suspiciousCode(code); + if (suspicious.length > 0) { return NextResponse.json( - { error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` }, + { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` }, { status: 400 }, ); } - const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - + // A new upload of an installed plugin is an update: keep whether the + // admin had it enabled or forced instead of switching it on for everyone. + const existing = await getPlugin(manifest.id); const now = new Date().toISOString(); const plugin: ServerPlugin = { - id: manifest.id as string, - name: manifest.name as string, - version: manifest.version as string, - author: manifest.author as string, - description: (manifest.description as string) || '', - type: manifest.type as string, + id: manifest.id, + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description, + type: manifest.type, ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), - permissions: (manifest.permissions as string[]) || [], - entrypoint: manifest.entrypoint as string, - enabled: true, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(manifest.locales && typeof manifest.locales === 'object' - ? { locales: manifest.locales as ServerPlugin['locales'] } - : {}), - ...(declaredFrameOrigins.length > 0 - ? { frameOrigins: declaredFrameOrigins } - : {}), - ...(declaredHttpOrigins.length > 0 - ? { httpOrigins: declaredHttpOrigins } - : {}), - ...(declaredApiPostPaths.length > 0 - ? { apiPostPaths: declaredApiPostPaths } - : {}), + permissions: manifest.permissions, + entrypoint: manifest.entrypoint, + enabled: existing?.enabled ?? true, + ...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}), + ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), + ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), + ...(manifest.locales ? { locales: manifest.locales } : {}), + ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), + ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), + ...(manifest.credentials ? { credentials: manifest.credentials } : {}), installedAt: now, updatedAt: now, }; + const declaredFrameOrigins = plugin.frameOrigins ?? []; + const declaredHttpOrigins = plugin.httpOrigins ?? []; + const declaredApiPostPaths = plugin.apiPostPaths ?? []; await savePlugin(plugin, code); invalidateFrameOriginsCache(); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); - return NextResponse.json({ plugin }); + return NextResponse.json({ plugin, warnings: pkg.warnings }); } catch (error) { logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -291,6 +200,7 @@ export async function DELETE(request: NextRequest) { if (!removed) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); } + await cleanUpDeletedPlugin(id); invalidateFrameOriginsCache(); await auditLog('plugin.delete', { id }, ip); diff --git a/app/api/admin/themes/[id]/css/route.ts b/app/api/admin/themes/[id]/css/route.ts index 347ccfaad..ad154a470 100644 --- a/app/api/admin/themes/[id]/css/route.ts +++ b/app/api/admin/themes/[id]/css/route.ts @@ -1,46 +1,13 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry'; -import { logger } from '@/lib/logger'; +import { NextRequest } from 'next/server'; +import { serveThemeAsset } from '@/lib/admin/theme-asset'; /** - * GET /api/admin/themes/[id]/css - Serve theme CSS to clients + * GET /api/admin/themes/[id]/css - Serve a theme's CSS to clients */ export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - - // Validate ID format - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { - return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 }); - } - - // Verify theme exists and is enabled - const registry = await getThemeRegistry(); - const theme = registry.themes.find(t => t.id === id); - if (!theme) { - return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); - } - if (!theme.enabled) { - return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 }); - } - - const css = await getThemeCSS(id); - if (!css) { - return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 }); - } - - return new NextResponse(css, { - headers: { - 'Content-Type': 'text/css; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', - 'X-Content-Type-Options': 'nosniff', - }, - }); - } catch (error) { - logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } + const { id } = await params; + return serveThemeAsset(id, 'css'); } diff --git a/app/api/admin/themes/[id]/skin/route.ts b/app/api/admin/themes/[id]/skin/route.ts new file mode 100644 index 000000000..c292e44e5 --- /dev/null +++ b/app/api/admin/themes/[id]/skin/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server'; +import { serveThemeAsset } from '@/lib/admin/theme-asset'; + +/** + * GET /api/admin/themes/[id]/skin - Serve a theme's skin (Theme API v2 component CSS) to clients + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + return serveThemeAsset(id, 'skin'); +} diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts new file mode 100644 index 000000000..89a60618a --- /dev/null +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); + +let policy: Record; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(), + getPolicy: vi.fn(() => policy), + setPolicy: vi.fn(async (p: Record) => { policy = p; }), + }, +})); +const saved: Array<{ theme: Record; css: string; skin: string | null }> = []; +vi.mock('@/lib/admin/plugin-registry', () => ({ + deleteTheme: vi.fn(async () => true), + themeCSSResolver: vi.fn(async () => () => null), + saveTheme: vi.fn(async (theme: Record, css: string, skin: string | null) => { saved.push({ theme, css, skin }); }), + getTheme: vi.fn(async () => null), + getThemeRegistry: vi.fn(async () => ({ themes: [] })), +})); + +import JSZip from 'jszip'; +import { DELETE, POST } from '../route'; + +function del(id: string) { + return DELETE(new NextRequest('http://localhost/api/admin/themes', { + method: 'DELETE', + body: JSON.stringify({ id }), + headers: { 'content-type': 'application/json' }, + })); +} + +beforeEach(() => { + saved.length = 0; + policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] }; +}); + +describe('DELETE /api/admin/themes', () => { + it('stops forcing a theme once it is deleted', async () => { + const res = await del('corp'); + + expect(res.status).toBe(200); + expect(policy.forceEnabledThemes).toEqual(['other']); + }); +}); + +describe('POST /api/admin/themes', () => { + it('refuses a theme.css that unpacks past the limit', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ id: 'big', name: 'Big', version: '1.0.0', author: 'a', type: 'theme', variants: ['light'] })); + zip.file('theme.css', new Uint8Array(30 * 1024 * 1024)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 't.zip')); + + const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form })); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/once unpacked/); + }); +}); + +describe('POST /api/admin/themes with Theme API v2', () => { + it('installs a tokens-only theme with a skin', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', type: 'theme', variants: ['light', 'dark'], + apiVersion: 2, tokens: { light: { '--color-primary': '#0066cc' }, dark: { '--color-primary': '#66aaff' } }, + })); + zip.file('skin.css', '.message-list { border-radius: 8px; }'); + const bytes = await zip.generateAsync({ type: 'uint8array' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'ocean.zip')); + + const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form })); + + expect(res.status).toBe(200); + expect(saved[0].css).toContain('#0066cc'); + expect(saved[0].skin).toContain('border-radius'); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index f600d09b6..3950021aa 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -3,15 +3,16 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { + getTheme, getThemeRegistry, saveTheme, deleteTheme as removeTheme, + themeCSSResolver, type ServerTheme, } from '@/lib/admin/plugin-registry'; +import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; -import JSZip from 'jszip'; -import { MAX_THEME_SIZE } from '@/lib/plugin-types'; -import { sanitizeThemeCSS } from '@/lib/theme-loader'; +import { readThemePackage } from '@/lib/extension-package'; /** * GET /api/admin/themes - List all admin-managed themes @@ -47,98 +48,30 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing file' }, { status: 400 }); } - if (file.size > MAX_THEME_SIZE) { - return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 }); + const read = await readThemePackage(await file.arrayBuffer(), { + resolveExtends: await themeCSSResolver(), + }); + if (!read.valid || !read.manifest) { + return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 }); } + const { manifest, css, skin, warnings } = read; - // Extract and validate ZIP - let zip: JSZip; - try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); - } - - // Find root - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); - } - - // Validate manifest - const errors: string[] = []; - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - - if (manifest.type !== 'theme') { - errors.push(`Expected type "theme", got "${manifest.type}"`); - } - - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { - errors.push('Missing or empty "variants" array'); - } else { - const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); - if (!valid) errors.push('Variants must be "light" or "dark"'); - } - - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Read theme.css - const cssFile = zip.file(root + 'theme.css'); - if (!cssFile) { - return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 }); - } - - let css = await cssFile.async('string'); - - // Sanitize unconditionally: the safety check is advisory, and the - // sanitizer is also what drops rules outside :root and .dark. - const warnings: string[] = []; - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); - + const existing = await getTheme(manifest.id); const now = new Date().toISOString(); const theme: ServerTheme = { - id: manifest.id as string, - name: manifest.name as string, - version: manifest.version as string, - author: manifest.author as string, - description: (manifest.description as string) || '', - variants: manifest.variants as string[], - enabled: true, - installedAt: now, + id: manifest.id, + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description || '', + variants: manifest.variants, + enabled: existing?.enabled ?? true, + ...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}), + installedAt: existing?.installedAt ?? now, updatedAt: now, }; - await saveTheme(theme, css); + await saveTheme(theme, css, skin); await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip); return NextResponse.json({ theme, warnings }); @@ -205,6 +138,7 @@ export async function DELETE(request: NextRequest) { if (!removed) { return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); } + await forgetExtensionInPolicy('theme', id); await auditLog('theme.delete', { id }, ip); return NextResponse.json({ success: true }); diff --git a/app/api/plugin-approval-status/__tests__/route.test.ts b/app/api/plugin-approval-status/__tests__/route.test.ts new file mode 100644 index 000000000..7d0ba7970 --- /dev/null +++ b/app/api/plugin-approval-status/__tests__/route.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) })); +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => (slot === 0 ? { username: currentUser } : null), +})); +vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null })); + +let currentUser = 'alice@example.org'; +const entries: Array> = []; +const bundles = new Map(); +vi.mock('@/lib/admin/plugin-approvals', async (orig) => { + const real = await orig(); + return { + ...real, + getApprovalStatus: vi.fn(async () => ({ status: 'not-requested' })), + requestApproval: vi.fn(async (pluginId: string, bundleHash: string, manifest: unknown, requestedBy: string) => { + const pending = entries.filter((e) => e.status === 'pending' && e.requestedBy === requestedBy).length; + if (pending >= real.MAX_PENDING_PER_USER) throw new real.ApprovalLimitError('too many'); + const entry = { pluginId, bundleHash, manifest, requestedBy, status: 'pending', requestedAt: 'now' }; + entries.push(entry); + return entry; + }), + saveApprovalBundle: vi.fn(async (id: string, hash: string, zip: Uint8Array) => { bundles.set(`${id}:${hash}`, zip); }), + }; +}); + +import { POST } from '../route'; +import { packageHash, checkPluginManifest } from '@/lib/extension-package'; + +const MANIFEST = { + id: 'my-plugin', name: 'Mine', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function zipOf(manifest: Record, code = 'module.exports = {}') { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest)); + zip.file('index.js', code); + return zip.generateAsync({ type: 'uint8array' }); +} + +async function submit(bytes: Uint8Array) { + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'p.zip')); + return POST(new NextRequest('http://localhost/api/plugin-approval-status', { method: 'POST', body: form })); +} + +beforeEach(() => { + entries.length = 0; + bundles.clear(); + currentUser = 'alice@example.org'; +}); + +describe('POST /api/plugin-approval-status', () => { + it('pins the request to the hash of the code and manifest it read itself', async () => { + const res = await submit(await zipOf(MANIFEST)); + const body = await res.json(); + + const expected = await packageHash(checkPluginManifest(MANIFEST).manifest!, 'module.exports = {}'); + expect(res.status).toBe(200); + expect(body.bundleHash).toBe(expected); + expect(entries[0].manifest).toMatchObject({ name: 'Mine', permissions: ['email:read'] }); + expect(bundles.has(`my-plugin:${expected}`)).toBe(true); + }); + + it('gives the same code with wider permissions a different hash', async () => { + const narrow = await (await submit(await zipOf(MANIFEST))).json(); + const wide = await (await submit(await zipOf({ ...MANIFEST, permissions: ['email:read', 'http:fetch'], httpOrigins: ['https://evil.example'] }))).json(); + + expect(wide.bundleHash).not.toBe(narrow.bundleHash); + }); + + it('refuses the old form where the client named the hash and manifest', async () => { + const res = await POST(new NextRequest('http://localhost/api/plugin-approval-status', { + method: 'POST', + body: JSON.stringify({ pluginId: 'my-plugin', bundleHash: 'a'.repeat(64), manifest: { permissions: [] } }), + headers: { 'content-type': 'application/json' }, + })); + + expect(res.status).toBe(400); + expect(entries).toHaveLength(0); + }); + + it('limits how many requests one user can have open', async () => { + for (let i = 0; i < 10; i++) { + expect((await submit(await zipOf({ ...MANIFEST, id: `p-${i}x` }))).status).toBe(200); + } + const res = await submit(await zipOf({ ...MANIFEST, id: 'one-more' })); + + expect(res.status).toBe(429); + }); +}); diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts index 08f99867e..d33f659c6 100644 --- a/app/api/plugin-approval-status/route.ts +++ b/app/api/plugin-approval-status/route.ts @@ -6,7 +6,14 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { logger } from '@/lib/logger'; -import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals'; +import { + ApprovalLimitError, + getApprovalStatus, + requestApproval, + saveApprovalBundle, + type ApprovalEntry, +} from '@/lib/admin/plugin-approvals'; +import { ExtensionPackageError, packageHash, readPluginPackage } from '@/lib/extension-package'; /** * GET /api/plugin-approval-status?pluginId=X&bundleHash=Y @@ -15,8 +22,9 @@ import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/ad * they want to enable. The client uses this BEFORE running `enablePlugin` * when the `requirePluginApproval` policy is set. * - * POST same path with body `{ pluginId, bundleHash, manifest }` creates a - * pending approval entry (or returns the existing one). + * POST same path with the plugin ZIP as multipart `file` creates a pending + * approval entry (or returns the existing one) for the package hash the + * server computes, and keeps the ZIP for the admin to review. */ async function resolveUsername(): Promise { @@ -67,26 +75,55 @@ export async function POST(request: NextRequest) { const username = await resolveUsername(); if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }); - let body: unknown; - try { body = await request.json(); } catch { body = null; } - const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown }; - if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) { - return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + // The request carries the package itself. The admin reviews the code + // and manifest the server read from it, and the approval is pinned to + // the hash the server computed, not to anything the client claims. + let file: File | null = null; + try { + const form = await request.formData(); + const value = form.get('file'); + file = value instanceof File ? value : null; + } catch { /* not multipart */ } + if (!file) { + return NextResponse.json({ error: 'Send the plugin ZIP as "file"' }, { status: 400 }); } + const bytes = new Uint8Array(await file.arrayBuffer()); - const m = (b.manifest ?? {}) as Record; - const manifest: ApprovalEntry['manifest'] = { - name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined, - version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined, - author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined, - description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined, - permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined, - httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined, - apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined, + let pkg; + try { + pkg = await readPluginPackage(bytes, { appVersion: process.env.NEXT_PUBLIC_APP_VERSION }); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + const { manifest, code } = pkg; + const hash = await packageHash(manifest, code); + + const snapshot: ApprovalEntry['manifest'] = { + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description, + permissions: manifest.permissions, + httpOrigins: manifest.httpOrigins, + apiPostPaths: manifest.apiPostPaths, }; - const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username); - return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt }); + let entry: ApprovalEntry; + try { + entry = await requestApproval(manifest.id, hash, snapshot, username); + } catch (err) { + if (err instanceof ApprovalLimitError) return NextResponse.json({ error: err.message }, { status: 429 }); + throw err; + } + if (entry.status === 'pending') await saveApprovalBundle(manifest.id, hash, bytes); + return NextResponse.json({ + pluginId: manifest.id, + bundleHash: hash, + status: entry.status, + requestedAt: entry.requestedAt, + decidedAt: entry.decidedAt, + }); } catch (err) { logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts new file mode 100644 index 000000000..a7d941c4c --- /dev/null +++ b/app/api/plugin-proxy/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials'; +import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy'; + +const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; +const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; + +function parseCapture(raw: string | null): CaptureSpec | null | undefined { + if (!raw) return undefined; + try { + const v = JSON.parse(raw) as Record; + if (typeof v.secret !== 'string' || !FIELD_RE.test(v.secret)) return null; + if (v.username !== undefined && (typeof v.username !== 'string' || !FIELD_RE.test(v.username))) return null; + return { secret: v.secret, ...(typeof v.username === 'string' ? { username: v.username } : {}) }; + } catch { + return null; + } +} + +/** + * POST /api/plugin-proxy?plugin=&credential=&url= + * + * Forwards a plugin's request to `url` with the named credential added. The + * target method goes in `X-Bulwark-Method` (default GET); the request body + * and headers are passed through, minus cookies, auth and transport headers. + * `X-Bulwark-Capture: {"secret":"appPassword","username":"loginName"}` stores + * those JSON fields of the response as the credential instead of returning + * them (Nextcloud Login Flow v2). + * + * Called by the host page on a plugin's behalf, never by the sandboxed + * plugin itself (its iframe has an opaque origin and is refused here). + */ +export async function POST(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + + await configManager.ensureLoaded(); + if (!configManager.getPolicy().features.pluginsEnabled) { + return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 }); + } + + const creds = await getStalwartCredentials(request); + if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); + + const params = request.nextUrl.searchParams; + const pluginId = params.get('plugin') ?? ''; + const credentialId = params.get('credential') ?? ''; + const url = params.get('url') ?? ''; + if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) { + return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 }); + } + const capture = parseCapture(request.headers.get('x-bulwark-capture')); + if (capture === null) { + return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 }); + } + + try { + return await proxyRequest({ + user: { username: creds.username, serverUrl: creds.serverUrl }, + pluginId, + credentialId, + url, + method: request.headers.get('x-bulwark-method') ?? 'GET', + headers: request.headers, + body: request.body, + capture, + signal: request.signal, + }); + } catch (error) { + if (error instanceof ProxyError) { + // Marks the proxy's own refusals, so the plugin can tell them from the + // upstream server's errors. + const headers: Record = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code }; + const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter; + if (retryAfter) headers['retry-after'] = String(retryAfter); + return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers }); + } + return NextResponse.json({ error: 'Internal server error', code: 'internal' }, { status: 500 }); + } +} diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts new file mode 100644 index 000000000..c99c310c9 --- /dev/null +++ b/app/api/plugin-secrets/__tests__/route.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let signedIn = true; +let pluginsEnabled = true; +let sameOrigin = true; +vi.mock('@/lib/stalwart/credentials', () => ({ + getStalwartCredentials: async () => (signedIn ? { username: 'alice@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null), +})); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) }, +})); +vi.mock('@/lib/security/same-origin', async () => { + const { NextResponse } = await import('next/server'); + return { rejectCrossOriginRequest: () => (sameOrigin ? null : NextResponse.json({}, { status: 403 })) }; +}); +const store = new Map>(); +vi.mock('@/lib/plugin-secrets/vault', () => ({ + VaultFullError: class extends Error {}, + putUserCredential: vi.fn(async (_u: unknown, pluginId: string, credentialId: string, input: Record) => { + store.set(`${pluginId}/${credentialId}`, input); + }), + listUserCredentials: vi.fn(async () => []), + deleteUserCredentials: vi.fn(async () => {}), +})); +const proxied: unknown[] = []; +vi.mock('@/lib/plugin-secrets/proxy', async (orig) => { + const real = await orig(); + return { + ...real, + proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }), + adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]), + adminCredentialOrigins: vi.fn(async () => ['https://paperless.example.com']), + }; +}); + +import { GET as listCredentials, POST as saveCredential } from '../route'; +import { POST as proxy } from '../../plugin-proxy/route'; + +const VALID = { + pluginId: 'nextcloud', credentialId: 'cloud', label: 'Nextcloud', + origin: 'https://cloud.example.org/index.php', inject: { type: 'basic' }, username: 'alice', secret: 'app-pw', +}; + +function save(body: unknown) { + return saveCredential(new NextRequest('http://localhost/api/plugin-secrets', { method: 'POST', body: JSON.stringify(body) })); +} + +function proxyCall(query: string, headers: Record = {}) { + return proxy(new NextRequest(`http://localhost/api/plugin-proxy?${query}`, { method: 'POST', headers })); +} + +beforeEach(() => { + signedIn = true; + pluginsEnabled = true; + sameOrigin = true; + store.clear(); + proxied.length = 0; +}); + +describe('GET /api/plugin-secrets', () => { + it("names the servers of set-up admin credentials for one plugin, for the host page's link check", async () => { + const res = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets?plugin=paperless')); + expect(await res.json()).toMatchObject({ admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] }); + const all = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets')); + expect((await all.json()).adminOrigins).toEqual([]); + }); +}); + +describe('POST /api/plugin-secrets', () => { + it('stores a credential for the server origin only', async () => { + const res = await save(VALID); + + expect(res.status).toBe(200); + expect(store.get('nextcloud/cloud')).toMatchObject({ origin: 'https://cloud.example.org', secret: 'app-pw', username: 'alice' }); + }); + + it.each([ + ['a plain http server', { origin: 'http://cloud.example.org' }], + ['a header the proxy owns', { inject: { type: 'header', name: 'Cookie' } }], + ['a secret with a line break', { secret: 'a\r\nX-Evil: 1' }], + ['a bad plugin id', { pluginId: '../x' }], + ])('refuses %s', async (_name, change) => { + const res = await save({ ...VALID, ...change }); + + expect(res.status).toBe(400); + expect(store.size).toBe(0); + }); + + it('refuses without a session, with plugins off, or from another site', async () => { + signedIn = false; + expect((await save(VALID)).status).toBe(401); + signedIn = true; + pluginsEnabled = false; + expect((await save(VALID)).status).toBe(403); + pluginsEnabled = true; + sameOrigin = false; + expect((await save(VALID)).status).toBe(403); + expect(store.size).toBe(0); + }); +}); + +describe('POST /api/plugin-proxy', () => { + it('passes the request on with the method and capture header', async () => { + const res = await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Flogin%2Fv2%2Fpoll', { + 'x-bulwark-method': 'POST', 'x-bulwark-capture': '{"secret":"appPassword","username":"loginName"}', + }); + + expect(res.status).toBe(200); + expect(proxied[0]).toMatchObject({ + pluginId: 'nextcloud', credentialId: 'cloud', method: 'POST', + url: 'https://cloud.example.org/login/v2/poll', + capture: { secret: 'appPassword', username: 'loginName' }, + user: { username: 'alice@example.org' }, + }); + }); + + it('refuses without a session, from another site, or with a broken capture header', async () => { + signedIn = false; + expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(401); + signedIn = true; + sameOrigin = false; + expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(403); + sameOrigin = true; + expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example', { 'x-bulwark-capture': '{"secret":"a.b"}' })).status).toBe(400); + expect(proxied).toEqual([]); + }); +}); diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts new file mode 100644 index 000000000..0e67b59ff --- /dev/null +++ b/app/api/plugin-secrets/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials'; +import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy'; +import { + VaultFullError, + deleteUserCredentials, + listUserCredentials, + putUserCredential, + type VaultUser, +} from '@/lib/plugin-secrets/vault'; + +const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; +const MAX_SECRET_LENGTH = 4096; +const NO_STORE = { 'cache-control': 'no-store' }; + +async function currentUser(request: NextRequest): Promise { + await configManager.ensureLoaded(); + if (!configManager.getPolicy().features.pluginsEnabled) { + return NextResponse.json({ error: 'Plugins are turned off' }, { status: 403 }); + } + const creds = await getStalwartCredentials(request); + if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + return { username: creds.username, serverUrl: creds.serverUrl }; +} + +/** GET /api/plugin-secrets[?plugin=] - the user's connected accounts, without secrets. */ +export async function GET(request: NextRequest) { + const user = await currentUser(request); + if (user instanceof NextResponse) return user; + const pluginId = request.nextUrl.searchParams.get('plugin') ?? undefined; + if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) { + return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 }); + } + const credentials = await listUserCredentials(user, pluginId); + // For one plugin, also say which of its admin credentials are set up, and + // on which servers: the host page checks the links a plugin shows against + // them (the plugin itself only gets yes/no, see proxyAccounts). + const [admin, adminOrigins] = pluginId + ? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)]) + : [[], []]; + return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE }); +} + +/** + * POST /api/plugin-secrets - connect an account for a plugin. + * Body: { pluginId, credentialId, label, origin, inject, username?, secret? } + * + * Sent by the host's own "Connect" dialog, where the user typed the secret + * and saw which plugin and which server it is for. Without `secret` it only + * records the approved server, for a sign-in flow that fills the secret in + * later through the proxy. + */ +export async function POST(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + const user = await currentUser(request); + if (user instanceof NextResponse) return user; + + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const { pluginId, credentialId, label, username, secret } = body; + const origin = credentialOrigin(body.origin); + const inject = checkInject(body.inject); + if (typeof pluginId !== 'string' || !PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id'); + if (typeof credentialId !== 'string' || !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id'); + if (typeof label !== 'string' || !label.trim() || label.length > 80) return bad('Invalid label'); + if (!origin) return bad('The server address must start with https://'); + if (!inject) return bad('Invalid inject'); + if (username !== undefined && (typeof username !== 'string' || username.length > 256 || /[\r\n]/.test(username))) return bad('Invalid username'); + if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || /[\r\n]/.test(secret))) return bad('Invalid secret'); + + try { + await putUserCredential(user, pluginId, credentialId, { + origin, inject, label: label.trim(), + ...(typeof username === 'string' && username ? { username } : {}), + ...(typeof secret === 'string' ? { secret } : {}), + }); + } catch (error) { + if (error instanceof VaultFullError) return NextResponse.json({ error: error.message }, { status: 409 }); + throw error; + } + return NextResponse.json({ ok: true }, { headers: NO_STORE }); +} + +/** DELETE /api/plugin-secrets?plugin=[&credential=] - disconnect. */ +export async function DELETE(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + const user = await currentUser(request); + if (user instanceof NextResponse) return user; + const pluginId = request.nextUrl.searchParams.get('plugin') ?? ''; + const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined; + if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id'); + if (credentialId !== undefined && !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id'); + await deleteUserCredentials(user, pluginId, credentialId); + return NextResponse.json({ ok: true }, { headers: NO_STORE }); +} + +function bad(error: string) { + return NextResponse.json({ error }, { status: 400 }); +} diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 8332d1da9..238ecc0e7 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -63,6 +63,8 @@ export async function GET(request: NextRequest) { httpOrigins: p.httpOrigins, // Surface so clients can enforce api.http.post path allowlists. apiPostPaths: p.apiPostPaths, + // Which credentials the plugin may use through the proxy; no secrets. + credentials: p.credentials, // Per-user settings schema, captured from the manifest at upload/load // time so the client can render the settings UI without re-parsing. settingsSchema: p.settingsSchema, @@ -82,6 +84,8 @@ export async function GET(request: NextRequest) { description: t.description, variants: t.variants, forceEnabled: t.forceEnabled || false, + hasSkin: t.hasSkin || false, + contentHash: t.contentHash, })); return NextResponse.json( diff --git a/app/globals.css b/app/globals.css index 5d0db0a5f..2c1008564 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; -@custom-variant dark (&:where(.dark, .dark *)); +@import "./theme-inline.css"; :root { font-size: var(--font-size-base); @@ -123,46 +123,6 @@ --color-chart-5: #a78bfa; } -@theme inline { - --color-border: var(--color-border); - --color-input: var(--color-input); - --color-ring: var(--color-ring); - --color-background: var(--color-background); - --color-foreground: var(--color-foreground); - --color-primary: var(--color-primary); - --color-primary-foreground: var(--color-primary-foreground); - --color-secondary: var(--color-secondary); - --color-secondary-foreground: var(--color-secondary-foreground); - --color-muted: var(--color-muted); - --color-muted-foreground: var(--color-muted-foreground); - --color-accent: var(--color-accent); - --color-accent-foreground: var(--color-accent-foreground); - --color-destructive: var(--color-destructive); - --color-destructive-foreground: var(--color-destructive-foreground); - --color-popover: var(--color-popover); - --color-popover-foreground: var(--color-popover-foreground); - --color-sidebar: var(--color-sidebar); - --color-sidebar-foreground: var(--color-sidebar-foreground); - --color-sidebar-border: var(--color-sidebar-border); - --color-sidebar-accent: var(--color-sidebar-accent); - --color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground); - --color-card: var(--color-card); - --color-card-foreground: var(--color-card-foreground); - --color-success: var(--color-success); - --color-success-foreground: var(--color-success-foreground); - --color-warning: var(--color-warning); - --color-warning-foreground: var(--color-warning-foreground); - --color-info: var(--color-info); - --color-info-foreground: var(--color-info-foreground); - --color-selection: var(--color-selection); - --color-selection-foreground: var(--color-selection-foreground); - --color-unread: var(--color-unread); - --color-chart-1: var(--color-chart-1); - --color-chart-2: var(--color-chart-2); - --color-chart-3: var(--color-chart-3); - --color-chart-4: var(--color-chart-4); - --color-chart-5: var(--color-chart-5); -} * { border-color: var(--color-border); diff --git a/app/theme-inline.css b/app/theme-inline.css new file mode 100644 index 000000000..32ab34fc7 --- /dev/null +++ b/app/theme-inline.css @@ -0,0 +1,46 @@ +/* Shared by the app (globals.css) and the plugin UI kit (sandbox + plugin-kit.css): the dark variant and the mapping from the theme tokens to + Tailwind colours, so both generate the same utilities. */ + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-border: var(--color-border); + --color-input: var(--color-input); + --color-ring: var(--color-ring); + --color-background: var(--color-background); + --color-foreground: var(--color-foreground); + --color-primary: var(--color-primary); + --color-primary-foreground: var(--color-primary-foreground); + --color-secondary: var(--color-secondary); + --color-secondary-foreground: var(--color-secondary-foreground); + --color-muted: var(--color-muted); + --color-muted-foreground: var(--color-muted-foreground); + --color-accent: var(--color-accent); + --color-accent-foreground: var(--color-accent-foreground); + --color-destructive: var(--color-destructive); + --color-destructive-foreground: var(--color-destructive-foreground); + --color-popover: var(--color-popover); + --color-popover-foreground: var(--color-popover-foreground); + --color-sidebar: var(--color-sidebar); + --color-sidebar-foreground: var(--color-sidebar-foreground); + --color-sidebar-border: var(--color-sidebar-border); + --color-sidebar-accent: var(--color-sidebar-accent); + --color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground); + --color-card: var(--color-card); + --color-card-foreground: var(--color-card-foreground); + --color-success: var(--color-success); + --color-success-foreground: var(--color-success-foreground); + --color-warning: var(--color-warning); + --color-warning-foreground: var(--color-warning-foreground); + --color-info: var(--color-info); + --color-info-foreground: var(--color-info-foreground); + --color-selection: var(--color-selection); + --color-selection-foreground: var(--color-selection-foreground); + --color-unread: var(--color-unread); + --color-chart-1: var(--color-chart-1); + --color-chart-2: var(--color-chart-2); + --color-chart-3: var(--color-chart-3); + --color-chart-4: var(--color-chart-4); + --color-chart-5: var(--color-chart-5); +} diff --git a/components/__tests__/keyboard-shortcuts-palette.test.tsx b/components/__tests__/keyboard-shortcuts-palette.test.tsx new file mode 100644 index 000000000..b4e6c02ed --- /dev/null +++ b/components/__tests__/keyboard-shortcuts-palette.test.tsx @@ -0,0 +1,37 @@ +import { act, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +const pro = vi.hoisted(() => ({ active: false })); +vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => pro.active })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) })); + +const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal'); +const { resetActions, setPluginActions } = await import('@/lib/plugin-sandbox/actions'); + +describe('keyboard shortcuts help', () => { + it('lists Ctrl+K as the command palette outside the Pro shell', () => { + pro.active = false; + render( {}} />); + expect(screen.getByText('command_palette.open')).toBeInTheDocument(); + expect(screen.queryByText('shortcuts.global.global_search')).toBeNull(); + }); + + it('lists Ctrl+K as global search in the Pro shell', () => { + pro.active = true; + render( {}} />); + expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument(); + expect(screen.queryByText('command_palette.open')).toBeNull(); + }); + + it("lists a plugin's composer button shortcut under the composer, with the plugin's name", () => { + act(() => setPluginActions('assistant', 'Writing assistant', 'composer-toolbar-actions', [ + { id: 'ask', label: 'Assistant', shortcut: 'Ctrl+Shift+Y' }, + { id: 'quiet', label: 'No key' }, + ])); + render( {}} />); + expect(screen.getByText('Assistant (Writing assistant)')).toBeInTheDocument(); + expect(screen.getByText('Ctrl+Shift+Y')).toBeInTheDocument(); + expect(screen.queryByText(/No key/)).toBeNull(); + act(() => resetActions()); + }); +}); diff --git a/components/__tests__/plugin-app-events.test.tsx b/components/__tests__/plugin-app-events.test.tsx new file mode 100644 index 000000000..75790e086 --- /dev/null +++ b/components/__tests__/plugin-app-events.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render } from '@testing-library/react'; +import { appLifecycleHooks, clearAllHooks, routerHooks } from '@/lib/plugin-hooks'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useAuthStore } from '@/stores/auth-store'; + +let pathname = '/'; +vi.mock('next/navigation', () => ({ usePathname: () => pathname })); + +import { PluginAppEvents, PLUGIN_INTERVAL_MS } from '../providers/plugin-app-events'; + +function listen unknown) => unknown }>(bus: T) { + const fn = vi.fn(); + bus.register('test', fn); + return fn; +} + +beforeEach(() => { + clearAllHooks(); + pathname = '/'; + usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useAuthStore.setState({ isAuthenticated: false }); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('app events for plugins', () => { + // These used to live in the mail view and stopped on every other page. + it('reports the network and tab state on any page', () => { + const online = listen(appLifecycleHooks.onOnline); + const visibility = listen(appLifecycleHooks.onVisibilityChange); + render(); + + act(() => { window.dispatchEvent(new Event('online')); }); + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + + expect(online).toHaveBeenCalledTimes(1); + expect(visibility).toHaveBeenCalledWith({ visible: true }); + }); + + it('reports unhandled errors', () => { + const errors = listen(appLifecycleHooks.onAppError); + render(); + + act(() => { window.dispatchEvent(new ErrorEvent('error', { message: 'boom' })); }); + + expect(errors).toHaveBeenCalledWith({ message: 'boom' }); + }); + + it('ticks once a minute', () => { + vi.useFakeTimers(); + const tick = listen(appLifecycleHooks.onInterval); + render(); + + act(() => { vi.advanceTimersByTime(PLUGIN_INTERVAL_MS * 2 + 10); }); + + expect(tick).toHaveBeenCalledTimes(2); + }); + + it('says the app is ready once, after sign-in and plugin start', () => { + const ready = listen(appLifecycleHooks.onAppReady); + const { rerender } = render(); + expect(ready).not.toHaveBeenCalled(); + + act(() => { + useAuthStore.setState({ isAuthenticated: true }); + usePluginStore.setState({ initialized: true }); + }); + rerender(); + act(() => { usePluginStore.setState({ initialized: false }); }); + act(() => { usePluginStore.setState({ initialized: true }); }); + + expect(ready).toHaveBeenCalledTimes(1); + }); + + it('starts the plugins once the user is signed in, on any page', () => { + render(); + expect(usePluginStore.getState().initializePlugins).not.toHaveBeenCalled(); + + act(() => { useAuthStore.setState({ isAuthenticated: true }); }); + + expect(usePluginStore.getState().initializePlugins).toHaveBeenCalledTimes(1); + }); + + it('reports navigation between pages', () => { + const navigate = listen(routerHooks.onNavigate); + const { rerender } = render(); + + pathname = '/calendar'; + rerender(); + + expect(navigate).toHaveBeenCalledWith({ path: '/calendar', from: '/' }); + }); +}); diff --git a/components/__tests__/plugin-oauth-callback-listener.test.tsx b/components/__tests__/plugin-oauth-callback-listener.test.tsx index 6b738810c..2c84da6d9 100644 --- a/components/__tests__/plugin-oauth-callback-listener.test.tsx +++ b/components/__tests__/plugin-oauth-callback-listener.test.tsx @@ -2,6 +2,7 @@ import { authHooks } from "@/lib/plugin-hooks"; import { PLUGIN_OAUTH_EVENT, PLUGIN_OAUTH_STORAGE_KEY, + createOAuthState, } from "@/lib/plugin-oauth"; import { act, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -25,6 +26,8 @@ function emitStorageEvent(newValue: string | null) { }); } +let STATE = ""; + describe("PluginOAuthCallbackListener", () => { let handler: (payload: unknown) => Promise; let disposable: { dispose: () => void }; @@ -35,6 +38,7 @@ describe("PluginOAuthCallbackListener", () => { mockUsePathname.mockReturnValue("/en/mail"); handler = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined); disposable = authHooks.onOAuthCallback.register("test-plugin", handler); + STATE = createOAuthState("test-plugin"); }); afterEach(() => { @@ -43,14 +47,14 @@ describe("PluginOAuthCallbackListener", () => { it("delivers a success payload via the storage event", () => { const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ code: "abc", state: "xyz" }), + expect.objectContaining({ code: "abc", state: STATE }), ); // Best-effort cleanup after delivery expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBeNull(); @@ -59,14 +63,14 @@ describe("PluginOAuthCallbackListener", () => { it("delivers an error payload (denial) with state", () => { const { unmount } = render(); - const payload = { state: "xyz", error: "access_denied", receivedAt: 1 }; + const payload = { state: STATE, error: "access_denied", receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ state: "xyz", error: "access_denied" }), + expect.objectContaining({ state: STATE, error: "access_denied" }), ); unmount(); }); @@ -74,7 +78,7 @@ describe("PluginOAuthCallbackListener", () => { it("does nothing on the callback route", () => { mockUsePathname.mockReturnValue("/en/plugins/oauth/callback"); const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); @@ -95,7 +99,7 @@ describe("PluginOAuthCallbackListener", () => { emitStorageEvent("not json"); emitStorageEvent(JSON.stringify({ foo: "bar" })); - emitStorageEvent(JSON.stringify({ code: 123, state: "xyz" })); + emitStorageEvent(JSON.stringify({ code: 123, state: STATE })); emitStorageEvent(null); expect(handler).not.toHaveBeenCalled(); @@ -119,7 +123,7 @@ describe("PluginOAuthCallbackListener", () => { try { vi.setSystemTime(0); const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); @@ -127,10 +131,10 @@ describe("PluginOAuthCallbackListener", () => { expect(handler).toHaveBeenCalledOnce(); - // Outside the dedup window the same payload is delivered again + // A state is used once: replaying it later delivers nothing either. vi.setSystemTime(61_000); emitStorageEvent(JSON.stringify(payload)); - expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledOnce(); unmount(); } finally { @@ -140,7 +144,7 @@ describe("PluginOAuthCallbackListener", () => { it("delivers same-tab custom events", () => { const { unmount } = render(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; act(() => { window.dispatchEvent( @@ -150,7 +154,7 @@ describe("PluginOAuthCallbackListener", () => { expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ code: "abc", state: "xyz" }), + expect.objectContaining({ code: "abc", state: STATE }), ); unmount(); }); @@ -159,7 +163,7 @@ describe("PluginOAuthCallbackListener", () => { const { unmount } = render(); unmount(); - const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + const payload = { code: "abc", state: STATE, receivedAt: 1 }; localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); emitStorageEvent(JSON.stringify(payload)); act(() => { @@ -170,4 +174,29 @@ describe("PluginOAuthCallbackListener", () => { expect(handler).not.toHaveBeenCalled(); }); + + it("delivers the code only to the plugin that started the flow", () => { + // Every plugin with http:fetch used to receive every code. + const other = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined); + const otherDisposable = authHooks.onOAuthCallback.register("other-plugin", other); + const { unmount } = render(); + + emitStorageEvent(JSON.stringify({ code: "abc", state: STATE, receivedAt: 1 })); + + expect(handler).toHaveBeenCalledOnce(); + expect(other).not.toHaveBeenCalled(); + otherDisposable.dispose(); + unmount(); + }); + + it("drops a callback whose state no plugin created", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { unmount } = render(); + + emitStorageEvent(JSON.stringify({ code: "abc", state: "made-up", receivedAt: 1 })); + + expect(handler).not.toHaveBeenCalled(); + warn.mockRestore(); + unmount(); + }); }); diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx new file mode 100644 index 000000000..b1d0063c1 --- /dev/null +++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx @@ -0,0 +1,254 @@ +import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventModal } from '../event-modal'; +import { EventDetailPopover } from '../event-detail-popover'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import { CalendarMonthView } from '../calendar-month-view'; +import { MiniCalendar } from '../mini-calendar'; +import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Plugin surfaces in the calendar: a section in an event's details and in +// its form, and the labels and dots plugins put on days. + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const calendars = [ + { id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, +] as unknown as Calendar[]; + +const existing = { + id: 'e1', + uid: 'uid-1', + title: 'Planning', + start: '2026-10-12T10:00:00', + duration: 'PT1H', + timeZone: null, + showWithoutTime: false, + calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +afterEach(() => { + cleanup(); + clearSlotOffers(); + resetDayLabels(); +}); + +describe('calendar-event-detail', () => { + it('shows the event in the detail popover', () => { + offerSlots('p1', ['calendar-event-detail']); + render( + , + ); + + const frame = screen.getByTestId('plugin-frame-calendar-event-detail'); + expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' }); + }); + + it('moves the popover up when the plugin section makes it taller, so it stays in the window', () => { + let resize: (() => void) | null = null; + vi.stubGlobal('ResizeObserver', class { + constructor(cb: () => void) { resize = cb; } + observe() {} + unobserve() {} + disconnect() {} + }); + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); + let height = 200; + const spy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.getAttribute('role') === 'dialog' ? height : 0; + }); + try { + offerSlots('p1', ['calendar-event-detail']); + // An event at 14:00 in the week view of a 1280x800 window. + render( + , + ); + const popover = screen.getByRole('dialog'); + expect(popover.style.top).toBe('520px'); + + height = 420; + act(() => resize?.()); + expect(popover.style.top).toBe(`${800 - 420 - 12}px`); + } finally { + spy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it('shows the event in the modal view, and not in the form', () => { + offerSlots('p1', ['calendar-event-detail']); + render(); + + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' }); + + fireEvent.click(screen.getByText('events.edit')); + expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull(); + }); + + it('renders nothing when no plugin offers it', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('calendar-event-form', () => { + it('hands the plugin the form state of an edited event and follows the fields', async () => { + offerSlots('p1', ['calendar-event-form']); + render(); + expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull(); + + fireEvent.click(screen.getByText('events.edit')); + const frame = screen.getByTestId('plugin-frame-calendar-event-form'); + expect(framePropsOf(frame).event).toMatchObject({ + id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false, + }); + + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } }); + await waitFor(() => { + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' }); + }); + }); + + it('gives a new event an empty id', () => { + offerSlots('p1', ['calendar-event-form']); + render( + , + ); + + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ + id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [], + }); + }); + + it('renders nothing when no plugin offers it', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('calendar-day-header and mini-calendar-marks', () => { + const week = { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'week:2026-09-07', + rangeStart: new Date(2026, 8, 7), + rangeEnd: new Date(2026, 8, 13), + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAtTime: vi.fn(), + }; + + function headerOf(dayKey: string): HTMLElement { + const header = document.querySelector(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`); + if (!header) throw new Error(`no header for ${dayKey}`); + return header; + } + + it('puts a label under the day name in the week view', () => { + render(); + expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }])); + + const label = headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]'); + expect(label).toHaveTextContent('Feast day'); + expect(label?.title).toBe('A feast (Holidays)'); + expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + }); + + it('puts a label under the day name in the day view', () => { + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }])); + render(); + + expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day'); + }); + + it('shows the label in the month cell header, as a dot on phones', () => { + act(() => setDayLabels('hol', 'Holidays', [ + { date: '2026-09-09', text: 'Feast day' }, + { date: '2026-09-10', dot: true, title: 'Dot only' }, + ])); + const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) }; + const { unmount } = render(); + + const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]'); + expect(labels).toHaveLength(1); + expect(labels[0]).toHaveTextContent('Feast day'); + expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/); + unmount(); + + render(); + const dots = [...document.querySelectorAll('[data-plugin-surface="calendar-day-header"]')]; + expect(dots).toHaveLength(2); + for (const dot of dots) expect(dot).toHaveAttribute('data-compact'); + expect(dots[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/); + expect(dots[0].title).toBe('Feast day (Holidays)'); + expect(dots[1].title).toBe('Dot only (Holidays)'); + }); + + it('marks days with a dot in the small month calendar', () => { + render( + , + ); + expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull(); + + act(() => setDayLabels('hol', 'Holidays', [ + { date: '2026-09-09', dot: true, title: 'A feast' }, + { date: '2026-09-10', text: 'Text only' }, + ])); + + const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]'); + expect(dots).toHaveLength(1); + expect(dots[0].closest('button')).toHaveTextContent('9'); + expect(dots[0].closest('button')).toHaveTextContent('A feast'); + }); + + it('keeps the dot visible on today, whose filled circle has white text', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 8, 9, 12)); + try { + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', dot: true, title: 'A feast' }])); + render( + , + ); + const dot = document.querySelector('[data-plugin-surface="mini-calendar-marks"]')!; + expect(dot.closest('button')).toHaveAttribute('aria-current', 'date'); + // Its own colour, not the button's text colour, which is white on today. + expect(dot.className).not.toMatch(/bg-current/); + expect(dot.className).toMatch(/ring-background/); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/components/calendar/__tests__/event-modal-open-hooks.test.tsx b/components/calendar/__tests__/event-modal-open-hooks.test.tsx new file mode 100644 index 000000000..00c4e44ca --- /dev/null +++ b/components/calendar/__tests__/event-modal-open-hooks.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventModal } from '../event-modal'; +import { calendarFormHooks, calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Opening an event for reading fires onCalendarEventOpen once; opening the +// form (a new event, or Edit on an open one) fires onCalendarEventFormOpen. + +const calendars = [ + { id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, +] as unknown as Calendar[]; + +const existing = { + id: 'e1', + uid: 'uid-1', + title: 'Planning', + start: '2026-10-12T10:00:00', + duration: 'PT1H', + timeZone: null, + showWithoutTime: false, + calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +afterEach(() => clearAllHooks()); + +describe('EventModal open hooks', () => { + it('reports an existing event opened for reading, and the form when Edit is pressed', async () => { + const opened = vi.fn(); + const formOpened = vi.fn(); + calendarHooks.onCalendarEventOpen.register('t', opened); + calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened); + + render(); + + await waitFor(() => expect(opened).toHaveBeenCalledTimes(1)); + expect(opened.mock.calls[0][0]).toMatchObject({ id: 'e1', title: 'Planning', calendarId: 'cal-1' }); + expect(formOpened).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('events.edit')); + await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1)); + expect(formOpened.mock.calls[0][0]).toMatchObject({ title: 'Planning', calendarId: 'cal-1', uid: 'uid-1', isAllDay: false }); + expect(opened).toHaveBeenCalledTimes(1); + }); + + it('reports the form for a new event, and no event open', async () => { + const opened = vi.fn(); + const formOpened = vi.fn(); + calendarHooks.onCalendarEventOpen.register('t', opened); + calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened); + + render( + , + ); + + await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1)); + expect(formOpened.mock.calls[0][0]).toMatchObject({ calendarId: 'cal-1', uid: undefined, attendees: [] }); + expect(opened).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/__tests__/task-plugin-surfaces.test.tsx b/components/calendar/__tests__/task-plugin-surfaces.test.tsx new file mode 100644 index 000000000..473f460e5 --- /dev/null +++ b/components/calendar/__tests__/task-plugin-surfaces.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { TaskModal } from '../task-modal'; +import { TaskToolbar } from '../task-toolbar'; +import { filterTasksForList } from '../task-list-view'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// Plugin surfaces for tasks: a section in a task's details and buttons in +// the task list toolbar acting on the tasks the list shows. + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const calendars = [{ id: 'cal-1', name: 'Tasks', isShared: false }] as unknown as Calendar[]; + +function makeTask(id: string, overrides: Partial = {}): CalendarTask { + return { + id, + '@type': 'Task', + uid: `uid-${id}`, + title: `Task ${id}`, + description: '', + due: null, + showWithoutTime: true, + priority: 0, + progress: 'needs-action', + calendarIds: { 'cal-1': true }, + alerts: null, + ...overrides, + } as unknown as CalendarTask; +} + +const dispatch = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + dispatch.mockClear(); + setActionDispatcher(dispatch); +}); + +afterEach(() => { + cleanup(); + clearSlotOffers(); + resetActions(); + setActionDispatcher(null); +}); + +describe('task-detail-section', () => { + it('shows an open task to the plugin', () => { + offerSlots('p1', ['task-detail-section']); + render(); + + const frame = screen.getByTestId('plugin-frame-task-detail-section'); + expect(framePropsOf(frame).task).toMatchObject({ id: 't1', uid: 'uid-t1', title: 'Task t1', priority: 5, calendarId: 'cal-1', isComplete: false }); + }); + + it('is not shown for a new task or without a plugin', () => { + offerSlots('p1', ['task-detail-section']); + const { unmount } = render(); + expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull(); + unmount(); + + clearSlotOffers(); + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('task-list-actions', () => { + const toolbar = { + filter: 'all' as const, + showCompleted: false, + onFilterChange: vi.fn(), + onShowCompletedChange: vi.fn(), + onCreateTask: vi.fn(), + }; + + it('puts plugin buttons after the create button and sends the listed task ids', async () => { + act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }])); + render(); + + const button = screen.getByRole('button', { name: 'Plan my day' }); + expect(screen.getByText('tasks.create').compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('pm', 'task-list-actions', 'plan', { taskIds: ['t2', 't1'] }); + }); + + it('draws icon-only buttons when compact', () => { + act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }])); + render(); + expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day'); + }); + + it('renders nothing without a plugin', () => { + render(); + expect(document.querySelector('[data-plugin-actions]')).toBeNull(); + }); + + it('lists the ids the task list shows, in its order', () => { + const tasks = [ + makeTask('done', { progress: 'completed' }), + makeTask('later', { due: '2030-01-02' }), + makeTask('other-cal', { calendarIds: { 'cal-2': true } }), + makeTask('soon', { due: '2030-01-01' }), + ]; + expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']); + expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 9d5b6e4e6..d3b17fe01 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -25,7 +25,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; -import { TaskListView } from "@/components/calendar/task-list-view"; +import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view"; import { TaskToolbar } from "@/components/calendar/task-toolbar"; import { TaskModal } from "@/components/calendar/task-modal"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; @@ -97,6 +97,8 @@ import { findMeetingLink } from "@/lib/event-links"; import { useDocumentTitle } from "@/hooks/use-document-title"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; +import { calendarHooks } from "@/lib/plugin-hooks"; +import { eventToView } from "@/lib/plugin-projection"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -707,6 +709,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setDetailPinned(true); setDetailEvent(event); setDetailAnchorRect(anchorRect); + // A click opens the event's card; the hover preview does not count as + // opening it. The editor reports its own opens (event-modal). + void calendarHooks.onCalendarEventOpen.emit(eventToView(event)); }, [isMobile, closeDetail, openEditModal]); const { @@ -1206,7 +1211,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = delete (newEventData as Record).uid; delete (newEventData as Record).recurrenceId; try { - await createEvent(client, newEventData, sendScheduling); + // null when the save failed or a plugin stopped it: either way + // the truncated master has to be restored. + const created = await createEvent(client, newEventData, sendScheduling); + if (!created) throw new Error("The new series was not created"); } catch (createError) { debug.error("Failed to create new series, rolling back master truncation:", createError); try { @@ -1480,6 +1488,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return filtered; }, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]); + // The tasks the task list shows; plugin buttons in its toolbar act on them. + const listedTaskIds = useMemo( + () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id), + [taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted], + ); + // Tasks on the month, week and day grids follow the calendar filter like // the events do (#1107). const calendarTasks = useMemo(() => { @@ -1671,6 +1685,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onFilterChange={taskStore.setFilter} onShowCompletedChange={taskStore.setShowCompleted} onCreateTask={openCreateTaskModal} + taskIds={listedTaskIds} + compact={isMobile} /> {format(day, "d")}
+
{hasAllDayArea && ( diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 31ba69a59..cd413898a 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useScrollWindow } from "@/hooks/use-scroll-window"; import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarMonthViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -379,6 +380,8 @@ export function CalendarMonthView({ > {formatDayNumber(day)} + {/* Phones have no room for the text: a dot stands in for it. */} +
{isMobile && !showChips ? (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 2a342a034..d7f63c9d4 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { readableTextOn } from "@/lib/color-transform"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarWeekViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -436,6 +437,7 @@ export function CalendarWeekView({ > {format(day, "d")} +
); })} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index e7c573d4c..4ab0eba77 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { eventToView } from "@/lib/plugin-projection"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -52,13 +54,18 @@ const POPOVER_GAP = 8; const VIEWPORT_MARGIN = 12; const MAX_HEIGHT = 480; +/** As tall as MAX_HEIGHT, but never taller than the window. */ +function maxPopoverHeight(): number { + return Math.max(0, Math.min(MAX_HEIGHT, window.innerHeight - 2 * VIEWPORT_MARGIN)); +} + function computePosition( anchorRect: DOMRect, popoverHeight: number ): { top: number; left: number } { const vw = window.innerWidth; const vh = window.innerHeight; - const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT); + const clampedHeight = Math.min(popoverHeight, maxPopoverHeight()); const clampTop = (top: number) => Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN); @@ -145,6 +152,7 @@ export function EventDetailPopover({ // Invitations (Teams above all) leave virtualLocations empty and bury the // join URL in the description; surface it as the meeting link anyway. const meeting = useMemo(() => findMeetingLink(event), [event]); + const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]); const virtualLocation = meeting?.uri ?? null; // A URL opens as a link, a location that only names the service joins the @@ -223,6 +231,19 @@ export function EventDetailPopover({ if (!ready) requestAnimationFrame(() => setReady(true)); }, [anchorRect, noteExpanded, showDeleteConfirm, ready]); + // Content that grows after the popover was placed (a plugin section reports + // its height a moment later) moves it again, so it stays in the window. + useEffect(() => { + const node = popoverRef.current; + if (!node || isMobile || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + const next = computePosition(anchorRect, node.offsetHeight); + setPosition((prev) => (prev && prev.top === next.top && prev.left === next.left ? prev : next)); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [anchorRect, isMobile]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -299,14 +320,14 @@ export function EventDetailPopover({ "fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out", isMobile ? "inset-0 rounded-none flex flex-col" - : "rounded-lg" + : "rounded-lg flex flex-col" )} style={isMobile ? { opacity: 1, transform: "none", } : { width: POPOVER_WIDTH, - maxHeight: MAX_HEIGHT, + maxHeight: maxPopoverHeight(), top: position?.top ?? -9999, left: position?.left ?? -9999, opacity: ready ? 1 : 0, @@ -410,7 +431,7 @@ export function EventDetailPopover({ {/* Content */}
{/* Date & Time */}
@@ -580,6 +601,8 @@ export function EventDetailPopover({

)} + +
{/* Quick Note */} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 340ba2e52..6a0b5b4f3 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -39,7 +39,8 @@ import { calendarHooks, type CalendarEventFormSavePatch, } from "@/lib/plugin-hooks"; -import type { ConflictWarning } from "@/lib/plugin-types"; +import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types"; +import { eventToView } from "@/lib/plugin-projection"; export interface PendingEventPreview { start: Date; @@ -418,6 +419,82 @@ export function EventModal({ return () => { cancelled = true; clearTimeout(t); }; }, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]); + // Plugins hear about an existing event opened for reading once per event, + // and about the form each time it opens (new event, or view -> edit). + const reportedOpenRef = useRef(null); + useEffect(() => { + if (!event || mode !== "view" || reportedOpenRef.current === event.id) return; + reportedOpenRef.current = event.id; + void calendarHooks.onCalendarEventOpen.emit(eventToView(event)); + }, [event, mode]); + + useEffect(() => { + if (mode !== "edit" || calendarFormHooks.onCalendarEventFormOpen.size === 0) return; + void calendarFormHooks.onCalendarEventFormOpen.emit({ + title, + description, + start: allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`, + end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`, + isAllDay: allDay, + location, + virtualLocation, + calendarId, + uid: event?.uid, + attendees: attendees.map((a) => a.email), + }); + // Only the opening counts; the fields are read as they are at that moment. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + // calendar-event-detail: the event as plugins see it, in the read views. + const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]); + + // calendar-event-form: the form's current state in the same shape. Typing + // would hand the plugin iframe new props on every key, so the view settles + // for half a second first. + const formEventView = useMemo(() => { + const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`; + const start = new Date(startStr); + let duration = event?.duration ?? "PT1H"; + if (!isNaN(start.getTime())) { + if (allDay) { + const inclusiveEnd = new Date(`${endDate}T00:00:00`); + if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd); + } else { + const end = new Date(`${endDate}T${endTime || "00:00"}:00`); + if (!isNaN(end.getTime())) duration = buildDuration(start, end); + } + } + const base = event ? eventToView(event) : null; + return { + id: event?.id ?? "", + uid: event?.uid ?? "", + calendarId, + title, + description, + start: startStr, + duration, + timeZone: getEffectiveTimeZone(), + utcStart: null, + utcEnd: null, + isAllDay: allDay, + location, + status: base?.status ?? "confirmed", + participants: attendees.map((a) => { + const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase()); + return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" }; + }), + isRecurring: recurrence !== "none", + keywords: base?.keywords ?? [], + }; + }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]); + const [settledFormEvent, setSettledFormEvent] = useState(formEventView); + useEffect(() => { + const timer = setTimeout(() => setSettledFormEvent(formEventView), 500); + return () => clearTimeout(timer); + }, [formEventView]); + const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]); + // Report live preview to parent for grid outline useEffect(() => { if (!onPreviewChange || isEdit) return; @@ -795,6 +872,8 @@ export function EventModal({ )} + + {eventDetailProps && } @@ -972,6 +1051,8 @@ export function EventModal({

)} + + {eventDetailProps && } @@ -1319,6 +1400,8 @@ export function EventModal({ )} + + diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index 03bc55dc8..cd7a9e8bc 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils"; import { displayNow } from "@/lib/timezone"; import type { CalendarEvent } from "@/lib/jmap/types"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { PluginDayLabel } from "./plugin-day-label"; type PickerView = "days" | "months" | "years"; @@ -199,6 +200,7 @@ export function MiniCalendar({ {hasEvent && !selected && !today && ( )} + ); diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx new file mode 100644 index 000000000..5fc22e768 --- /dev/null +++ b/components/calendar/plugin-day-label.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { cn } from "@/lib/utils"; +import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels"; + +const NONE: RegisteredDayLabel[] = []; +const EMPTY_MAP = new Map(); + +/** The labels plugins put on one day (api.ui.setDayLabels). */ +export function useDayLabels(date: string): RegisteredDayLabel[] { + const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP); + return byDate.get(date) ?? NONE; +} + +function tooltip(labels: RegisteredDayLabel[]): string { + return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n"); +} + +interface PluginDayLabelProps { + /** + * calendar-day-header: the label text under a day's name (day, week and + * month views). mini-calendar-marks: a dot on the day in the small month + * calendar; the parent must be positioned. + */ + surface: "calendar-day-header" | "mini-calendar-marks"; + /** YYYY-MM-DD */ + date: string; + className?: string; + /** + * calendar-day-header only: no room for text (the phone month view), so a + * dot stands for the day's labels; their text is its tooltip and is read + * out by screen readers. + */ + compact?: boolean; +} + +/** What plugins put on a calendar day. Renders nothing without labels. */ +export function PluginDayLabel({ surface, date, className, compact }: PluginDayLabelProps) { + const labels = useDayLabels(date); + + if (surface === "mini-calendar-marks") { + const marks = labels.filter((l) => l.dot); + if (marks.length === 0) return null; + return ( + <> +