feat(themes): free-form community themes with a security floor (#1015)

* feat(themes): free-form community themes with a security floor

Community themes are no longer token-only. The in-app guard
(validateThemeCss) now enforces only a security floor — no network
(@import / non-data url()), no scripts (<style>/<script>/expression()/
javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-,
and a 256 KB cap — and otherwise allows any selectors, structure and
animations. validateThemePackage checks the manifest plus that floor.

Themes can react to app state via same-element attributes set on the theme
root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open.

The local-import confirm dialog now notes that imported themes aren't
reviewed and are installed at the user's own risk. Removes the now-unused
bundled token whitelist.

* docs(themes): note free-form themes in the Theme Store entry

Add PR #1015 and a free-form bullet to the still-unreleased Theme Store
changelog and credits entry.
This commit is contained in:
Psychotoxical
2026-06-07 14:04:38 +02:00
committed by GitHub
parent aad1a6c3f0
commit 23f032b274
19 changed files with 170 additions and 335 deletions
+3 -2
View File
@@ -56,11 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Themes — community Theme Store ### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews. * New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline. * The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is fully validated against the theme contract, you confirm its name and author, then it installs like any other community theme. * **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top. * The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light). * Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
+21
View File
@@ -1,5 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { useLyricsStore } from './store/lyricsStore';
import { useThemeStore } from './store/themeStore'; import { useThemeStore } from './store/themeStore';
import { useInstalledThemesStore } from './store/installedThemesStore'; import { useInstalledThemesStore } from './store/installedThemesStore';
import { syncInjectedThemes } from './utils/themes/themeInjection'; import { syncInjectedThemes } from './utils/themes/themeInjection';
@@ -34,6 +36,25 @@ export default function App() {
document.documentElement.setAttribute('data-theme', effectiveTheme); document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]); }, [effectiveTheme]);
// Expose app state on the theme root so themes can react to it with a
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
// The set of allowed state attributes is the contract's `stateSelectors`.
// (Sidebar-collapsed is set in AppShell, where that state lives.)
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
}, [isPlaying]);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
useEffect(() => {
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
}, [isFullscreenOpen]);
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
useEffect(() => {
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
}, [lyricsOpen]);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-font', font); document.documentElement.setAttribute('data-font', font);
}, [font]); }, [font]);
+7
View File
@@ -189,6 +189,13 @@ export function AppShell() {
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar); return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
}, [isSidebarCollapsed, setSidebarCollapsed]); }, [isSidebarCollapsed, setSidebarCollapsed]);
// Expose sidebar state on the theme root so themes can react with a
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
// `stateSelectors`). Other state attributes are set in App.tsx.
useEffect(() => {
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
}, [isSidebarCollapsed]);
// Workaround for WebKitGTK 2.50.x text-input repaint hang on // Workaround for WebKitGTK 2.50.x text-input repaint hang on
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in, // Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
// nudge WebKit awake on every input/textarea focus via a sync // nudge WebKit awake on every input/textarea focus via a sync
@@ -132,7 +132,7 @@ export function ThemeImportSection() {
<ConfirmModal <ConfirmModal
open={pending !== null} open={pending !== null}
title={t('settings.themeImportConfirmTitle')} title={t('settings.themeImportConfirmTitle')}
message={pending ? t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author }) : ''} message={pending ? `${t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author })} ${t('settings.themeImportConfirmRisk')}` : ''}
confirmLabel={t('settings.themeStoreInstall')} confirmLabel={t('settings.themeStoreInstall')}
cancelLabel={t('common.cancel')} cancelLabel={t('common.cancel')}
onConfirm={confirmInstall} onConfirm={confirmInstall}
+1 -1
View File
@@ -356,7 +356,7 @@ const CONTRIBUTOR_ENTRIES = [
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)', 'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)', 'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)', 'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import with full contract validation, and 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014)', 'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, and free-form community themes (safety floor + state-reactive styling); 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015)',
], ],
}, },
{ {
+1
View File
@@ -365,6 +365,7 @@ export const settings = {
themeImportErrorDetails: 'Technische Details', themeImportErrorDetails: 'Technische Details',
themeImportConfirmTitle: 'Theme installieren?', themeImportConfirmTitle: 'Theme installieren?',
themeImportConfirmBody: '„{{name}}“ von {{author}} installieren?', themeImportConfirmBody: '„{{name}}“ von {{author}} installieren?',
themeImportConfirmRisk: 'Importierte Themes werden nicht geprüft — installiere nur Themes, denen du vertraust.',
tabLibrary: 'Bibliothek', tabLibrary: 'Bibliothek',
tabServers: 'Server', tabServers: 'Server',
tabLyrics: 'Songtexte', tabLyrics: 'Songtexte',
+1
View File
@@ -432,6 +432,7 @@ export const settings = {
themeImportErrorDetails: 'Technical details', themeImportErrorDetails: 'Technical details',
themeImportConfirmTitle: 'Install theme?', themeImportConfirmTitle: 'Install theme?',
themeImportConfirmBody: 'Install "{{name}}" by {{author}}?', themeImportConfirmBody: 'Install "{{name}}" by {{author}}?',
themeImportConfirmRisk: "Imported themes aren't reviewed — only install themes you trust.",
tabLibrary: 'Library', tabLibrary: 'Library',
tabServers: 'Servers', tabServers: 'Servers',
tabLyrics: 'Lyrics', tabLyrics: 'Lyrics',
+1
View File
@@ -363,6 +363,7 @@ export const settings = {
themeImportErrorDetails: 'Detalles técnicos', themeImportErrorDetails: 'Detalles técnicos',
themeImportConfirmTitle: '¿Instalar tema?', themeImportConfirmTitle: '¿Instalar tema?',
themeImportConfirmBody: '¿Instalar «{{name}}» de {{author}}?', themeImportConfirmBody: '¿Instalar «{{name}}» de {{author}}?',
themeImportConfirmRisk: 'Los temas importados no se revisan: instala solo temas en los que confíes.',
tabLibrary: 'Biblioteca', tabLibrary: 'Biblioteca',
tabServers: 'Servidores', tabServers: 'Servidores',
tabLyrics: 'Letras', tabLyrics: 'Letras',
+1
View File
@@ -361,6 +361,7 @@ export const settings = {
themeImportErrorDetails: 'Détails techniques', themeImportErrorDetails: 'Détails techniques',
themeImportConfirmTitle: 'Installer le thème ?', themeImportConfirmTitle: 'Installer le thème ?',
themeImportConfirmBody: 'Installer « {{name}} » de {{author}} ?', themeImportConfirmBody: 'Installer « {{name}} » de {{author}} ?',
themeImportConfirmRisk: "Les thèmes importés ne sont pas vérifiés — n'installez que des thèmes de confiance.",
tabLibrary: 'Bibliothèque', tabLibrary: 'Bibliothèque',
tabServers: 'Serveurs', tabServers: 'Serveurs',
tabLyrics: 'Paroles', tabLyrics: 'Paroles',
+1
View File
@@ -364,6 +364,7 @@ export const settings = {
themeImportErrorDetails: 'Tekniske detaljer', themeImportErrorDetails: 'Tekniske detaljer',
themeImportConfirmTitle: 'Installere tema?', themeImportConfirmTitle: 'Installere tema?',
themeImportConfirmBody: 'Installere «{{name}}» av {{author}}?', themeImportConfirmBody: 'Installere «{{name}}» av {{author}}?',
themeImportConfirmRisk: 'Importerte temaer blir ikke gjennomgått — installer bare temaer du stoler på.',
tabStorage: 'Frakoblet & Cache', tabStorage: 'Frakoblet & Cache',
inputKeybindingsTitle: 'Tastatursnarveier', inputKeybindingsTitle: 'Tastatursnarveier',
aboutContributorsCount_one: '{{count}} bidrag', aboutContributorsCount_one: '{{count}} bidrag',
+1
View File
@@ -361,6 +361,7 @@ export const settings = {
themeImportErrorDetails: 'Technische details', themeImportErrorDetails: 'Technische details',
themeImportConfirmTitle: 'Thema installeren?', themeImportConfirmTitle: 'Thema installeren?',
themeImportConfirmBody: '"{{name}}" van {{author}} installeren?', themeImportConfirmBody: '"{{name}}" van {{author}} installeren?',
themeImportConfirmRisk: 'Geïmporteerde themas worden niet gecontroleerd — installeer alleen themas die je vertrouwt.',
tabLibrary: 'Bibliotheek', tabLibrary: 'Bibliotheek',
tabServers: 'Servers', tabServers: 'Servers',
tabLyrics: 'Songteksten', tabLyrics: 'Songteksten',
+1
View File
@@ -367,6 +367,7 @@ export const settings = {
themeImportErrorDetails: 'Detalii tehnice', themeImportErrorDetails: 'Detalii tehnice',
themeImportConfirmTitle: 'Instalezi tema?', themeImportConfirmTitle: 'Instalezi tema?',
themeImportConfirmBody: 'Instalezi „{{name}}” de {{author}}?', themeImportConfirmBody: 'Instalezi „{{name}}” de {{author}}?',
themeImportConfirmRisk: 'Temele importate nu sunt verificate — instalează doar teme în care ai încredere.',
tabLibrary: 'Librărie', tabLibrary: 'Librărie',
tabServers: 'Servere', tabServers: 'Servere',
tabLyrics: 'Versuri', tabLyrics: 'Versuri',
+1
View File
@@ -443,6 +443,7 @@ export const settings = {
themeImportErrorDetails: 'Технические подробности', themeImportErrorDetails: 'Технические подробности',
themeImportConfirmTitle: 'Установить тему?', themeImportConfirmTitle: 'Установить тему?',
themeImportConfirmBody: 'Установить «{{name}}» от {{author}}?', themeImportConfirmBody: 'Установить «{{name}}» от {{author}}?',
themeImportConfirmRisk: 'Импортированные темы не проверяются — устанавливайте только темы, которым доверяете.',
tabLibrary: 'Библиотека', tabLibrary: 'Библиотека',
tabServers: 'Серверы', tabServers: 'Серверы',
tabLyrics: 'Тексты песен', tabLyrics: 'Тексты песен',
+1
View File
@@ -360,6 +360,7 @@ export const settings = {
themeImportErrorDetails: '技术细节', themeImportErrorDetails: '技术细节',
themeImportConfirmTitle: '安装主题?', themeImportConfirmTitle: '安装主题?',
themeImportConfirmBody: '安装来自 {{author}} 的“{{name}}”?', themeImportConfirmBody: '安装来自 {{author}} 的“{{name}}”?',
themeImportConfirmRisk: '导入的主题未经审核——请仅安装你信任的主题。',
tabLibrary: '媒体库', tabLibrary: '媒体库',
tabServers: '服务器', tabServers: '服务器',
tabLyrics: '歌词', tabLyrics: '歌词',
@@ -1,90 +0,0 @@
{
"schemaVersion": 1,
"frozenAt": "2026-06-05",
"description": "The community Theme Store contract surface. A community theme.css may set ONLY these custom properties (plus color-scheme) on its single [data-theme='<id>'] selector. Frozen from the real token surface of the built-in themes (B0 semantic-token refactor). --ctp-* (Catppuccin palette) and the global design-system tokens (spacing/radii/elevation-shadows/transitions/fonts) are intentionally NOT part of the contract.",
"colorScheme": {
"required": true,
"values": ["dark", "light"],
"note": "Declared on the same selector (not a custom property). Must match manifest.mode. Drives OS-level form/scrollbar theming and the store dark/light filter."
},
"dataUriTokens": ["--select-arrow"],
"core": {
"--accent": "Primary accent / brand colour.",
"--accent-dim": "Low-alpha accent for fills/backgrounds (e.g. selected row).",
"--accent-glow": "Accent glow / focus halo colour.",
"--accent-2": "Secondary accent — second stop of two-colour gradient flourishes.",
"--bg-app": "Main app background.",
"--bg-sidebar": "Sidebar / navigation background.",
"--bg-card": "Card / panel surface.",
"--bg-hover": "Hover / elevated surface.",
"--bg-elevated": "Highest elevated surface.",
"--bg-player": "Player bar background.",
"--bg-deep": "Deepest background / gradient stop.",
"--bg-glass": "Translucent glass background (use rgba — sits over blurred surfaces).",
"--border": "Default border colour.",
"--border-subtle": "Subtle / hairline border.",
"--border-dropdown": "Border for dropdowns / popovers.",
"--text-primary": "Primary text.",
"--text-secondary": "Secondary text.",
"--text-muted": "Muted / tertiary text.",
"--text-on-accent": "Text/icon colour drawn on top of --accent.",
"--danger": "Error / destructive.",
"--positive": "Success / positive.",
"--warning": "Warning.",
"--highlight": "Highlight / rating colour.",
"--select-arrow": "Dropdown chevron, an inline SVG data-URI. The one token whose value is url(data:...). Recolour by changing the stroke=%23RRGGBB hex inside the URI.",
"--shadow-dropdown": "Drop-shadow colour for dropdowns/popovers (e.g. rgba(0,0,0,0.6))."
},
"optional": {
"--volume-accent": "Volume slider fill (falls back to --accent).",
"--waveform-played": "Waveform — played portion (falls back to --accent).",
"--waveform-unplayed": "Waveform — unplayed portion.",
"--waveform-buffered": "Waveform — buffered portion.",
"--player-title": "Player-bar track title colour (falls back to --text-primary).",
"--player-artist": "Player-bar artist colour (falls back to --text-secondary)."
},
"granular": {
"$comment": "Optional per-region override tokens (added 2026-06-06). Each defaults to a base token in the app, so omitting them all is fine — a theme that sets only the core tokens still looks complete. Set one to recolour that region independently, e.g. give the sidebar its own hover without touching every other hover. Colour values only, same as everything else.",
"--sidebar-item-hover": "Sidebar item hover background.",
"--sidebar-item-active-bg": "Sidebar active item background.",
"--sidebar-item-active-text": "Sidebar active item text/icon.",
"--sidebar-text": "Sidebar item text (resting).",
"--sidebar-trigger-bg": "Sidebar library-scope trigger background.",
"--sidebar-border": "Sidebar borders/dividers.",
"--player-control": "Player-bar transport icon (resting).",
"--player-control-hover": "Player-bar transport icon (hover).",
"--player-control-hover-bg": "Player-bar transport button hover background.",
"--player-time-toggle-hover": "Player-bar time-toggle hover background.",
"--player-time-toggle-active": "Player-bar time-toggle active background.",
"--player-border": "Player-bar top border.",
"--row-hover": "List/track row hover background.",
"--row-playing-bg": "Now-playing row background.",
"--row-playing-text": "Now-playing row title + indicator colour.",
"--row-divider": "Row divider line.",
"--row-text": "Row primary text (track title).",
"--row-text-secondary": "Row secondary text (artist).",
"--row-text-muted": "Row muted text (number, duration).",
"--col-header-text": "List column-header text.",
"--col-resize-active": "Column resize handle when active.",
"--card-hover-border": "Album/artist card hover border.",
"--card-title": "Card title text.",
"--card-subtitle": "Card subtitle/meta text.",
"--card-placeholder-bg": "Card cover placeholder background.",
"--menu-bg": "Dropdown / context-menu / modal background.",
"--menu-item-hover": "Menu item hover background.",
"--menu-item-active-text": "Menu item active/selected text.",
"--menu-divider": "Menu divider line.",
"--modal-bg": "Modal panel background.",
"--modal-scrim": "Modal backdrop scrim.",
"--input-bg": "Input / select background.",
"--input-border": "Input / select border.",
"--input-focus-border": "Input border on focus.",
"--button-hover": "Secondary / ghost button hover background.",
"--slider-track": "Range slider track.",
"--slider-thumb": "Range slider thumb.",
"--slider-thumb-hover": "Range slider thumb on hover.",
"--scrollbar-thumb": "Scrollbar thumb.",
"--scrollbar-thumb-hover": "Scrollbar thumb on hover.",
"--scrollbar-track": "Scrollbar track."
}
}
+37 -24
View File
@@ -1,5 +1,7 @@
/** /**
* Tests for the runtime theme-CSS trust boundary and <head> injection sync. * Tests for the runtime theme-CSS security floor and <head> injection sync.
* Community themes are free-form; the floor only blocks the hard safety
* invariants (network, scripts, breakout, unscoped @keyframes, size).
*/ */
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
@@ -21,56 +23,67 @@ afterEach(() => {
document.head.querySelectorAll(`style[${ATTR}]`).forEach((el) => el.remove()); document.head.querySelectorAll(`style[${ATTR}]`).forEach((el) => el.remove());
}); });
describe('validateThemeCss', () => { describe('validateThemeCss (security floor)', () => {
it('accepts a valid single scoped rule', () => { it('accepts a simple scoped rule', () => {
expect(validateThemeCss(block('dracula'), 'dracula')).not.toBeNull(); expect(validateThemeCss(block('dracula'), 'dracula')).not.toBeNull();
}); });
it('accepts a data: url on the contract (e.g. --select-arrow)', () => { it('accepts free-form selectors and structure', () => {
const css = `html { color: red; } .sidebar { background: #000; } [data-theme='x'] .player-bar { opacity: 0.9; }`;
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('accepts @media and multiple rules', () => {
const css = `${block('x')} @media (min-width: 600px) { .sidebar { width: 200px; } }`;
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('accepts @keyframes namespaced with the theme id, and its animation use', () => {
const css = `@keyframes x-pulse { from { opacity: 1 } to { opacity: 0.5 } } .sidebar { animation: x-pulse 2s infinite; }`;
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('accepts a data: url()', () => {
const css = block('x', `--select-arrow: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E");`); const css = block('x', `--select-arrow: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E");`);
expect(validateThemeCss(css, 'x')).not.toBeNull(); expect(validateThemeCss(css, 'x')).not.toBeNull();
}); });
it('rejects @keyframes not namespaced with the theme id', () => {
expect(validateThemeCss(`@keyframes pulse { from {} to {} } ${block('x')}`, 'x')).toBeNull();
});
it('rejects @import', () => { it('rejects @import', () => {
expect(validateThemeCss(`@import 'evil.css'; ${block('x')}`, 'x')).toBeNull(); expect(validateThemeCss(`@import 'evil.css'; ${block('x')}`, 'x')).toBeNull();
}); });
it('rejects @property (global custom-prop registration)', () => {
const css = `@property --x { syntax: '<color>'; inherits: false; initial-value: red; } ${block('x')}`;
expect(validateThemeCss(css, 'x')).toBeNull();
});
it('rejects a non-data url()', () => { it('rejects a non-data url()', () => {
expect(validateThemeCss(block('x', `--accent: url(https://evil.test/x.png);`), 'x')).toBeNull(); expect(validateThemeCss(block('x', `--accent: url(https://evil.test/x.png);`), 'x')).toBeNull();
}); });
it('rejects </style> breakout', () => { it('rejects </style> / <script> breakout', () => {
expect(validateThemeCss(`${block('x')}</style><script>`, 'x')).toBeNull(); expect(validateThemeCss(`${block('x')}</style><script>`, 'x')).toBeNull();
}); });
it('rejects an unscoped/global selector', () => {
expect(validateThemeCss(':root{ --accent:red; }', 'x')).toBeNull();
expect(validateThemeCss('*{ color:red; }', 'x')).toBeNull();
});
it('rejects a foreign theme id selector', () => {
expect(validateThemeCss(block('other'), 'dracula')).toBeNull();
});
it('rejects more than one rule', () => {
expect(validateThemeCss(`${block('x')} ${block('x', '--bg-app:#000;')}`, 'x')).toBeNull();
});
it('rejects expression() / javascript:', () => { it('rejects expression() / javascript:', () => {
expect(validateThemeCss(block('x', '--accent: expression(alert(1));'), 'x')).toBeNull(); expect(validateThemeCss(block('x', '--accent: expression(alert(1));'), 'x')).toBeNull();
expect(validateThemeCss(block('x', '--accent: javascript:alert(1);'), 'x')).toBeNull(); expect(validateThemeCss(block('x', '--accent: javascript:alert(1);'), 'x')).toBeNull();
}); });
it('rejects an oversized css blob', () => { it('rejects an oversized css blob', () => {
const huge = `[data-theme='x']{ ${'--accent:#ffffff;'.repeat(6000)} }`; const huge = `[data-theme='x']{ ${'--accent:#ffffff;'.repeat(20000)} }`;
expect(huge.length).toBeGreaterThan(64 * 1024); expect(huge.length).toBeGreaterThan(256 * 1024);
expect(validateThemeCss(huge, 'x')).toBeNull(); expect(validateThemeCss(huge, 'x')).toBeNull();
}); });
it('ignores comments when validating', () => { it('ignores comments when validating', () => {
expect(validateThemeCss(`/* hi */ ${block('x')}`, 'x')).not.toBeNull(); expect(validateThemeCss(`/* hi */ ${block('x')}`, 'x')).not.toBeNull();
// comment cannot smuggle a second rule past the single-rule shape // A comment cannot smuggle an @import past the floor.
expect(validateThemeCss(`${block('x')} /* */ ${block('x')}`, 'x')).toBeNull(); expect(validateThemeCss(`${block('x')} /* */ @import 'x';`, 'x')).toBeNull();
}); });
}); });
@@ -102,8 +115,8 @@ describe('syncInjectedThemes', () => {
expect(el?.textContent).toContain('#222'); expect(el?.textContent).toContain('#222');
}); });
it('does not inject invalid css', () => { it('does not inject css that fails the floor', () => {
syncInjectedThemes([mk('a', ':root{ --accent:red; }')]); syncInjectedThemes([mk('a', `@import 'evil.css'; ${block('a')}`)]);
expect(injected()).toHaveLength(0); expect(injected()).toHaveLength(0);
}); });
}); });
+45 -38
View File
@@ -3,58 +3,65 @@ import type { InstalledTheme } from '../../store/installedThemesStore';
/** /**
* Runtime CSS injection for installed community themes. Built-in themes are * Runtime CSS injection for installed community themes. Built-in themes are
* bundled at build time (`src/styles/themes/index.css`); installed ones have no * bundled at build time (`src/styles/themes/index.css`); installed ones have no
* build-time presence, so their `[data-theme='<id>']` block must be injected * build-time presence, so their CSS is injected into <head> at runtime. Each
* into <head> at runtime. Each installed theme gets one * installed theme gets one `<style data-installed-theme="<id>">` element, kept
* `<style data-installed-theme="<id>">` element, kept in sync with the store. * in sync with the store.
*/ */
const ATTR = 'data-installed-theme'; const ATTR = 'data-installed-theme';
// Generous but bounded — a rich free-form theme (animations, an embedded
function escapeRegExp(s: string): string { // data: font/icon) is still small; this matches the import command's CSS cap
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // and keeps one theme from eating the install store's localStorage quota.
} const MAX_CSS_BYTES = 256 * 1024;
/** /**
* Validate one installed theme's CSS against the runtime trust boundary. The * The in-app **security floor** for an installed theme's CSS. Community themes
* repo CI is the authoritative contract check (token whitelist, thumbnail, ); * are free-form (any selectors, structure, `@keyframes`, animations quality
* this is the in-app guard for the structural invariants the app *relies on* to * is handled by store moderation, and sideloaded themes are installed at the
* keep a theme contained important because every installed theme is injected * user's own risk). This guard enforces only the hard safety invariants the app
* into <head> at all times and scoping depends entirely on the selector. * relies on, because every installed theme is injected into <head> at all times:
* *
* A valid theme CSS is **exactly one rule**, scoped **exactly** to this theme's * - can't break out of its `<style>` element (`</style>` / `<script>`),
* `[data-theme='<id>']` selector, with: * - can't pull anything off the network no `@import`, and `url()` may only be
* - no `<style>`/`</style>` (can't break out of the element), * an inline `data:` URI (prevents tracking/exfiltration on every app start),
* - no `@import` / at-rules (the single-rule shape rejects these), * - no `@property` (would register global custom properties that could clash
* - no unscoped/global (`:root`, `html`, `*`) or *foreign* (another id's) * with the app or other themes),
* selector so it can never style anything but its own theme, * - no legacy script-in-CSS vectors (`expression()`, `javascript:`,
* - `url()` only as `data:` (the inline `--select-arrow` SVG), and * `-moz-binding`),
* - no `expression()` / `javascript:`. * - a size cap, and
* - `@keyframes` must be namespaced with the theme id (`<id>-…`) so animations
* from different installed themes can't collide.
* *
* Returns the original CSS if valid, or `null` if it must not be injected. * Returns the original CSS if it passes, or `null` if it must not be injected.
*/ */
export function validateThemeCss(css: string, id: string): string | null { export function validateThemeCss(css: string, id: string): string | null {
if (typeof css !== 'string' || !css) return null; if (typeof css !== 'string' || !css) return null;
// Bound the per-theme localStorage footprint. A real token-only theme is a if (css.length > MAX_CSS_BYTES) return null;
// few KB; this generous cap rejects a pathological/huge file before it can
// eat the install store's quota.
if (css.length > 64 * 1024) return null;
// Strip comments first so they can't smuggle content past the checks. // Strip comments first so they can't smuggle content past the checks.
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '').trim(); const s = css.replace(/\/\*[\s\S]*?\*\//g, '');
if (/<\/?\s*style/i.test(stripped)) return null;
if (/@import/i.test(stripped)) return null; // No element breakout, no remote stylesheet, no global custom-prop registration.
// Exactly one rule, scoped to exactly this theme's selector. The single if (/<\/?\s*(?:style|script)\b/i.test(s)) return null;
// braces-pair shape also rejects at-rules (`@media{…}`), extra rules, and if (/@import\b/i.test(s)) return null;
// unscoped/foreign selectors. if (/@(?:-[a-z]+-)?property\b/i.test(s)) return null;
const selector = `\\[data-theme=(['"])${escapeRegExp(id)}\\1\\]`; // No legacy script-in-CSS vectors.
const match = stripped.match(new RegExp(`^${selector}\\s*\\{([^{}]*)\\}$`)); if (/expression\s*\(/i.test(s) || /javascript:/i.test(s) || /-moz-binding/i.test(s)) return null;
if (!match) return null;
const body = match[2]; // group 1 is the selector quote; group 2 is the declarations // url() may only be an inline data: URI. Match each `url(` and inspect the
if (/expression\s*\(/i.test(body) || /javascript:/i.test(body)) return null; // start of its content — a non-data target never starts with `data:`.
const urls = body.match(/url\(\s*['"]?[^'")]*/gi) || []; const urls = s.match(/url\(\s*['"]?\s*[^'")]*/gi) || [];
for (const u of urls) { for (const u of urls) {
const inner = u.replace(/^url\(\s*['"]?/i, ''); const inner = u.replace(/^url\(\s*['"]?\s*/i, '');
if (!/^data:/i.test(inner)) return null; if (!/^data:/i.test(inner)) return null;
} }
// @keyframes (and vendor-prefixed) must start with `<id>-`.
const prefix = `${id}-`;
const kf = s.matchAll(/@(?:-[a-z]+-)?keyframes\s+([A-Za-z0-9_-]+)/gi);
for (const m of kf) {
if (!m[1].startsWith(prefix)) return null;
}
return css; return css;
} }
+31 -70
View File
@@ -1,32 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { validateThemePackage } from './validateThemePackage'; import { validateThemePackage } from './validateThemePackage';
import tokens from './contract/allowed-tokens.json';
const CORE = Object.keys(tokens.core).filter((k) => !k.startsWith('$')); /** A minimal floor-passing theme.css for `id`. */
function css(id = 'my-theme'): string {
interface CssOpts { return `[data-theme='${id}'] { color-scheme: dark; --accent: #abcdef; }`;
id?: string;
mode?: 'dark' | 'light';
omit?: string;
overrides?: Record<string, string>;
extraDecl?: string;
extraRule?: string;
}
/** Build a complete, contract-valid theme.css (all core tokens), with knobs to
* break exactly one thing per test. */
function buildCss(o: CssOpts = {}): string {
const { id = 'my-theme', mode = 'dark', omit, overrides = {}, extraDecl, extraRule } = o;
const decls = CORE.filter((tok) => tok !== omit).map((tok) => {
if (overrides[tok] !== undefined) return `${tok}: ${overrides[tok]}`;
if (tok === '--select-arrow') {
return `${tok}: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'/>")`;
}
return `${tok}: #abcdef`;
});
if (extraDecl) decls.push(extraDecl);
const rule = `[data-theme='${id}'] {\n color-scheme: ${mode};\n ${decls.join(';\n ')};\n}`;
return extraRule ? `${rule}\n${extraRule}` : rule;
} }
function manifest(over: Record<string, unknown> = {}): string { function manifest(over: Record<string, unknown> = {}): string {
@@ -45,8 +22,8 @@ const hasError = (r: ReturnType<typeof validateThemePackage>, re: RegExp): boole
!r.ok && r.errors.some((e) => re.test(e)); !r.ok && r.errors.some((e) => re.test(e));
describe('validateThemePackage', () => { describe('validateThemePackage', () => {
it('accepts a fully contract-valid package', () => { it('accepts a valid package', () => {
const r = validateThemePackage(manifest(), buildCss()); const r = validateThemePackage(manifest(), css());
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (r.ok) { if (r.ok) {
expect(r.theme.id).toBe('my-theme'); expect(r.theme.id).toBe('my-theme');
@@ -55,76 +32,60 @@ describe('validateThemePackage', () => {
} }
}); });
it('accepts free-form CSS — foreign selectors and namespaced animations', () => {
const freeform = `
[data-theme='my-theme'] { color-scheme: dark; --accent: #abcdef; }
@keyframes my-theme-pulse { from { opacity: 1 } to { opacity: .5 } }
.sidebar { animation: my-theme-pulse 2s infinite; }
[data-theme='my-theme'][data-playing='true'] .player-bar { filter: brightness(1.1); }
`;
expect(validateThemePackage(manifest(), freeform).ok).toBe(true);
});
it('preserves valid tags', () => { it('preserves valid tags', () => {
const r = validateThemePackage(manifest({ tags: ['dark', 'neon'] }), buildCss()); const r = validateThemePackage(manifest({ tags: ['dark', 'neon'] }), css());
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (r.ok) expect(r.theme.tags).toEqual(['dark', 'neon']); if (r.ok) expect(r.theme.tags).toEqual(['dark', 'neon']);
}); });
it('rejects invalid JSON', () => { it('rejects invalid JSON', () => {
const r = validateThemePackage('{ not json', buildCss()); expect(hasError(validateThemePackage('{ not json', css()), /not valid JSON/)).toBe(true);
expect(hasError(r, /not valid JSON/)).toBe(true);
}); });
it('rejects a non-object manifest', () => { it('rejects a non-object manifest', () => {
const r = validateThemePackage('"a string"', buildCss()); expect(hasError(validateThemePackage('"a string"', css()), /must be a JSON object/)).toBe(true);
expect(hasError(r, /must be a JSON object/)).toBe(true);
}); });
it('rejects unknown manifest properties', () => { it('rejects unknown manifest properties', () => {
const r = validateThemePackage(manifest({ evil: true }), buildCss()); expect(hasError(validateThemePackage(manifest({ evil: true }), css()), /unknown property "evil"/)).toBe(true);
expect(hasError(r, /unknown property "evil"/)).toBe(true);
}); });
it('rejects a missing required field', () => { it('rejects a missing required field', () => {
const r = validateThemePackage(manifest({ name: undefined }), buildCss()); expect(hasError(validateThemePackage(manifest({ name: undefined }), css()), /manifest\.name is required/)).toBe(true);
expect(hasError(r, /manifest\.name is required/)).toBe(true);
}); });
it('rejects an id that is not lowercase kebab-case', () => { it('rejects an id that is not lowercase kebab-case', () => {
const r = validateThemePackage(manifest({ id: 'My_Theme' }), buildCss({ id: 'My_Theme' })); const r = validateThemePackage(manifest({ id: 'My_Theme' }), css('My_Theme'));
expect(hasError(r, /kebab-case/)).toBe(true); expect(hasError(r, /kebab-case/)).toBe(true);
}); });
it('rejects an id that collides with a built-in theme', () => { it('rejects an id that collides with a built-in theme', () => {
const r = validateThemePackage(manifest({ id: 'mocha' }), buildCss({ id: 'mocha' })); const r = validateThemePackage(manifest({ id: 'mocha' }), css('mocha'));
expect(hasError(r, /collides with a built-in/)).toBe(true); expect(hasError(r, /collides with a built-in/)).toBe(true);
}); });
it('rejects a missing required core token', () => { it('rejects CSS that reaches the network via @import', () => {
const r = validateThemePackage(manifest(), buildCss({ omit: '--accent' })); const r = validateThemePackage(manifest(), `@import 'https://evil/x.css'; ${css()}`);
expect(hasError(r, /missing required core token --accent/)).toBe(true); expect(hasError(r, /failed the safety check/)).toBe(true);
}); });
it('rejects a token that is not in the whitelist', () => { it('rejects CSS with a non-data url() (containment)', () => {
const r = validateThemePackage(manifest(), buildCss({ extraDecl: '--not-a-real-token: #fff' })); const r = validateThemePackage(manifest(), `[data-theme='my-theme'] { background: url(https://evil/x.png); }`);
expect(hasError(r, /--not-a-real-token is not in the contract whitelist/)).toBe(true); expect(hasError(r, /failed the safety check/)).toBe(true);
}); });
it('rejects color-scheme not matching manifest.mode', () => { it('rejects @keyframes not namespaced with the theme id', () => {
const r = validateThemePackage(manifest({ mode: 'light' }), buildCss({ mode: 'dark' })); const r = validateThemePackage(manifest(), `@keyframes pulse { from {} to {} } ${css()}`);
expect(hasError(r, /must match/)).toBe(true); expect(hasError(r, /failed the safety check/)).toBe(true);
});
it('rejects a data-URI on a token other than --select-arrow', () => {
const r = validateThemePackage(
manifest(),
buildCss({ overrides: { '--accent': 'url("data:image/png;base64,AAAA")' } }),
);
expect(hasError(r, /data-URI values are only allowed on --select-arrow/)).toBe(true);
});
it('rejects an external url() value (containment)', () => {
const r = validateThemePackage(
manifest(),
buildCss({ overrides: { '--bg-app': 'url(https://evil.example/x.png)' } }),
);
// validateThemeCss flags this structurally (only data: URIs are allowed).
expect(hasError(r, /one safe \[data-theme/)).toBe(true);
});
it('rejects more than one rule (structural)', () => {
const r = validateThemePackage(manifest(), buildCss({ extraRule: "[data-theme='other'] { --accent: #000 }" }));
expect(hasError(r, /one safe \[data-theme/)).toBe(true);
}); });
}); });
+15 -109
View File
@@ -1,37 +1,21 @@
import allowedTokens from './contract/allowed-tokens.json';
import { validateThemeCss } from './themeInjection'; import { validateThemeCss } from './themeInjection';
import { FIXED_THEMES } from '../../components/settings/fixedThemes'; import { FIXED_THEMES } from '../../components/settings/fixedThemes';
/** /**
* Full theme-package validation for locally imported themes (a .zip holding * Validation for a locally imported theme package (a .zip holding manifest.json
* manifest.json + theme.css). This is the in-app mirror of the repo CI * + theme.css). Community themes are free-form, so this enforces two things:
* contract check (`scripts/validate-theme.mjs`): it enforces the exact same
* manifest schema and CSS token whitelist, using a byte-identical copy of
* `schema/allowed-tokens.json` and the schema's own field patterns.
* *
* Layering: `validateThemeCss` (themeInjection) is the security/containment * 1. the **manifest** is well-formed (the same field rules as the repo schema),
* guard it guarantees the CSS is a single, scoped, at-rule-free rule with * and its id doesn't collide with a built-in theme, and
* data-URI-only `url()`. Once that holds, the declaration list is a flat * 2. the CSS passes the in-app **security floor** (`validateThemeCss`) no
* `prop: value;` sequence we can extract safely (no nesting) and check against * network/scripts/breakout, data:-only `url()`, id-namespaced `@keyframes`.
* the contract here. The same `validateThemeCss` runs again at injection time, *
* so a theme that slips past this is still contained at the boundary. * Quality, structure, animations and taste are deliberately NOT checked here:
* store themes are vetted by maintainers, and sideloaded themes are installed
* at the user's own risk (the import UI says so). The floor is the safety line.
*/ */
const noMeta = (o: Record<string, unknown>): string[] => // Field patterns copied verbatim from the repo's schema/manifest.schema.json.
Object.keys(o || {}).filter((k) => !k.startsWith('$'));
const CORE = noMeta(allowedTokens.core as Record<string, unknown>);
const ALLOWED = new Set([
...CORE,
...noMeta(allowedTokens.optional as Record<string, unknown>),
...noMeta(allowedTokens.granular as Record<string, unknown>),
]);
const DATA_URI_TOKENS = new Set(allowedTokens.dataUriTokens as string[]);
const SCHEME_VALUES = new Set(allowedTokens.colorScheme.values as string[]);
const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id));
// Field patterns copied verbatim from schema/manifest.schema.json so the
// in-app check stays identical to CI without bundling a JSON-schema engine.
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const AUTHOR_RE = /^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$/; const AUTHOR_RE = /^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$/;
const SEMVER_RE = const SEMVER_RE =
@@ -41,6 +25,7 @@ const TAG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const MANIFEST_KEYS = new Set([ const MANIFEST_KEYS = new Set([
'id', 'name', 'author', 'version', 'description', 'mode', 'tags', 'minAppVersion', 'id', 'name', 'author', 'version', 'description', 'mode', 'tags', 'minAppVersion',
]); ]);
const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id));
export interface ValidatedTheme { export interface ValidatedTheme {
id: string; id: string;
@@ -57,44 +42,6 @@ export type ValidateResult =
| { ok: true; theme: ValidatedTheme } | { ok: true; theme: ValidatedTheme }
| { ok: false; errors: string[] }; | { ok: false; errors: string[] };
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** The declarations inside the single `[data-theme='<id>']` rule, or null. */
function ruleBody(css: string, id: string): string | null {
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
const sel = `\\[data-theme=(['"])${escapeRegExp(id)}\\1\\]`;
const m = stripped.match(new RegExp(`^${sel}\\s*\\{([^{}]*)\\}$`));
return m ? m[2] : null;
}
/**
* Split a flat declaration body on top-level `;` only never inside `()` or a
* quoted string, so a `url("data:image/svg+xml;...")` value (the `;` in the
* MIME type / SVG) stays intact.
*/
function splitDeclarations(body: string): string[] {
const out: string[] = [];
let cur = '';
let depth = 0;
let quote: string | null = null;
for (const ch of body) {
if (quote) {
cur += ch;
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
if (ch === '(') { depth++; cur += ch; continue; }
if (ch === ')') { if (depth > 0) depth--; cur += ch; continue; }
if (ch === ';' && depth === 0) { out.push(cur); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur);
return out.map((s) => s.trim()).filter(Boolean);
}
export function validateThemePackage(manifestText: string, css: string): ValidateResult { export function validateThemePackage(manifestText: string, css: string): ValidateResult {
const errors: string[] = []; const errors: string[] = [];
@@ -165,61 +112,20 @@ export function validateThemePackage(manifestText: string, css: string): Validat
} }
} }
// ---- css ---- // ---- css security floor ----
// The selector check needs a valid id; if the id is bad, skip the CSS rule // Needs a valid id (the @keyframes namespace check is keyed on it).
// checks (they would all fail for the wrong reason) but keep the manifest
// errors above.
const idForCss = id && ID_RE.test(id) ? id : null; const idForCss = id && ID_RE.test(id) ? id : null;
if (idForCss === null) { if (idForCss === null) {
errors.push('theme.css cannot be validated until manifest.id is valid'); errors.push('theme.css cannot be validated until manifest.id is valid');
return { ok: false, errors }; return { ok: false, errors };
} }
if (validateThemeCss(css, idForCss) == null) { if (validateThemeCss(css, idForCss) == null) {
errors.push( errors.push(
`theme.css must be exactly one safe [data-theme='${idForCss}'] rule (no @-rules, no foreign/global selectors, url() may only be a data: URI)`, "theme.css failed the safety check — it may exceed the size limit, reach the network (only data: url() is allowed), use @import / @property / scripts, break out of its <style>, or define @keyframes not namespaced as \"" + idForCss + "-…\"",
); );
return { ok: false, errors }; return { ok: false, errors };
} }
const body = ruleBody(css, idForCss);
if (body === null) {
// Should not happen once validateThemeCss passed, but stay defensive.
errors.push('theme.css declarations could not be read');
return { ok: false, errors };
}
const seen = new Set<string>();
let scheme: string | null = null;
for (const decl of splitDeclarations(body)) {
const idx = decl.indexOf(':');
if (idx < 0) { errors.push(`malformed declaration: "${decl}"`); continue; }
const prop = decl.slice(0, idx).trim();
const value = decl.slice(idx + 1).trim();
if (prop === 'color-scheme') {
scheme = value;
if (!SCHEME_VALUES.has(value)) errors.push(`color-scheme must be one of ${[...SCHEME_VALUES].join(' | ')} (got: ${value})`);
continue;
}
if (!prop.startsWith('--')) { errors.push(`only custom properties and color-scheme are allowed (found: ${prop})`); continue; }
if (!ALLOWED.has(prop)) { errors.push(`token ${prop} is not in the contract whitelist`); continue; }
if (seen.has(prop)) errors.push(`token ${prop} is declared more than once`);
seen.add(prop);
const urls = value.toLowerCase().match(/url\(([^)]*)\)/g) || [];
for (const u of urls) {
if (!/url\(\s*["']?\s*data:/.test(u)) errors.push(`${prop}: only url(data:...) is allowed`);
else if (!DATA_URI_TOKENS.has(prop)) errors.push(`${prop}: data-URI values are only allowed on ${[...DATA_URI_TOKENS].join(', ')}`);
}
}
if (scheme === null) errors.push('color-scheme is required in theme.css');
if (mode && scheme && mode !== scheme) errors.push(`manifest.mode "${mode}" must match theme.css color-scheme "${scheme}"`);
for (const t of CORE) {
if (!seen.has(t)) errors.push(`missing required core token ${t}`);
}
if (errors.length) return { ok: false, errors }; if (errors.length) return { ok: false, errors };
return { return {