From fe3144a390dfb8cf97602863c347f79169161c02 Mon Sep 17 00:00:00 2001 From: jherforth Date: Fri, 23 Oct 2026 17:30:21 -0400 Subject: [PATCH] fix(plugins): isolate plugins from HomeGlow and from each other Reported in mrramam/HomeGlow#50, #49 and #62 (the CORS part). Plugin frames were sandboxed with allow-same-origin and served from HomeGlow's origin, and the API allowed any origin, so a plugin could do anything the Admin Panel can: write settings (the proxy whitelist included), export the database and its key, stage a restore, restart the server, and use another plugin's storage, notifications or Home Assistant panel by naming its id. - Opaque sandbox: plugin frames (and the Admin preview) drop allow-same-origin. The dashboard posts to them with "*" and accepts their messages from origin "null", still from its own frame only. - A token per page: /widgets/:filename injects a frozen identity and a frame script (utils/pluginFrameShims.js) carrying an HMAC token for "plugin:" (or "widget:"), signed with PLUGIN_TOKEN_SECRET (made on first use, redacted). The script adds it to fetch and XHR calls to HomeGlow's own host only, so plugins and the SDK need no change. - One request policy (utils/pluginRequestPolicy.js): a request with Origin: null needs a valid token, and reaches only its own /api/plugin/v1/{storage,settings,ha,notify}/ routes, the core reads and the four household writes the plugin guide documents, the proxy, and theme files (no token: FontFace can't add one). Everything else answers 403. calendar-sources leaves out url and username for plugins. - CORS: @fastify/cors answers per request instead of "*": a plugin's allowed requests, the dev server, and a new CORS_ORIGINS list. The proxy and the event stream no longer set "*" by hand. - localStorage: the frame script puts a per-plugin stand-in in its place (the real one throws in an opaque origin), seeded through the frame's name and kept by the dashboard per plugin and display (client/src/utils/pluginLocalStore.js). The published plugins' old keys are carried over once per display, for the plugins installed then only. Co-Authored-By: Claude Opus 5.5 --- client/src/app.jsx | 7 +- client/src/components/AdminPanel.jsx | 6 +- client/src/components/PluginWidgetWrapper.jsx | 49 ++-- client/src/utils/pluginLocalStore.js | 146 ++++++++++++ client/src/utils/pluginLocalStore.test.js | 95 ++++++++ docs/architecture/plugin-platform.md | 83 ++++--- docs/guides/custom-widgets.md | 16 +- docs/guides/plugin-development.md | 67 +++++- docs/reference/configuration.md | 1 + server/index.js | 81 +++++-- server/plugin-sdk/v1.js | 4 +- server/tests/haPanelRoutes.test.js | 2 +- server/tests/pluginIsolation.test.js | 209 ++++++++++++++++++ server/tests/pluginPlatform.test.js | 15 +- server/tests/pluginRequestPolicy.test.js | 110 +++++++++ server/tests/themeStore.test.js | 8 +- server/utils/pluginFrameShims.js | 72 ++++++ server/utils/pluginRequestPolicy.js | 184 +++++++++++++++ 18 files changed, 1070 insertions(+), 85 deletions(-) create mode 100644 client/src/utils/pluginLocalStore.js create mode 100644 client/src/utils/pluginLocalStore.test.js create mode 100644 server/tests/pluginIsolation.test.js create mode 100644 server/tests/pluginRequestPolicy.test.js create mode 100644 server/utils/pluginFrameShims.js create mode 100644 server/utils/pluginRequestPolicy.js diff --git a/client/src/app.jsx b/client/src/app.jsx index 222ab367..cfaede42 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -11,6 +11,7 @@ import TabBar from './components/TabBar.jsx'; import ScreensaverCountdown from './components/ScreensaverCountdown.jsx'; import UpdateIndicator from './components/UpdateIndicator.jsx'; import { API_BASE_URL } from './utils/apiConfig.js'; +import { migrateLegacyPluginStorage } from './utils/pluginLocalStore.js'; import { getDeviceApiBase, getDeviceName } from './utils/deviceName.js'; import { refreshSubscription } from './utils/pushNotifications.js'; import { unlockAudio } from './utils/choreSound.js'; @@ -326,7 +327,11 @@ const App = () => { const fetchInstalledPlugins = async () => { try { const response = await axios.get(`${API_BASE_URL}/api/widgets`); - setInstalledPlugins(Array.isArray(response.data) ? response.data : []); + const plugins = Array.isArray(response.data) ? response.data : []; + // Before any plugin frame is drawn: carry the plugins' old localStorage + // into their own stores, once per display (utils/pluginLocalStore.js). + migrateLegacyPluginStorage(plugins); + setInstalledPlugins(plugins); } catch { setInstalledPlugins([]); } diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index 0a9cef47..64de69bc 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -103,6 +103,7 @@ import { ADMIN_LAYOUT, ADMIN_TABS, normalizeAdminLocation, parseAdminHash, setAd import SoundPicker from './SoundPicker'; import ControlsOnDisplay from './ControlsOnDisplay'; import ExpandWidgetsSettings from './ExpandWidgetsSettings'; +import { frameNameFor } from '../utils/pluginLocalStore.js'; import useFetchTabs from '../hooks/useFetchTabs.js'; import { MAX_DOCK_HIDE_MINUTES, screensaverPreemptsDock } from '../utils/dockIdleHide.js'; import useIsMobile from '../hooks/useIsMobile.js'; @@ -2539,7 +2540,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ // it, so a plugin whose own UI saves one would fail here // while working fine on the dashboard. src={`${API_BASE_URL}/widgets/${encodeURIComponent(plugin.filename)}?theme=${document.documentElement.getAttribute('data-theme') || 'light'}&device=${encodeURIComponent(currentDeviceName)}&lang=${i18n.language || 'en'}`} - sandbox="allow-scripts allow-same-origin" + // Opaque, like the dashboard's frames (PluginWidgetWrapper): + // a plugin can't act as HomeGlow from here either. + sandbox="allow-scripts allow-forms" + name={frameNameFor(plugin.filename)} style={{ width: '100%', height: '100%', border: 0, display: 'block', backgroundColor: 'var(--card-bg)' }} /> diff --git a/client/src/components/PluginWidgetWrapper.jsx b/client/src/components/PluginWidgetWrapper.jsx index 68ddf84b..3801c2f0 100644 --- a/client/src/components/PluginWidgetWrapper.jsx +++ b/client/src/components/PluginWidgetWrapper.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useId, useRef } from 'react'; +import React, { useCallback, useContext, useEffect, useId, useMemo, useRef } from 'react'; import { Box } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { API_BASE_URL } from '../utils/apiConfig.js'; @@ -10,6 +10,16 @@ import { THEME_TOKENS_APPLIED_EVENT, buildPluginThemeMessage, readRoleTokens } f import { ThemeContext } from '../themes/engine/ThemeContext.js'; import { acceptFrameTouchMessage, buildFrameTouchSettingsMessage, emitFrameTouch } from '../utils/twoFingerSwipe.js'; import { acceptFrameExpandMessage, buildFrameExpandMessage, emitFrameExpand } from '../utils/widgetExpand.js'; +import { LOCAL_STORAGE_MESSAGE_TYPE, applyPluginStoreChange, frameNameFor } from '../utils/pluginLocalStore.js'; + +// Plugin frames are sandboxed without allow-same-origin (mrramam/HomeGlow#50): +// the plugin's page gets an opaque origin, so it can't read this page, its +// storage or its API as HomeGlow, wherever it was served from. Messages from +// it say they come from "null"; messages to it can't name its origin, so they +// go to "*". The source check (this wrapper's own frame) is the real gate. +export const PLUGIN_FRAME_SANDBOX = 'allow-scripts allow-forms allow-popups'; +const FRAME_ORIGIN = 'null'; +const TO_FRAME = '*'; const PluginWidgetWrapper = ({ filename, @@ -44,12 +54,8 @@ const PluginWidgetWrapper = ({ // each gets its own swipe detector. The same plugin can be on the board twice. const touchSourceKey = useId(); - // The iframe's document comes from API_BASE_URL, which is NOT this page's - // origin in dev mode / split-origin deployments — postMessage must target - // the iframe's actual origin or the browser silently drops the message. - const iframeOrigin = API_BASE_URL - ? new URL(API_BASE_URL, window.location.href).origin - : window.location.origin; + // The origin messages from the frame carry: opaque, so always "null". + const iframeOrigin = FRAME_ORIGIN; // Forward core events this plugin declared in its manifest into the iframe // (issue #105 Phase 3). Legacy widgets declare nothing and skip the stream. @@ -65,10 +71,10 @@ const PluginWidgetWrapper = ({ if (!target) return; target.postMessage( { type: 'homeglow:event', event: message.event, payload: message.payload, emittedAt: message.emittedAt }, - iframeOrigin + TO_FRAME ); }); - }, [eventsKey, iframeOrigin]); + }, [eventsKey]); // The reverse direction: a plugin that wrote core data through the REST API // tells the host, so widgets showing that data can refetch instead of @@ -80,6 +86,11 @@ const PluginWidgetWrapper = ({ useEffect(() => { const onMessage = (event) => { const frame = iframeRef.current?.contentWindow; + // The plugin's localStorage (its stand-in posts each change here). + if (frame && event.source === frame && event.origin === iframeOrigin && event.data?.type === LOCAL_STORAGE_MESSAGE_TYPE) { + applyPluginStoreChange(filename, event.data); + return; + } // HomeGlow.celebrate(): the board's celebration, which CelebrationHost // rate-limits per plugin. if (acceptPluginCelebrateMessage(event, frame, iframeOrigin)) { @@ -124,8 +135,8 @@ const PluginWidgetWrapper = ({ if (!target) return; const { theme: mode, colors: picked, fonts } = sendRef.current; const message = buildPluginThemeMessage(mode, picked, { tokens: readRoleTokens(document.documentElement), fonts }); - target.postMessage(message, iframeOrigin); - }, [iframeOrigin]); + target.postMessage(message, TO_FRAME); + }, []); useEffect(() => { const frame = requestAnimationFrame(postTheme); return () => cancelAnimationFrame(frame); @@ -144,9 +155,9 @@ const PluginWidgetWrapper = ({ if (!target) return; target.postMessage( buildFrameTouchSettingsMessage({ swipe: swipeOn, blockPinchZoom: pinchBlocked, expand: expandMode }), - iframeOrigin + TO_FRAME ); - }, [iframeOrigin, swipeOn, pinchBlocked, expandMode]); + }, [swipeOn, pinchBlocked, expandMode]); useEffect(() => { postTouch(); }, [postTouch]); @@ -154,8 +165,8 @@ const PluginWidgetWrapper = ({ const postExpand = useCallback(() => { const target = iframeRef.current?.contentWindow; if (!target) return; - target.postMessage(buildFrameExpandMessage(expandSize), iframeOrigin); - }, [iframeOrigin, expandSize]); + target.postMessage(buildFrameExpandMessage(expandSize), TO_FRAME); + }, [expandSize]); useEffect(() => { postExpand(); }, [postExpand]); @@ -170,6 +181,11 @@ const PluginWidgetWrapper = ({ // unchanged hidden set produces a byte-identical src — changing src navigates // the frame, which is what makes a newly hidden control take effect, and is // waste when a settings refetch merely handed us an equal array. + // The plugin's saved localStorage on this display, read when the frame is + // made (again on a refresh), for its stand-in to start from. + // eslint-disable-next-line react-hooks/exhaustive-deps + const frameName = useMemo(() => frameNameFor(filename), [filename, refreshNonce]); + const hideParam = (Array.isArray(hiddenControls) ? hiddenControls : []) .map((id) => encodeURIComponent(id)) .join(','); @@ -188,6 +204,7 @@ const PluginWidgetWrapper = ({ // ignore it are unaffected. src={`${API_BASE_URL}/widgets/${filename}?theme=${theme}&device=${encodeURIComponent(deviceName)}&lang=${i18n.language || 'en'}${hideParam ? `&hide=${hideParam}` : ''}${transparentBackground ? '&transparent=true' : ''}`} title={name} + name={frameName} style={{ width: '100%', height: '100%', @@ -200,7 +217,7 @@ const PluginWidgetWrapper = ({ borderRadius: 'max(0px, calc(var(--hg-frame-radius) - var(--hg-frame-inset) - 3px))', overflow: 'hidden', }} - sandbox="allow-scripts allow-same-origin allow-forms allow-popups" + sandbox={PLUGIN_FRAME_SANDBOX} /> ); diff --git a/client/src/utils/pluginLocalStore.js b/client/src/utils/pluginLocalStore.js new file mode 100644 index 00000000..3dedffe2 --- /dev/null +++ b/client/src/utils/pluginLocalStore.js @@ -0,0 +1,146 @@ +// Each plugin's own localStorage, kept by the dashboard (mrramam/HomeGlow#50). +// +// Plugin frames have an opaque origin, where localStorage throws, so the +// script HomeGlow serves every plugin page with (server/utils/pluginFrameShims.js) +// puts a stand-in in its place. This is the other half: the dashboard keeps +// each plugin's keys under its own entry in this display's localStorage, hands +// them to the frame in its `name` when it loads, and saves the changes the +// frame posts back. One plugin never sees another's keys, or the dashboard's. +// +// Plugins used to share the dashboard's own localStorage, so their saved data +// is still there under the keys they used. The first time this version runs +// on a display, it copies the published plugins' keys (LEGACY_PLUGIN_KEYS) +// into their stores, once, for the plugins installed then. A plugin installed +// later gets nothing, so it can't collect what another plugin left behind. +// The old keys stay where they were, for a rollback; no plugin can read them. + +export const PLUGIN_STORE_PREFIX = 'homeglow-plugin-local:'; +export const PLUGIN_STORE_MIGRATED_KEY = 'homeglow-plugin-local-migrated'; +// The same limit the frame's stand-in enforces, in characters. +export const PLUGIN_STORE_MAX_CHARS = 512 * 1024; +export const LOCAL_STORAGE_MESSAGE_TYPE = 'homeglow:local-storage'; + +// The published plugins' old keys (HomeGlowPlugins), by plugin id. A key the +// plugin saved under is taken if it starts with one of these. +export const LEGACY_PLUGIN_KEYS = Object.freeze({ + 'bills-reminder': ['billsReminder_'], + 'family-bucket-list': ['homeglowBucketList'], + 'calendar-agenda': ['homeglow.calendarAgenda.'], + 'family-countdown': ['homeglowCountdowns'], + 'daily-rotation': ['dailyRotation'], + 'emoji-story': ['emojiStory_'], + 'family-contacts': ['familyContacts_'], + 'grill-card': ['grill-card:'], + 'guest-wifi': ['guestWifi_'], + 'healthy-habits': ['habitCompletions', 'habitsSelectedUser', 'healthyHabits'], + 'family-messages': ['familyMessages', 'messageBoardCurrentUser'], + 'rss-reader': ['homeglow.rssReader.'], + 'pixel-reveal': ['pixelReveal_'], + 'family-polls': ['pollWidget_'], + 'routines': ['homeglow.routines.'], + 'school-lunch': ['schoolLunch_'], + 'family-scoreboard': ['homeglowActivities', 'homeglowScores'], + 'ha-vacuum-card': ['homeglow.vacuum.'], + 'wheel-of-choice': ['spinWheel_'], + 'word-guess': ['wotd_'], +}); + +const sizeOf = (map) => Object.entries(map).reduce((total, [key, value]) => total + key.length + value.length, 0); + +const browserStorage = () => { + try { + return window.localStorage; + } catch { + return null; + } +}; + +/** A plugin's saved keys on this display: { key: value }, all strings. */ +export function readPluginStore(filename, storage = browserStorage()) { + if (!storage || !filename) return {}; + try { + const parsed = JSON.parse(storage.getItem(PLUGIN_STORE_PREFIX + filename) || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === 'string')); + } catch { + return {}; + } +} + +function writePluginStore(filename, map, storage) { + try { + storage.setItem(PLUGIN_STORE_PREFIX + filename, JSON.stringify(map)); + return true; + } catch { + return false; // this display's storage is full or blocked: the plugin keeps it in memory + } +} + +/** The frame's `name`: the plugin's saved keys, for its stand-in to start from. */ +export function frameNameFor(filename, storage = browserStorage()) { + return JSON.stringify({ homeglowLocalStorage: readPluginStore(filename, storage) }); +} + +/** + * Apply one change a plugin's frame posted ({ op, key?, value? }). The + * caller has already checked it came from that plugin's own frame. Returns + * true when it was applied. + */ +export function applyPluginStoreChange(filename, change, storage = browserStorage()) { + if (!storage || !filename || !change || typeof change !== 'object') return false; + const map = readPluginStore(filename, storage); + if (change.op === 'clear') { + return writePluginStore(filename, {}, storage); + } + if (typeof change.key !== 'string' || change.key.length > 1024) return false; + if (change.op === 'remove') { + if (!Object.hasOwn(map, change.key)) return false; + delete map[change.key]; + return writePluginStore(filename, map, storage); + } + if (change.op === 'set' && typeof change.value === 'string') { + map[change.key] = change.value; + if (sizeOf(map) > PLUGIN_STORE_MAX_CHARS) return false; + return writePluginStore(filename, map, storage); + } + return false; +} + +/** + * Once per display: copy each installed published plugin's old keys from the + * dashboard's own localStorage into that plugin's store. `plugins` is the + * list from /api/widgets ({ filename, pluginId }). Keys a plugin's store + * already has are kept as they are. + */ +export function migrateLegacyPluginStorage(plugins, storage = browserStorage()) { + if (!storage || !Array.isArray(plugins)) return; + try { + if (storage.getItem(PLUGIN_STORE_MIGRATED_KEY)) return; + } catch { + return; + } + const keys = []; + for (let i = 0; i < storage.length; i += 1) { + const key = storage.key(i); + if (key && !key.startsWith(PLUGIN_STORE_PREFIX)) keys.push(key); + } + for (const plugin of plugins) { + const prefixes = plugin && LEGACY_PLUGIN_KEYS[plugin.pluginId]; + if (!prefixes || typeof plugin.filename !== 'string') continue; + const map = readPluginStore(plugin.filename, storage); + let changed = false; + for (const key of keys) { + if (!prefixes.some((prefix) => key.startsWith(prefix)) || Object.hasOwn(map, key)) continue; + const value = storage.getItem(key); + if (typeof value !== 'string') continue; + map[key] = value; + changed = true; + } + if (changed && sizeOf(map) <= PLUGIN_STORE_MAX_CHARS) writePluginStore(plugin.filename, map, storage); + } + try { + storage.setItem(PLUGIN_STORE_MIGRATED_KEY, new Date().toISOString()); + } catch { + // Not marked: it runs again next time, and keeps what it already copied. + } +} diff --git a/client/src/utils/pluginLocalStore.test.js b/client/src/utils/pluginLocalStore.test.js new file mode 100644 index 00000000..e2c9507f --- /dev/null +++ b/client/src/utils/pluginLocalStore.test.js @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + PLUGIN_STORE_MAX_CHARS, PLUGIN_STORE_MIGRATED_KEY, PLUGIN_STORE_PREFIX, + applyPluginStoreChange, frameNameFor, migrateLegacyPluginStorage, readPluginStore, +} from './pluginLocalStore.js'; + +// A Storage with the same surface the browser's has. +function fakeStorage(initial = {}) { + const data = new Map(Object.entries(initial)); + return { + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => { data.set(k, String(v)); }, + removeItem: (k) => { data.delete(k); }, + key: (i) => [...data.keys()][i] ?? null, + get length() { return data.size; }, + dump: () => Object.fromEntries(data), + }; +} + +describe('a plugin\'s own store', () => { + it('is empty until the plugin saves, then holds what it saved', () => { + const storage = fakeStorage(); + expect(readPluginStore('Grill.html', storage)).toEqual({}); + expect(applyPluginStoreChange('Grill.html', { op: 'set', key: 'grill-card:range', value: '24h' }, storage)).toBe(true); + expect(readPluginStore('Grill.html', storage)).toEqual({ 'grill-card:range': '24h' }); + expect(readPluginStore('Polls.html', storage)).toEqual({}, 'another plugin sees none of it'); + expect(JSON.parse(frameNameFor('Grill.html', storage))).toEqual({ homeglowLocalStorage: { 'grill-card:range': '24h' } }); + }); + + it('removes and clears, and refuses malformed changes', () => { + const storage = fakeStorage(); + applyPluginStoreChange('A.html', { op: 'set', key: 'a', value: '1' }, storage); + applyPluginStoreChange('A.html', { op: 'set', key: 'b', value: '2' }, storage); + expect(applyPluginStoreChange('A.html', { op: 'remove', key: 'a' }, storage)).toBe(true); + expect(readPluginStore('A.html', storage)).toEqual({ b: '2' }); + expect(applyPluginStoreChange('A.html', { op: 'set', key: 'n', value: 5 }, storage)).toBe(false); + expect(applyPluginStoreChange('A.html', { op: 'grant', key: 'x' }, storage)).toBe(false); + expect(applyPluginStoreChange('A.html', null, storage)).toBe(false); + expect(applyPluginStoreChange('A.html', { op: 'clear' }, storage)).toBe(true); + expect(readPluginStore('A.html', storage)).toEqual({}); + }); + + it('keeps each plugin within its share of the display\'s storage', () => { + const storage = fakeStorage(); + const big = 'x'.repeat(PLUGIN_STORE_MAX_CHARS); + expect(applyPluginStoreChange('A.html', { op: 'set', key: 'k', value: big }, storage)).toBe(false); + expect(readPluginStore('A.html', storage)).toEqual({}); + }); + + it('survives a corrupt entry', () => { + const storage = fakeStorage({ [`${PLUGIN_STORE_PREFIX}A.html`]: '{not json', [`${PLUGIN_STORE_PREFIX}B.html`]: '{"ok":"1","n":2}' }); + expect(readPluginStore('A.html', storage)).toEqual({}); + expect(readPluginStore('B.html', storage)).toEqual({ ok: '1' }); + }); +}); + +describe('carrying the old localStorage over', () => { + const installed = [ + { filename: 'GuestWifi.html', pluginId: 'guest-wifi' }, + { filename: 'Grill.html', pluginId: 'grill-card' }, + { filename: 'Mine.html', pluginId: 'my-own-plugin' }, + ]; + + it('gives each published plugin its own old keys, once', () => { + const storage = fakeStorage({ + guestWifi_networks: '[{"ssid":"Home"}]', + 'grill-card:range': 'all', + 'grill-card:cmp': '{"sid":1}', + homeglow_device_name: 'Kitchen', + themeMode: 'dark', + }); + migrateLegacyPluginStorage(installed, storage); + expect(readPluginStore('GuestWifi.html', storage)).toEqual({ guestWifi_networks: '[{"ssid":"Home"}]' }); + expect(readPluginStore('Grill.html', storage)).toEqual({ 'grill-card:range': 'all', 'grill-card:cmp': '{"sid":1}' }); + expect(readPluginStore('Mine.html', storage)).toEqual({}, 'no table entry: nothing carried'); + expect(storage.getItem(PLUGIN_STORE_MIGRATED_KEY)).toBeTruthy(); + expect(storage.getItem('guestWifi_networks')).toBe('[{"ssid":"Home"}]', 'the original stays, for a rollback'); + for (const store of ['GuestWifi.html', 'Grill.html']) { + expect(readPluginStore(store, storage)).not.toHaveProperty('homeglow_device_name'); + } + }); + + it('never runs again, so a plugin installed later gets nothing', () => { + const storage = fakeStorage({ guestWifi_networks: 'old' }); + migrateLegacyPluginStorage([], storage); + migrateLegacyPluginStorage(installed, storage); + expect(readPluginStore('GuestWifi.html', storage)).toEqual({}); + }); + + it('keeps what a plugin\'s store already has', () => { + const storage = fakeStorage({ guestWifi_networks: 'old', [`${PLUGIN_STORE_PREFIX}GuestWifi.html`]: '{"guestWifi_networks":"new"}' }); + migrateLegacyPluginStorage(installed, storage); + expect(readPluginStore('GuestWifi.html', storage)).toEqual({ guestWifi_networks: 'new' }); + }); +}); diff --git a/docs/architecture/plugin-platform.md b/docs/architecture/plugin-platform.md index 063884e7..eb095060 100644 --- a/docs/architecture/plugin-platform.md +++ b/docs/architecture/plugin-platform.md @@ -28,8 +28,8 @@ deliberately minimal: | **Format** | A single `.html` file uploaded via Admin Panel → Dashboard → Plugins, or installed from the `jherforth/HomeGlowPlugins` GitHub repo. | | **Storage** | ~~`server/widgets/.html` + `widgets_registry.json`~~ → now the `plugins` table in `tasks.db` (§10, implemented). | | **Serving** | `GET /widgets/:filename` — backend rewrites `localhost:PORT` to the live origin and injects an overflow-fix `