mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9c310498 | ||
|
|
4213d8e672 | ||
|
|
59721f9717 | ||
|
|
ab8764a317 | ||
|
|
7e72ae65ad | ||
|
|
f2e878858f | ||
|
|
2ff86c2217 | ||
|
|
5e6278cbc7 | ||
|
|
83952aac0d | ||
|
|
7ec6473112 | ||
|
|
17e6834f74 | ||
|
|
9ed8bf76dd | ||
|
|
1e5e77fb9c | ||
|
|
d0e2c905cb | ||
|
|
c11cde9bb2 |
No files matched your search
@@ -1298,6 +1298,9 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Last recorded error",
|
||||
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
|
||||
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
|
||||
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.manager.details": "View details",
|
||||
"settings.plugins.manager.noDescription": "No description provided.",
|
||||
@@ -1328,6 +1331,25 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
|
||||
"settings.plugins.openView": "Open",
|
||||
"settings.plugins.openManager": "Open plugin manager",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
|
||||
@@ -1298,6 +1298,9 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Último error registrado",
|
||||
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
|
||||
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
|
||||
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
|
||||
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
|
||||
"settings.plugins.manager.details": "Ver detalles",
|
||||
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
|
||||
@@ -1328,6 +1331,25 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
|
||||
"settings.plugins.openView": "Abrir",
|
||||
"settings.plugins.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
|
||||
@@ -1193,6 +1193,9 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
"settings.plugins.market.results": "Плагинов: {count}",
|
||||
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
|
||||
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
|
||||
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
|
||||
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
|
||||
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
|
||||
"settings.plugins.manager.details": "Подробнее",
|
||||
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
|
||||
@@ -1223,6 +1226,25 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
||||
"settings.plugins.openView": "Открыть",
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
|
||||
@@ -1213,6 +1213,9 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
"settings.plugins.market.results": "{count} 个插件",
|
||||
"settings.plugins.manager.lastError": "上次记录的错误",
|
||||
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
|
||||
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
|
||||
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
|
||||
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
|
||||
"settings.plugins.manager.details": "查看详情",
|
||||
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
|
||||
@@ -1243,6 +1246,25 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
||||
"settings.plugins.openView": "打开",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
"settings.plugins.sources.select": "选择来源",
|
||||
"settings.plugins.sources.empty": "选择或添加第三方来源。",
|
||||
"settings.plugins.sources.add": "添加来源",
|
||||
"settings.plugins.sources.remove": "移除来源",
|
||||
"settings.plugins.sources.name": "来源名称",
|
||||
"settings.plugins.sources.address": "HTTPS 来源地址",
|
||||
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
|
||||
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
|
||||
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
|
||||
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
|
||||
"settings.plugins.sources.installConfirm": "确认第三方安装",
|
||||
"settings.plugins.sources.source": "来源",
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
|
||||
@@ -1217,11 +1217,33 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
||||
"settings.plugins.openView": "開啟",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
"settings.plugins.sources.select": "选择来源",
|
||||
"settings.plugins.sources.empty": "选择或添加第三方来源。",
|
||||
"settings.plugins.sources.add": "添加来源",
|
||||
"settings.plugins.sources.remove": "移除来源",
|
||||
"settings.plugins.sources.name": "来源名称",
|
||||
"settings.plugins.sources.address": "HTTPS 来源地址",
|
||||
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
|
||||
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
|
||||
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
|
||||
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
|
||||
"settings.plugins.sources.installConfirm": "确认第三方安装",
|
||||
"settings.plugins.sources.source": "来源",
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
"settings.plugins.manager.lastError": "上次記錄的錯誤",
|
||||
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
|
||||
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
|
||||
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
|
||||
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
|
||||
"settings.plugins.manager.details": "查看詳情",
|
||||
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
|
||||
|
||||
@@ -77,8 +77,8 @@ test('offline snapshot stays browsable, retry clears warning, and operations bin
|
||||
offline = false;
|
||||
await act(async () => state.retryList());
|
||||
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
|
||||
await act(async () => state.install(entry.id));
|
||||
assert.deepEqual(installs, [[entry.id, 'second']]);
|
||||
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
|
||||
export function usePluginCatalog(selectedId: string | null) {
|
||||
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
|
||||
@@ -12,6 +12,7 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
const [readmeAttempt, setReadmeAttempt] = useState(0);
|
||||
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
|
||||
const installing = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
@@ -22,12 +23,18 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog();
|
||||
const list = await bridge.listPluginCatalog(sourceId);
|
||||
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
|
||||
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [listAttempt]);
|
||||
}, [listAttempt, sourceId]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSourceBinding(null);
|
||||
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
|
||||
return () => { active = false; };
|
||||
}, [selectedId, sourceId]);
|
||||
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
@@ -38,15 +45,15 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
|
||||
if (active) setReadmeState({ ...base, status: 'ready', text });
|
||||
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, readmeAttempt]);
|
||||
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError, source,
|
||||
entries, listStatus, listError, source, sourceBinding,
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
@@ -54,14 +61,19 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
busy,
|
||||
retryList: () => setListAttempt((attempt) => attempt + 1),
|
||||
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
|
||||
async install(id: string) {
|
||||
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setBusy(true); setInstallError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
|
||||
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
|
||||
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
|
||||
await bridge.resetPluginSource(id, sourceBinding.id);
|
||||
setSourceBinding(null);
|
||||
}
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
|
||||
finally { installing.current = false; if (mounted.current) setBusy(false); }
|
||||
|
||||
@@ -61,9 +61,14 @@ export function usePluginManager() {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string) => run(async (bridge) => {
|
||||
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
return bridge.uninstallPlugin(id);
|
||||
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
|
||||
await bridge.uninstallPlugin(id);
|
||||
if (clearSource && source) {
|
||||
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
|
||||
await bridge.resetPluginSource(id, source.id);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginSources() {
|
||||
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const reload = useCallback(async () => {
|
||||
const values = await netcattyBridge.require().listPluginSources?.();
|
||||
if (!values) throw new Error('Source management unavailable');
|
||||
setSources(values);
|
||||
}, []);
|
||||
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
|
||||
const mutate = async (operation: () => Promise<unknown>) => {
|
||||
setBusy(true); setError(null);
|
||||
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
|
||||
catch (cause) { setError(String(cause)); return false; }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return { sources, error, busy,
|
||||
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
|
||||
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
|
||||
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
|
||||
};
|
||||
}
|
||||
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
|
||||
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
|
||||
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
|
||||
} as NetcattyPluginCatalogEntry;
|
||||
const catalog = { entries: [entry], source: 'registry', listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
|
||||
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
|
||||
function Browser() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
|
||||
@@ -104,8 +104,28 @@ test('information previews on hover/focus, pins on click and Escape returns focu
|
||||
await act(async () => { healthy.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
|
||||
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
|
||||
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
|
||||
await act(async () => { thirdStatus.focus(); await settle(); });
|
||||
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
|
||||
assert.match(thirdPreview, /provided by the selected third party/);
|
||||
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
|
||||
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
|
||||
assert.equal(document.querySelector('[role="tooltip"] button'), null);
|
||||
await act(async () => { thirdStatus.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
|
||||
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
|
||||
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
|
||||
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
|
||||
await act(async () => { sourceInfo.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
|
||||
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
|
||||
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
|
||||
|
||||
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
|
||||
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
|
||||
})) as NetcattyPluginCatalogEntry[];
|
||||
const catalog = {
|
||||
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
|
||||
} as ReturnType<typeof usePluginCatalog>;
|
||||
|
||||
@@ -42,7 +42,7 @@ test('catalog keeps search during selection/back, switches detail, and displays
|
||||
function Harness() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
|
||||
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
|
||||
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' }, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Harness />); });
|
||||
@@ -158,7 +158,21 @@ test('replacing an installed plugin discloses that its running version will stop
|
||||
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
|
||||
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
|
||||
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true } as NetcattyInstalledPlugin]} /></I18nProvider>);
|
||||
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin]} /></I18nProvider>);
|
||||
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
|
||||
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
|
||||
});
|
||||
|
||||
test('a mirror cannot claim another source installation or offer cross-source replacement', () => {
|
||||
const mirror = { id: 'third-party:mirror', kind: 'third-party' as const, name: 'Netcatty', origin: 'https://mirror.example.com' };
|
||||
const existing = { id: entries[0].id, activeVersion: '1.0.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin;
|
||||
const list = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog}
|
||||
marketSource={mirror} selected={null} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
|
||||
assert.doesNotMatch(list, />Installed</);
|
||||
const downloadable = { ...entries[0], artifact: { url: `${mirror.origin}/assets/${'a'.repeat(64)}.ncpkg`, sha256: 'a'.repeat(64) } };
|
||||
const detail = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, entries: [downloadable] }}
|
||||
marketSource={mirror} selected={downloadable.id} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
|
||||
assert.match(detail, /installed from another source/);
|
||||
assert.match(detail, /https:\/\/plugins.netcatty.app/);
|
||||
assert.match(detail, /disabled=""[^>]*>Install this version/);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PluginStatus } from './PluginStatus';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
|
||||
import { PluginInfo } from './PluginInfo';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
@@ -11,14 +12,15 @@ import { Button } from '../../ui/button';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { PluginReadme } from './PluginReadme';
|
||||
|
||||
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const catalog = usePluginCatalog(selected);
|
||||
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
|
||||
const catalog = usePluginCatalog(selected, source?.id);
|
||||
return <PluginMarketplaceContent catalog={catalog} marketSource={source} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
|
||||
}
|
||||
|
||||
// The browser owns only presentation state; installation and README policy stay in the existing hook.
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
|
||||
marketSource?: NetcattyPluginSource;
|
||||
noVault?: boolean;
|
||||
toolbarContainer?: HTMLElement | null;
|
||||
catalog: ReturnType<typeof usePluginCatalog>;
|
||||
@@ -28,13 +30,19 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [confirmInstall, setConfirmInstall] = useState(false);
|
||||
const [clearPreviousSource, setClearPreviousSource] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const detailRef = useRef<HTMLHeadingElement>(null);
|
||||
const installButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const lastSelection = useRef<string | null>(null);
|
||||
const pageScroll = useRef(0);
|
||||
const entry = catalog.entries.find((item) => item.id === selected);
|
||||
const filtered = filterPluginCatalog(catalog.entries, search);
|
||||
const installedEntry = installed.find((item) => item.id === entry?.id);
|
||||
const sourceId = marketSource?.id ?? 'official';
|
||||
const sameSource = (plugin: NetcattyInstalledPlugin) => (plugin.source?.id ?? 'local') === sourceId;
|
||||
const installedEntry = installed.find((item) => item.id === entry?.id && sameSource(item));
|
||||
const conflictingInstall = installed.find((item) => item.id === entry?.id && !sameSource(item));
|
||||
const choose = (id: string) => {
|
||||
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
|
||||
pageScroll.current = pane?.scrollTop ?? 0;
|
||||
@@ -59,7 +67,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
};
|
||||
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
|
||||
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
|
||||
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
|
||||
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
|
||||
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
|
||||
<div className="plugin-marketplace-browser">
|
||||
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
|
||||
@@ -70,7 +78,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
|
||||
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
|
||||
<div className="plugin-marketplace-grid">{filtered.map((item) => {
|
||||
const isInstalled = installed.some((plugin) => plugin.id === item.id);
|
||||
const isInstalled = installed.some((plugin) => plugin.id === item.id && sameSource(plugin));
|
||||
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
|
||||
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
|
||||
@@ -89,7 +97,8 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
|
||||
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
|
||||
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
|
||||
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
|
||||
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
|
||||
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
|
||||
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
|
||||
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
|
||||
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
|
||||
@@ -97,6 +106,13 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
</div>
|
||||
</article>}
|
||||
</div>
|
||||
<Dialog open={confirmInstall && Boolean(entry)} onOpenChange={setConfirmInstall}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); installButtonRef.current?.focus(); }}>
|
||||
<DialogTitle>{t('settings.plugins.sources.installConfirm')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
|
||||
<p className="break-all text-sm font-medium">{marketSource?.origin}</p>
|
||||
{entry && <PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, source: marketSource, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />}
|
||||
{catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && <label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearPreviousSource} onChange={(event) => setClearPreviousSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}<span className="break-all">{catalog.sourceBinding.origin ?? t('settings.plugins.sources.local')}</span></label>}
|
||||
<Button disabled={catalog.busy || Boolean(catalog.listError) || Boolean(catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && !clearPreviousSource)} onClick={() => { if (entry) void catalog.install(entry.id, marketSource?.id, clearPreviousSource).then(() => setConfirmInstall(false)); }}>{t('settings.plugins.market.install')}</Button>
|
||||
</DialogContent></Dialog>
|
||||
</section>;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
const scrollTop = useRef(0);
|
||||
const lastSelection = useRef<string | null>(null);
|
||||
@@ -72,11 +73,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
|
||||
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [clearSource, setClearSource] = useState(false);
|
||||
if (!manager.available) return null;
|
||||
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
|
||||
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
@@ -99,6 +102,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
|
||||
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
|
||||
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
@@ -111,9 +115,10 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
|
||||
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
|
||||
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</>}
|
||||
</article>)}
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { PluginInfo } from './PluginInfo';
|
||||
|
||||
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
|
||||
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
|
||||
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
|
||||
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
|
||||
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
|
||||
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
|
||||
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
|
||||
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
|
||||
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('source management requires consent and Escape restores either opener inside the plugin window', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
// Install a synthetic preload surface on the isolated DOM window.
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: { listPluginSources: async () => [] }, configurable: true });
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { PluginThirdPartyMarket } = await import('./PluginThirdPartyMarket');
|
||||
const { PluginManagementDialog } = await import('./PluginManagementDialog');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginThirdPartyMarket installed={[]} /></PluginManagementDialog></I18nProvider>));
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
|
||||
for (const label of ['Manage sources', 'Add source']) {
|
||||
const opener = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === label)!;
|
||||
assert.ok(opener);
|
||||
await act(async () => { opener.focus(); opener.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]');
|
||||
const dialog = dialogs[dialogs.length - 1];
|
||||
assert.match(dialog.textContent ?? '', /real domain/);
|
||||
assert.equal(dialog.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
|
||||
assert.equal(dialog.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled, true);
|
||||
await act(async () => { dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(document.activeElement, opener);
|
||||
}
|
||||
} finally {
|
||||
// Radix schedules unmount autofocus on a timer. Drain it while the DOM's
|
||||
// Event constructor is still installed (Node 22 has a distinct global Event).
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { usePluginSources } from '../../../application/state/usePluginSources';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
|
||||
import { PluginMarketplace } from './PluginMarketplace';
|
||||
|
||||
export function PluginThirdPartyMarket({ installed, toolbarContainer, noVault }: {
|
||||
installed: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null; noVault?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const model = usePluginSources();
|
||||
const [selected, setSelected] = useState('');
|
||||
const [visited, setVisited] = useState<string[]>([]);
|
||||
const [managing, setManaging] = useState(false);
|
||||
const opener = useRef<HTMLButtonElement | null>(null);
|
||||
const openManager = (event: React.MouseEvent<HTMLButtonElement>) => { opener.current = event.currentTarget; setManaging(true); };
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [disablePlugins, setDisablePlugins] = useState(false);
|
||||
const sources = model.sources.filter((source) => source.kind === 'third-party');
|
||||
const source = sources.find((item) => item.id === selected && item.enabled);
|
||||
let domain = '';
|
||||
try { domain = new URL(address).host; } catch { /* Host validates before saving. */ }
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select aria-label={t('settings.plugins.sources.select')} value={selected} onChange={(event) => { setSelected(event.target.value); setVisited((items) => [...new Set([...items, event.target.value])]); }} className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm">
|
||||
<option value="">{t('settings.plugins.sources.select')}</option>
|
||||
{sources.filter((item) => item.enabled).map((item) => <option key={item.id} value={item.id}>{item.name} — {new URL(item.origin!).hostname}</option>)}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={openManager}>{t('settings.plugins.sources.manage')}</Button>
|
||||
</div>
|
||||
{!source && <div className="py-8 text-center text-sm text-muted-foreground"><p>{t('settings.plugins.sources.empty')}</p><Button className="mt-3" variant="outline" onClick={openManager}>{t('settings.plugins.sources.add')}</Button></div>}
|
||||
{/* Keep each visited source mounted to preserve its own selection/search/scroll. */}
|
||||
{sources.filter((item) => item.enabled && (item.id === selected || visited.includes(item.id))).map((item) => {
|
||||
return <div key={item.id} hidden={item.id !== source?.id}><PluginMarketplace source={item} installed={installed} toolbarContainer={item.id === source?.id ? toolbarContainer : undefined} noVault={noVault} /></div>;
|
||||
})}
|
||||
<Dialog open={managing} onOpenChange={setManaging}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus(); }}>
|
||||
<DialogTitle>{t('settings.plugins.sources.manage')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
|
||||
{model.error && <p role="alert" className="text-sm text-destructive">{model.error}</p>}
|
||||
<div className="space-y-3">{sources.map((item) => <div key={item.id} className="space-y-2 rounded-lg bg-muted/30 p-3">
|
||||
<Input aria-label={t('settings.plugins.sources.name')} defaultValue={item.name} onBlur={(event) => { if (event.target.value !== item.name) void model.update(item, event.target.value, Boolean(item.enabled)); }} disabled={model.busy} />
|
||||
<p className="break-all text-xs text-muted-foreground">{item.origin}</p>
|
||||
<div className="flex items-center justify-between"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(item.enabled)} disabled={model.busy} onChange={(event) => void model.update(item, item.name, event.target.checked)} />{t('settings.plugins.manager.enabled')}</label><Button variant="ghost" size="sm" disabled={model.busy} onClick={() => { setRemoving(item.id); setDisablePlugins(false); }}>{t('settings.plugins.sources.remove')}</Button></div>
|
||||
{removing === item.id && <div className="space-y-2 text-sm"><p>{t('settings.plugins.sources.removeNote')}</p><label className="flex items-center gap-2"><input type="checkbox" checked={disablePlugins} onChange={(event) => setDisablePlugins(event.target.checked)} />{t('settings.plugins.sources.disablePlugins')}</label><Button size="sm" variant="destructive" disabled={model.busy} onClick={() => void model.remove(item.id, disablePlugins).then((ok) => { if (ok) setRemoving(null); })}>{t('settings.plugins.sources.remove')}</Button></div>}
|
||||
</div>)}</div>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); if (accepted) void model.add(name, address).then((ok) => { if (ok) { setName(''); setAddress(''); setAccepted(false); } }); }}>
|
||||
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t('settings.plugins.sources.name')} aria-label={t('settings.plugins.sources.name')} />
|
||||
<Input required type="url" value={address} onChange={(event) => { setAddress(event.target.value); setAccepted(false); }} placeholder="https://plugins.example.com" aria-label={t('settings.plugins.sources.address')} />
|
||||
{domain && <p className="break-all text-sm font-medium">{domain}</p>}
|
||||
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={accepted} onChange={(event) => setAccepted(event.target.checked)} />{t('settings.plugins.sources.accept')}</label>
|
||||
<Button type="submit" disabled={model.busy || !accepted}>{t('settings.plugins.sources.add')}</Button>
|
||||
</form>
|
||||
</DialogContent></Dialog>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
import { PluginMarketplace } from './PluginMarketplace';
|
||||
@@ -290,7 +291,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
const manager = usePluginManager();
|
||||
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
|
||||
const availableFonts = useAvailableFonts();
|
||||
const scopeCatalog = usePluginSettingScopeCatalog();
|
||||
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
|
||||
@@ -311,11 +312,12 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
|
||||
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
|
||||
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
|
||||
@@ -323,7 +325,8 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent value="installed" className="m-0 space-y-6">
|
||||
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
|
||||
{contextualScopes.size > 0 && (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Market sources (unreleased)
|
||||
|
||||
The plugin manager has Installed, Official marketplace and Third-party marketplace
|
||||
pages. Listing a plugin in the official market does not certify it or establish
|
||||
that Netcatty maintains it. Remote manifests, publisher names, source names and
|
||||
README content cannot create a host-issued official badge. This revision does not
|
||||
issue maintainer badges. README text and images remain author content.
|
||||
|
||||
## Add a source
|
||||
|
||||
Enter a public HTTPS origin, for example `https://plugins.example.com`, or that
|
||||
origin's `/v1/snapshot.json`. Names can change; identity is derived by the host
|
||||
from the canonical origin. Credentials, query strings, fragments, custom ports,
|
||||
IP literals and local hostnames are rejected. The initial third-party transport
|
||||
requires public IPv4 DNS records, pins the checked address to the TLS connection,
|
||||
and rejects private/reserved destinations. IPv6-only sources are not supported
|
||||
in this revision. No cookies, login tokens, redirects or GitHub fallback are used.
|
||||
|
||||
Third-party sources begin empty. A failed request never substitutes the official
|
||||
catalog. Each source has its own in-memory last-known-good snapshot and selection
|
||||
tokens; an offline snapshot is browse-only. A process restart requires a fresh
|
||||
third-party fetch. The official source remains fixed to `plugins.netcatty.app`
|
||||
and retains its bundled source examples when unavailable.
|
||||
|
||||
## Publish a static source
|
||||
|
||||
Use the existing snapshot protocol in the registry project's
|
||||
`docs/STATIC-DISTRIBUTION.md`. Every source
|
||||
serves `/v1/snapshot.json`, pointing to hash/size-bound catalog, index and delivery
|
||||
JSON under `/snapshots/<delivery-sha256>/`. All resources remain on the source's
|
||||
origin under content-addressed `/assets/` paths. Whole-JSON budgets, schema,
|
||||
manifest, compatibility, browser-runtime admission and archive validation still
|
||||
apply. Third-party source consent is not permission consent.
|
||||
|
||||
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
|
||||
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
|
||||
`artifact` can be null or a package reference:
|
||||
|
||||
```json
|
||||
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
|
||||
```
|
||||
|
||||
Packages are at most 32 MiB. The catalog entry must contain
|
||||
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
|
||||
The index's current, non-withdrawn version must exactly equal the catalog entry
|
||||
and use `distribution: "release"`. Source-only entries retain null delivery
|
||||
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
|
||||
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
|
||||
Existing official bootstrap entries are not changed or made installable.
|
||||
|
||||
Before every install, refresh and validate the entire snapshot again. A changed
|
||||
selection, withdrawal, unavailable source, altered hash or size prevents install;
|
||||
there is no fallback to another source or latest release. Packages install disabled.
|
||||
|
||||
## Ownership and changing source
|
||||
|
||||
A public plugin ID can have only one installed source. The package's host install
|
||||
metadata and durable database binding record that source, including across
|
||||
recovery and uninstall. Cross-source install or update is rejected, even when
|
||||
version and package bytes match. Local imports are a separate source.
|
||||
|
||||
Private settings, view state, persistent and in-memory grants, secrets, and cloud
|
||||
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
|
||||
inside the existing per-plugin FK. Code, permissions and source configuration do
|
||||
not participate in cloud sync. Old-source cloud settings may remain orphaned;
|
||||
they cannot hydrate the same plugin ID from a different source. Generic KV still
|
||||
is not cloud-synced. No host/Vault/input migration changes are made.
|
||||
|
||||
To change source, uninstall and explicitly select **clear this source's private
|
||||
data, grants and secrets**. The host serializes shutdown, revocation, private-file
|
||||
cleanup and binding reset. This is destructive and is never selected by default.
|
||||
Then install from the new source and review permissions again. An uninstall that
|
||||
retains data deliberately retains the binding and continues to reject other sources.
|
||||
|
||||
Removing a market keeps its installed plugins and data. Updates stop; optionally
|
||||
disable its plugins. No other market silently takes over. Disabling a source only
|
||||
stops browsing/install/update access; it does not silently revoke existing plugins.
|
||||
|
||||
## Validation limits
|
||||
|
||||
Tests use synthetic source responses and first-party packages. Passing tests is
|
||||
not a sandbox certification. Native UI, real third-party hosting, network
|
||||
availability and multi-device encrypted-sync acceptance must be reported
|
||||
separately. No real credentials or untrusted community code are needed for tests.
|
||||
|
||||
## Source-owner integration
|
||||
|
||||
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
|
||||
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
|
||||
`plugin.source` is host-owned `{id, kind, name, origin}`.
|
||||
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
|
||||
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
|
||||
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
|
||||
request or invent a `pluginId` fallback. Capture owner/source at async operation
|
||||
start and revalidate before committing; follow runtime lifecycle cancellation.
|
||||
|
||||
Plugin database schema 7 atomically converts existing raw local private rows and
|
||||
KV keys to the same encoded-owner model without deleting them. This is a one-time
|
||||
conversion, not a parallel runtime compatibility path. A conflicting conversion
|
||||
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
|
||||
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
|
||||
@@ -1,56 +1,84 @@
|
||||
# Official registry client
|
||||
# Official static distribution client
|
||||
|
||||
The Community page refreshes the fixed endpoint
|
||||
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
|
||||
in the main process. The renderer cannot choose a registry URL. This endpoint
|
||||
must be published separately; client implementation and synthetic tests do not
|
||||
prove the repository or an online artifact is available.
|
||||
Community starts at the fixed main-process endpoint
|
||||
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
|
||||
endpoint. All automatic catalog, README and potential package reads allow only
|
||||
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
|
||||
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
|
||||
source links may still open GitHub in an external browser.
|
||||
|
||||
## Catalog contract
|
||||
## Snapshot contract
|
||||
|
||||
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
|
||||
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
|
||||
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
|
||||
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
|
||||
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
|
||||
including manifest defaults, uses the existing 128-level / 100,000-value budget
|
||||
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
|
||||
and license checks apply. Source-only entries remain build-and-import examples.
|
||||
No download counts, ratings, or timestamps are invented. Brand previews remain
|
||||
bundled and reviewed; arbitrary author image URLs are never loaded.
|
||||
This client implements the version-1 contract from
|
||||
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
|
||||
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
|
||||
|
||||
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
|
||||
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
|
||||
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
|
||||
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
|
||||
when the last refresh succeeded; it is disabled while a request is pending.
|
||||
The last good catalog is retained **in memory for this application session**;
|
||||
a fresh offline launch uses bundled examples. There is no new disk cache,
|
||||
database migration, cloud sync record, or credential requirement.
|
||||
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
|
||||
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
|
||||
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
|
||||
SHA-256. The host checks the pointer before requesting its three documents,
|
||||
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
|
||||
identity. The three documents must all validate before replacing any cache.
|
||||
|
||||
## Selection and installation
|
||||
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
|
||||
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
|
||||
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
|
||||
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
|
||||
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
|
||||
for brand icons. This v1 publishing schema requires **avatar and artifact to
|
||||
be null**. The client rejects unsupported delivery metadata rather than
|
||||
inventing installable packages or deriving a GitHub avatar URL.
|
||||
|
||||
Each returned entry has a host-generated `selectionToken` covering its complete
|
||||
JSON contents. This is a concurrency check, **not a signature or trust proof**.
|
||||
README requests must still match that selection and fetch its pinned source
|
||||
commit. A changed entry requires refresh and renewed review.
|
||||
Index is validated with its pinned schema. Plugin/version identities must be
|
||||
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
|
||||
source-only entry. Withdrawal does not disable, uninstall or delete any existing
|
||||
plugin: this is catalog admission, not a remote installed-code kill switch.
|
||||
|
||||
A manual install must first refresh the official catalog successfully and match
|
||||
the selected token again. Failed refresh is browse-only, even if the cached
|
||||
entry has an artifact. Withdrawal, version changes, permission changes, README
|
||||
changes, or artifact hash changes cannot silently replace the selected package.
|
||||
Archive download then uses the existing approved-host, size, exact SHA-256,
|
||||
manifest and package validation pipeline. Installation leaves the plugin disabled;
|
||||
existing authorization gates still apply on activation/use.
|
||||
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
|
||||
fixtures described there. All schema references resolve locally; schema validation
|
||||
does not fetch GitHub or netcatty.com.
|
||||
|
||||
`index.json` release history and withdrawal records are not consumed by this
|
||||
first client. Removing an entry does **not** disable, uninstall, update, or delete
|
||||
data from an already installed plugin. There are no background code updates.
|
||||
## Bounds, cache and selection
|
||||
|
||||
## Verification boundary
|
||||
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
|
||||
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
|
||||
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
|
||||
entries. Existing manifest identity, license and browser-only policies remain.
|
||||
|
||||
Service regressions use synthetic network responses and package bytes; the
|
||||
existing package manager tests cover archive admission. No third-party plugin,
|
||||
real credential, real sync account, or live registry write is used. Native QA
|
||||
must separately verify the visible offline/retry state and eventual real catalog
|
||||
availability once the repository is published.
|
||||
The host prepares private entries, private delivery resources and all public
|
||||
selection tokens before one synchronous cache publication. Parse, integrity,
|
||||
schema, clone, tuple or network failures preserve the last complete good snapshot.
|
||||
The cache remains **in memory for this app session**; a fresh offline launch lists
|
||||
bundled examples. No new disk database, sync record or credential is introduced.
|
||||
|
||||
A host-generated selection token binds the complete entry and its delivery item.
|
||||
This prevents silent selection changes; it is **not a signature**. README requests
|
||||
capture the matched static reference and verify its exact byte count and digest.
|
||||
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
|
||||
README HTML and remote images remain inert in the existing safe renderer.
|
||||
|
||||
The current v1 distribution cannot install packages. A later publishing/client
|
||||
schema revision must jointly introduce reviewed package mirroring, keep source
|
||||
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
|
||||
ledger before any install. A historical snapshot alone cannot establish availability.
|
||||
The existing lower-level package downloader accepts only official static URLs and
|
||||
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
|
||||
|
||||
## Network inventory
|
||||
|
||||
| Path | Automatic source | Failure behavior |
|
||||
| --- | --- | --- |
|
||||
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
|
||||
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
|
||||
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
|
||||
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
|
||||
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
|
||||
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
|
||||
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
|
||||
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
|
||||
|
||||
The real PR3 bootstrap is retained as a test fixture with its original bytes and
|
||||
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
|
||||
remote icon renderer. The static publisher is not a proxy. Client tests do not
|
||||
prove deployment, mainland reachability or successful native CDN access. Native
|
||||
QA must independently verify the published endpoint and visible failure state.
|
||||
@@ -12,25 +12,29 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
|
||||
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
function assertDownloadUrl(value) {
|
||||
const { OFFICIAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
|
||||
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
|
||||
function assertDownloadUrl(value, origin = DISTRIBUTION_ORIGIN) {
|
||||
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
|
||||
if (url.origin !== origin || url.username || url.password || url.port
|
||||
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function pinnedReadmeUrl(entry) {
|
||||
function assertReadmeSource(entry) {
|
||||
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|
||||
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error("Invalid or unpinned README path");
|
||||
}
|
||||
const prefix = `/${entry.repository}/${entry.commit}/`;
|
||||
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
|
||||
return url.href;
|
||||
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
|
||||
throw new Error("README source escapes the pinned commit");
|
||||
}
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
function validateCatalog(entries, origin = DISTRIBUTION_ORIGIN) {
|
||||
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
|
||||
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
|
||||
const ids = new Set();
|
||||
@@ -43,71 +47,68 @@ function validateCatalog(entries) {
|
||||
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
|
||||
throw new Error("Invalid or unpinned plugin catalog entry");
|
||||
}
|
||||
pinnedReadmeUrl(entry);
|
||||
assertReadmeSource(entry);
|
||||
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
|
||||
assertCatalogManifest(entry.manifest, entry);
|
||||
ids.add(entry.id);
|
||||
if (Object.hasOwn(entry, "artifact")) {
|
||||
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|
||||
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|
||||
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
|
||||
throw new Error("Invalid catalog artifact metadata");
|
||||
for (const field of ["artifact"]) {
|
||||
if (!Object.hasOwn(entry, field)) continue;
|
||||
const asset = entry[field];
|
||||
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|
||||
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|
||||
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
|
||||
throw new Error(`Invalid catalog ${field} metadata`);
|
||||
}
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
assertDownloadUrl(asset.url, origin);
|
||||
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, origin = DISTRIBUTION_ORIGIN) {
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
||||
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
url = new URL(response.headers.get("location"), url).href;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
|
||||
if (Number(response.headers.get("content-length")) > limit) {
|
||||
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("Catalog download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
const response = await fetchImpl(assertDownloadUrl(url, origin), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
throw new Error("Catalog redirects are not allowed");
|
||||
}
|
||||
throw new Error("Too many catalog redirects");
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
|
||||
if (Number(response.headers.get("content-length")) > limit) {
|
||||
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("Catalog download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
|
||||
this.marketSource = assertSource(options.source ?? OFFICIAL_SOURCE);
|
||||
this.origin = this.marketSource.origin;
|
||||
this.entries = structuredClone(validateCatalog(options.entries ?? (this.marketSource.id === "official" ? catalog : []), this.origin));
|
||||
this.fetch = options.fetch ?? fetch;
|
||||
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
|
||||
this.manager = options.manager;
|
||||
this.sourceGuard = options.sourceGuard ?? (() => {});
|
||||
this.installing = false;
|
||||
}
|
||||
list() { return structuredClone(this.entries); }
|
||||
artifactSize() { return undefined; }
|
||||
get(id) {
|
||||
const entry = this.entries.find((item) => item.id === id);
|
||||
if (!entry) throw new Error("Plugin is not in the curated catalog");
|
||||
return entry;
|
||||
}
|
||||
async readme(id) {
|
||||
const entry = this.get(id);
|
||||
const url = pinnedReadmeUrl(entry);
|
||||
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
|
||||
}
|
||||
async install(id) {
|
||||
const entry = this.get(id);
|
||||
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
|
||||
@@ -115,16 +116,18 @@ class PluginCatalogService {
|
||||
this.installing = true;
|
||||
let directory;
|
||||
try {
|
||||
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
|
||||
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch, this.origin);
|
||||
const expectedSize = this.artifactSize(entry);
|
||||
if (expectedSize !== undefined && bytes.byteLength !== expectedSize) throw new Error("Catalog package size mismatch");
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== entry.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
|
||||
directory = await fs.mkdtemp(this.getTempFilePath("plugin-catalog-"));
|
||||
const archive = path.join(directory, "package.ncpkg");
|
||||
await fs.writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
|
||||
return await this.manager.install(archive, { enable: false, catalog: entry });
|
||||
return await this.manager.install(archive, { enable: false, catalog: entry, source: this.marketSource, sourceGuard: this.sourceGuard });
|
||||
} finally {
|
||||
this.installing = false;
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
|
||||
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
|
||||
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
|
||||
});
|
||||
|
||||
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
|
||||
const requested = [];
|
||||
const service = new PluginCatalogService({ entries, fetch: async (url) => {
|
||||
requested.push(url); return new Response("# First-party example");
|
||||
} });
|
||||
assert.equal(await service.readme(entries[0].id), "# First-party example");
|
||||
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
|
||||
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
|
||||
await assert.rejects(service.readme(entries[0].id), /redirects/);
|
||||
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
|
||||
test("static downloader is bounded and never follows redirects", async () => {
|
||||
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
|
||||
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
|
||||
});
|
||||
|
||||
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
let installs = 0;
|
||||
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
|
||||
assert.equal(name, "plugin-catalog-");
|
||||
|
||||
@@ -5,10 +5,13 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const SCHEMA_VERSION = 4;
|
||||
const { LOCAL_SOURCE, assertSource, privateOwner } = require("./sourceIdentity.cjs");
|
||||
|
||||
const SCHEMA_VERSION = 7;
|
||||
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
|
||||
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
|
||||
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
plugin_source_bindings: ["plugin_id", "source_json"],
|
||||
plugin_host_preferences: ["key", "value_json", "updated_at"],
|
||||
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
|
||||
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
|
||||
@@ -217,7 +220,46 @@ class PluginDatabase {
|
||||
CREATE TABLE plugin_host_preferences (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
||||
PRAGMA user_version = 4;
|
||||
`));
|
||||
if (version <= 4) this.transaction(() => this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_source_bindings (plugin_id TEXT PRIMARY KEY, source_json TEXT NOT NULL);
|
||||
`));
|
||||
this.#assertSchemaLayout();
|
||||
if (version <= 5) this.transaction(() => {
|
||||
// Upgraded installs and retained local private records must keep their
|
||||
// provenance after uninstall. Never infer official ownership from names.
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id AS plugin_id FROM plugins
|
||||
UNION SELECT plugin_id FROM plugin_settings
|
||||
UNION SELECT plugin_id FROM plugin_view_state
|
||||
UNION SELECT plugin_id FROM plugin_permission_grants
|
||||
UNION SELECT plugin_id FROM plugin_secrets
|
||||
UNION SELECT plugin_id FROM plugin_sync_sidecars
|
||||
`).all();
|
||||
const insert = this.db.prepare("INSERT OR IGNORE INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?)");
|
||||
for (const row of rows) {
|
||||
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(row.plugin_id)) insert.run(row.plugin_id, JSON.stringify(LOCAL_SOURCE));
|
||||
}
|
||||
this.db.exec("PRAGMA user_version = 6");
|
||||
});
|
||||
if (version <= 6) this.transaction(() => {
|
||||
// One-time conversion of existing local private rows; all sources now use
|
||||
// immutable encoded owners. Never retain a raw-ID lookup fallback.
|
||||
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars"]) {
|
||||
const rows = this.db.prepare(`SELECT DISTINCT plugin_id FROM ${table}`).all();
|
||||
for (const { plugin_id: id } of rows) {
|
||||
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(id)) {
|
||||
this.db.prepare(`UPDATE ${table} SET plugin_id = ? WHERE plugin_id = ?`).run(privateOwner(LOCAL_SOURCE, id), id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const row of this.db.prepare("SELECT plugin_id, key FROM plugin_kv").all()) {
|
||||
if (this.getPluginSource(row.plugin_id).id === "local") {
|
||||
const prefix = `${privateOwner(LOCAL_SOURCE, row.plugin_id)}:`;
|
||||
if (!row.key.startsWith(prefix)) this.db.prepare("UPDATE plugin_kv SET key = ? WHERE plugin_id = ? AND key = ?").run(prefix + row.key, row.plugin_id, row.key);
|
||||
}
|
||||
}
|
||||
this.db.exec("PRAGMA user_version = 7");
|
||||
});
|
||||
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
||||
// (hostService seeds after package initialize). Running here would promote
|
||||
// sole map candidates before recovered manifests are visible, then skip
|
||||
@@ -250,15 +292,64 @@ class PluginDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
getPluginSource(pluginId) {
|
||||
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
|
||||
return row ? assertSource(parseJson(row.source_json, "source identity")) : { ...LOCAL_SOURCE };
|
||||
}
|
||||
|
||||
getRetainedPluginSource(pluginId) {
|
||||
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
|
||||
if (row) return assertSource(parseJson(row.source_json, "source identity"));
|
||||
return this.db.prepare("SELECT id FROM plugins WHERE id = ?").get(pluginId) ? { ...LOCAL_SOURCE } : null;
|
||||
}
|
||||
|
||||
assertInstallSource(pluginId, source = LOCAL_SOURCE) {
|
||||
const trusted = assertSource(source);
|
||||
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
|
||||
const existing = row ? assertSource(parseJson(row.source_json, "source identity"))
|
||||
: this.getActivePlugin(pluginId) ? LOCAL_SOURCE : null;
|
||||
if (existing && existing.id !== trusted.id) {
|
||||
throw new Error("Plugin source differs. Uninstall and explicitly clear the old source's private data before reinstalling.");
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
privateOwner(pluginId) {
|
||||
// Opaque owners enter only from host sync storage, never plugin RPC identity.
|
||||
if (/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) return pluginId;
|
||||
const source = this.getPluginSource(pluginId);
|
||||
return privateOwner(source, pluginId);
|
||||
}
|
||||
|
||||
publicPluginId(owner) {
|
||||
if (!/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(owner)) return null;
|
||||
const pluginId = owner.slice(owner.lastIndexOf(":") + 1);
|
||||
return this.privateOwner(pluginId) === owner ? pluginId : null;
|
||||
}
|
||||
|
||||
clearUninstalledSourceData(pluginId, expectedSourceId) {
|
||||
if (this.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing its source");
|
||||
if (this.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
|
||||
const owner = this.privateOwner(pluginId);
|
||||
this.transaction(() => {
|
||||
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars", "plugin_sync_provider_bindings"]) {
|
||||
this.db.prepare(`DELETE FROM ${table} WHERE plugin_id = ? OR plugin_id = ?`).run(owner, pluginId);
|
||||
}
|
||||
this.db.prepare("DELETE FROM plugin_source_bindings WHERE plugin_id = ?").run(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
installVersion(record, options = {}) {
|
||||
if (options.enable === true && options.forceDisabled === true) {
|
||||
throw new TypeError("Plugin version cannot be enabled and force-disabled together");
|
||||
}
|
||||
const source = this.assertInstallSource(record.pluginId, record.source);
|
||||
const now = this.clock();
|
||||
const manifestJson = JSON.stringify(record.manifest);
|
||||
const requestedEnabled = options.enable === true ? 1 : 0;
|
||||
const overwriteEnabled = options.enable === true || options.forceDisabled === true;
|
||||
this.transaction(() => {
|
||||
this.db.prepare("INSERT INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?) ON CONFLICT(plugin_id) DO UPDATE SET source_json=excluded.source_json").run(record.pluginId, JSON.stringify(source));
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -365,6 +456,7 @@ class PluginDatabase {
|
||||
#mapPlugin(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
source: this.getPluginSource(row.id),
|
||||
enabled: row.enabled === 1,
|
||||
activeVersion: row.active_version ?? null,
|
||||
installedAt: Number(row.installed_at),
|
||||
@@ -498,6 +590,8 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getValue(pluginId, key) {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
if (owner !== pluginId) key = `${owner}:${key}`;
|
||||
const row = this.db.prepare(
|
||||
"SELECT value_json FROM plugin_kv WHERE plugin_id = ? AND key = ?",
|
||||
).get(pluginId, key);
|
||||
@@ -505,6 +599,8 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setValue(pluginId, key, value) {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
if (owner !== pluginId) key = `${owner}:${key}`;
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Plugin storage value must be JSON serializable");
|
||||
this.db.prepare(`
|
||||
@@ -517,16 +613,22 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteValue(pluginId, key) {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
if (owner !== pluginId) key = `${owner}:${key}`;
|
||||
this.db.prepare("DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?").run(pluginId, key);
|
||||
}
|
||||
|
||||
listKeys(pluginId) {
|
||||
return this.db.prepare(
|
||||
"SELECT key FROM plugin_kv WHERE plugin_id = ? ORDER BY key COLLATE BINARY",
|
||||
).all(pluginId).map((row) => row.key);
|
||||
).all(pluginId).map((row) => row.key).filter((key) => {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
return owner === pluginId || key.startsWith(`${owner}:`);
|
||||
}).map((key) => this.privateOwner(pluginId) === pluginId ? key : key.slice(this.privateOwner(pluginId).length + 1));
|
||||
}
|
||||
|
||||
getSetting(pluginId, settingId, scope, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT value_json FROM plugin_settings
|
||||
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
|
||||
@@ -535,6 +637,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
|
||||
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
|
||||
@@ -548,6 +651,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteSetting(pluginId, settingId, scope, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_settings
|
||||
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
|
||||
@@ -555,6 +659,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
listSettings(pluginId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
return this.db.prepare(`
|
||||
SELECT setting_id, scope, scope_id, value_json, updated_at
|
||||
FROM plugin_settings WHERE plugin_id = ?
|
||||
@@ -584,6 +689,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getSyncSidecar(pluginId, kind, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT value_json, updated_at FROM plugin_sync_sidecars
|
||||
WHERE plugin_id = ? AND kind = ? AND key = ?
|
||||
@@ -599,6 +705,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
|
||||
this.db.prepare(`
|
||||
@@ -611,6 +718,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteSyncSidecar(pluginId, kind, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_sync_sidecars
|
||||
WHERE plugin_id = ? AND kind = ? AND key = ?
|
||||
@@ -618,6 +726,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
listSyncSidecars(pluginId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
|
||||
FROM plugin_sync_sidecars
|
||||
@@ -672,6 +781,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getViewState(pluginId, viewId, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT state_json FROM plugin_view_state
|
||||
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
|
||||
@@ -680,6 +790,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setViewState(pluginId, viewId, scopeId, state) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const serialized = JSON.stringify(state);
|
||||
if (serialized === undefined) throw new TypeError("Plugin view state must be JSON serializable");
|
||||
this.db.prepare(`
|
||||
@@ -692,6 +803,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteViewState(pluginId, viewId, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_view_state
|
||||
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
|
||||
@@ -704,8 +816,8 @@ class PluginDatabase {
|
||||
FROM plugin_permission_grants
|
||||
WHERE plugin_id = ?
|
||||
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
|
||||
`).all(pluginId).map((row) => ({
|
||||
pluginId: row.plugin_id,
|
||||
`).all(this.privateOwner(pluginId)).map((row) => ({
|
||||
pluginId,
|
||||
permission: row.permission,
|
||||
resource: row.resource,
|
||||
resourceKind: row.resource_kind,
|
||||
@@ -724,7 +836,7 @@ class PluginDatabase {
|
||||
declaration_hash = excluded.declaration_hash,
|
||||
granted_at = excluded.granted_at
|
||||
`).run(
|
||||
record.pluginId,
|
||||
this.privateOwner(record.pluginId),
|
||||
record.permission,
|
||||
record.resource,
|
||||
record.resourceKind,
|
||||
@@ -734,6 +846,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deletePermissionGrant(pluginId, permission, resource) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_permission_grants
|
||||
WHERE plugin_id = ? AND permission = ? AND resource = ?
|
||||
@@ -741,10 +854,12 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteAllPermissionGrants(pluginId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare("DELETE FROM plugin_permission_grants WHERE plugin_id = ?").run(pluginId);
|
||||
}
|
||||
|
||||
getSecretByKey(pluginId, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
||||
FROM plugin_secrets WHERE plugin_id = ? AND key = ?
|
||||
@@ -753,6 +868,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getSecretByRef(pluginId, secretRef) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
||||
FROM plugin_secrets WHERE plugin_id = ? AND secret_ref = ?
|
||||
@@ -762,7 +878,7 @@ class PluginDatabase {
|
||||
|
||||
#mapSecret(row) {
|
||||
return {
|
||||
pluginId: row.plugin_id,
|
||||
pluginId: this.publicPluginId(row.plugin_id),
|
||||
key: row.key,
|
||||
secretRef: row.secret_ref,
|
||||
ciphertext: Buffer.from(row.ciphertext),
|
||||
@@ -782,7 +898,7 @@ class PluginDatabase {
|
||||
ciphertext = excluded.ciphertext,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
record.pluginId,
|
||||
this.privateOwner(record.pluginId),
|
||||
record.key,
|
||||
record.secretRef,
|
||||
record.ciphertext,
|
||||
@@ -792,12 +908,14 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteSecret(pluginId, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare("DELETE FROM plugin_secrets WHERE plugin_id = ? AND key = ?")
|
||||
.run(pluginId, key);
|
||||
}
|
||||
|
||||
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
|
||||
deleteSecretsByKeyPrefix(pluginId, prefix) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM plugin_secrets
|
||||
WHERE plugin_id = ?
|
||||
@@ -857,7 +975,7 @@ class PluginDatabase {
|
||||
SELECT DISTINCT plugin_id
|
||||
FROM plugin_secrets
|
||||
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
||||
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
||||
`).all(prefix, `${escaped}:%`).map((row) => this.publicPluginId(row.plugin_id)).filter((id) => id !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -890,7 +1008,7 @@ class PluginDatabase {
|
||||
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
||||
const byProvider = new Map();
|
||||
for (const row of legacyRows) {
|
||||
const pluginId = row.plugin_id;
|
||||
const pluginId = this.publicPluginId(row.plugin_id);
|
||||
const key = row.key;
|
||||
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
||||
if (!key.startsWith(legacyPrefix)) continue;
|
||||
|
||||
@@ -6,6 +6,7 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const { privateOwner, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
|
||||
const {
|
||||
MAX_SECURITY_AUDIT_DETAILS_BYTES,
|
||||
@@ -488,7 +489,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -499,7 +500,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
@@ -532,7 +533,7 @@ test("legacy map backfill does not overwrite an existing host binding", (context
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -566,7 +567,7 @@ test("legacy map backfill skips when no candidate holds sync credentials", (cont
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
|
||||
@@ -589,7 +590,7 @@ test("legacy map backfill binds sole credential-backed owner when maps conflict"
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
@@ -618,7 +619,7 @@ test("legacy map backfill does not let longest map override credential-backed pa
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
@@ -648,7 +649,7 @@ test("legacy map backfill skips when multiple credential-backed candidates exist
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
|
||||
@@ -672,7 +673,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -683,7 +684,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
@@ -711,7 +712,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
@@ -723,7 +724,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
@@ -750,7 +751,7 @@ test("legacy map backfill does not resurrect after explicit unbind tombstone", (
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -823,7 +824,7 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
}
|
||||
// Parent prefix alone is not enough: a removed plugin may have owned
|
||||
// com.example.backup.sync while only com.example still has credentials.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Pinned static distribution schemas
|
||||
|
||||
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
|
||||
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
|
||||
(PR 3). The host compiles them with the existing local plugin-contract schema;
|
||||
no schema reference causes network access.
|
||||
|
||||
`../fixtures/static-distribution/` contains that commit's actual
|
||||
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
|
||||
four referenced README/PNG assets. Raw-byte SHA-256 checks in
|
||||
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
|
||||
hashes; every document and asset is checked against the pointer/sidecar.
|
||||
The fixture includes no executable plugin package, avatar or credentials.
|
||||
|
||||
Schema revisions must be coordinated with the registry publisher. In particular,
|
||||
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Netcatty static delivery v1 (source-only bootstrap)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"registryCommit",
|
||||
"plugins",
|
||||
"catalogSha256",
|
||||
"indexSha256",
|
||||
"sourceLockSha256",
|
||||
"builderSha256"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"registryCommit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"repository",
|
||||
"commit",
|
||||
"readme",
|
||||
"iconLight",
|
||||
"iconDark",
|
||||
"avatar",
|
||||
"artifact"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"readme": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
},
|
||||
"iconLight": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"iconDark": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"avatar": {
|
||||
"type": "null"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalogSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"indexSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"sourceLockSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"builderSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"plugins"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"current",
|
||||
"versions"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[a-z0-9][a-z0-9.-]+$"
|
||||
},
|
||||
"current": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"versions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"entry",
|
||||
"distribution",
|
||||
"withdrawal"
|
||||
],
|
||||
"properties": {
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"name",
|
||||
"description",
|
||||
"author",
|
||||
"repository",
|
||||
"commit",
|
||||
"license",
|
||||
"readme",
|
||||
"manifest"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[a-z0-9][a-z0-9.-]+$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"readme": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"manifest": {
|
||||
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"url",
|
||||
"sha256"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"distribution": {
|
||||
"enum": [
|
||||
"source-only",
|
||||
"release"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
},
|
||||
"withdrawal": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"at",
|
||||
"reason",
|
||||
"advisory"
|
||||
],
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"advisory": {
|
||||
"type": "string",
|
||||
"pattern": "^https://github.com/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const { isDeepStrictEqual } = require("node:util");
|
||||
const Ajv2020 = require("ajv/dist/2020").default;
|
||||
const addFormats = require("ajv-formats").default;
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const { formatValidationErrors } = require("./contractValidator.cjs");
|
||||
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
|
||||
const SHA256 = /^[a-f0-9]{64}$/u;
|
||||
const COMMIT = /^[a-f0-9]{40}$/u;
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
|
||||
addFormats(ajv);
|
||||
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
|
||||
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
|
||||
const deliverySchema = require("./distribution/delivery.schema.json");
|
||||
const validateDelivery = ajv.compile(deliverySchema);
|
||||
const deliveryV2 = structuredClone(deliverySchema);
|
||||
deliveryV2.title = "Netcatty static delivery v2 (hash-bound packages)";
|
||||
deliveryV2.properties.schemaVersion.const = 2;
|
||||
const packageRef = structuredClone(deliveryV2.properties.plugins.items.properties.readme);
|
||||
packageRef.properties.path.pattern = "^/assets/[a-f0-9]{64}\\.ncpkg$";
|
||||
packageRef.properties.size.maximum = 32 * 1024 * 1024;
|
||||
deliveryV2.properties.plugins.items.properties.artifact = { oneOf: [{ type: "null" }, packageRef] };
|
||||
const validateDeliveryV2 = ajv.compile(deliveryV2);
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
|
||||
|
||||
function exactKeys(value, keys) {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
||||
}
|
||||
function assertReference(ref, path, limit) {
|
||||
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|
||||
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
|
||||
throw new Error("Invalid static snapshot resource reference");
|
||||
}
|
||||
}
|
||||
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
|
||||
const value = JSON.parse(bytes.toString("utf8"));
|
||||
assertPluginJsonValue(value, { maxBytes: limit });
|
||||
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
|
||||
return value;
|
||||
}
|
||||
function assertPointer(pointer) {
|
||||
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|
||||
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|
||||
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
|
||||
throw new Error("Invalid static snapshot pointer");
|
||||
}
|
||||
for (const name of ["catalog", "index", "delivery"]) {
|
||||
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
|
||||
}
|
||||
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
|
||||
}
|
||||
function assertAsset(ref, extensions) {
|
||||
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
|
||||
throw new Error("Invalid static asset path or format");
|
||||
}
|
||||
assertReference(ref, ref.path, 256 * 1024);
|
||||
}
|
||||
async function fetchVerified(ref, fetchImpl, origin = DISTRIBUTION_ORIGIN) {
|
||||
const bytes = await fetchBounded(`${origin}${ref.path}`, ref.size, fetchImpl, origin);
|
||||
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
|
||||
return bytes;
|
||||
}
|
||||
function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_ORIGIN) {
|
||||
validateCatalog(entries, origin);
|
||||
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
|
||||
const validator = delivery.schemaVersion === 2 ? validateDeliveryV2 : validateDelivery;
|
||||
if (!validator(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validator.errors)}`);
|
||||
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|
||||
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
|
||||
// v1 publishing deliberately refuses packages and avatars. Do not extend this
|
||||
// consumer ahead of the reviewed publishing schema or invent installable code.
|
||||
if (delivery.schemaVersion === 1 && entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
|
||||
const current = new Map();
|
||||
const indexIds = new Set();
|
||||
for (const plugin of index.plugins) {
|
||||
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
|
||||
indexIds.add(plugin.id);
|
||||
const versions = new Map();
|
||||
for (const version of plugin.versions) {
|
||||
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
|
||||
versions.set(version.entry.version, version);
|
||||
}
|
||||
if (plugin.current !== null) {
|
||||
const version = versions.get(plugin.current);
|
||||
if (!version || version.withdrawal !== null || (version.distribution !== (version.entry.artifact ? "release" : "source-only"))) throw new Error("Current distribution version is missing or withdrawn");
|
||||
current.set(plugin.id, version.entry);
|
||||
}
|
||||
}
|
||||
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
|
||||
throw new Error("Catalog does not match the current non-withdrawn index");
|
||||
}
|
||||
const resources = new Map();
|
||||
const catalogTuples = new Set(entries.map(tuple));
|
||||
for (const plugin of delivery.plugins) {
|
||||
const key = tuple(plugin);
|
||||
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
|
||||
assertAsset(plugin.readme, ["md"]);
|
||||
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
|
||||
const entry = entries.find((item) => tuple(item) === key);
|
||||
if (plugin.artifact !== null) {
|
||||
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 32 * 1024 * 1024);
|
||||
if (!entry.artifact || entry.artifact.sha256 !== plugin.artifact.sha256 || entry.artifact.url !== `${origin}${plugin.artifact.path}`) throw new Error("Package delivery does not match catalog hash and source");
|
||||
} else if (entry.artifact) throw new Error("Catalog package has no delivery resource");
|
||||
resources.set(key, plugin);
|
||||
}
|
||||
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
|
||||
return resources;
|
||||
}
|
||||
async function fetchDistribution(fetchImpl, origin = DISTRIBUTION_ORIGIN) {
|
||||
const pointer = parseJson(await fetchBounded(`${origin}/v1/snapshot.json`, 16 * 1024, fetchImpl, origin), "snapshot pointer", 16 * 1024);
|
||||
assertPointer(pointer);
|
||||
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl, origin), name)));
|
||||
const [entries, index, delivery] = documents;
|
||||
const resources = validateSet(pointer, entries, index, delivery, origin);
|
||||
return { entries, resources, pointer };
|
||||
}
|
||||
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Hello Netcatty
|
||||
|
||||
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
|
||||
command-palette contributions, lazy command and Provider activation,
|
||||
localization, terminal completion/decoration Providers, and the runtime SDK.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
|
||||
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
```
|
||||
|
||||
After installation, change **Greeting** under **Settings → Plugins**, then run
|
||||
**Examples: Say Hello** from the command palette. The setting is read through
|
||||
the host settings broker and the command handler is registered inside the
|
||||
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
|
||||
completion Provider, and print `Hello from Netcatty` to exercise the declarative
|
||||
decoration Provider. Neither Provider receives raw xterm objects or sensitive
|
||||
terminal input/output streams.
|
||||
|
||||
## Import and inspect
|
||||
|
||||
Launch the normal browser-only plugin build using a fresh Electron
|
||||
user-data directory for demonstrations. In **Settings > Plugins**, choose
|
||||
**Import .ncpkg**, select the generated archive, and open its details. Review the
|
||||
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
|
||||
permission prompts still mediate activation and sensitive operations.
|
||||
|
||||
Importing an update is a manual action and leaves it disabled, including when
|
||||
the previous version was enabled. Review the new package before enabling it.
|
||||
Changing unsigned package contents changes its security principal; previous
|
||||
persistent grants are not transferred to different bytes.
|
||||
|
||||
An incompatible application/API upgrade disables installed plugins before
|
||||
publishing their contributions. The reason remains in the installed list, and
|
||||
package files and local data remain available. Returning to a compatible host
|
||||
requires explicit re-enable. Uninstall is different: package code and local KV
|
||||
are deleted; user-owned settings, secret storage and sync sidecars are retained.
|
||||
|
||||
For a synthetic demonstration, use only this first-party example, change the
|
||||
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
|
||||
inspect the package again. Do not connect real hosts or a cloud account. The
|
||||
existing greeting setting is local; declaring `sync: true` on a non-secret
|
||||
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
|
||||
storage does not sync.
|
||||
|
||||
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
|
||||
Enabling this lazy example does not grant permissions: required permissions are
|
||||
requested before its first command/provider activation, and optional permissions
|
||||
when used. Never treat Enabled as a security certification or grant.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
+37
@@ -0,0 +1,37 @@
|
||||
# Plugin Starter
|
||||
|
||||
A minimal first-party browser plugin for learning import, settings and lifecycle.
|
||||
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
|
||||
network, files, secrets or advanced Node access. It works without OS key storage.
|
||||
Permission declarations are not grants; follow the app's normal mediation.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
|
||||
```
|
||||
|
||||
Start the actual app normally with a fresh test profile; no development flag is
|
||||
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
|
||||
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
|
||||
in the command palette. Review the normal permission prompts, then click
|
||||
**Show greeting** to see the exact preference in the sandboxed panel.
|
||||
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
|
||||
The panel inserts text using `textContent`, never HTML. It does not write to
|
||||
a terminal or connect to a host. Denied permissions remain denied.
|
||||
|
||||
Disable, restart and re-enable to verify the preference persists. Re-importing
|
||||
the same package leaves it disabled for review. Uninstall removes code and local
|
||||
KV; user-owned settings remain. The greeting is local only, not cloud-synced.
|
||||
|
||||
The separate Hello Netcatty example demonstrates terminal providers and requests
|
||||
terminal permissions. Use this Starter for the default no-sensitive-access demo.
|
||||
|
||||
|
||||
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
|
||||
images does not activate the plugin or request permissions. This example now
|
||||
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
|
||||
Older Starter 0.1.1 packages remain compatible with the new host.
|
||||
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
@@ -0,0 +1,195 @@
|
||||
[
|
||||
{
|
||||
"id": "com.netcatty.starter",
|
||||
"version": "0.1.2",
|
||||
"name": "Plugin Starter",
|
||||
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/starter-netcatty/README.md",
|
||||
"manifest": {
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.starter",
|
||||
"name": "starter-netcatty",
|
||||
"displayName": {
|
||||
"en": "Plugin Starter",
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"version": "0.1.2",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal.1 <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.starter.greet",
|
||||
"onView:com.netcatty.starter.panel"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"views"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greeting",
|
||||
"label": {
|
||||
"en": "Greeting",
|
||||
"zh-CN": "问候语"
|
||||
},
|
||||
"description": "A local preference for the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello, synthetic world!"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greet",
|
||||
"title": {
|
||||
"en": "Starter: Read Greeting",
|
||||
"zh-CN": "入门:读取问候语"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.starter.greet",
|
||||
"location": "commandPalette",
|
||||
"group": "examples"
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.starter.panel",
|
||||
"title": {
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"metadata.brand-icon"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"kind": "package",
|
||||
"light": "assets/logo-light.png",
|
||||
"dark": "assets/logo-dark.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello",
|
||||
"version": "0.1.0",
|
||||
"name": "Hello Netcatty",
|
||||
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/hello-netcatty/README.md",
|
||||
"manifest": {
|
||||
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.hello",
|
||||
"name": "hello-netcatty",
|
||||
"displayName": {
|
||||
"en": "Hello Netcatty",
|
||||
"zh-CN": "你好,Netcatty"
|
||||
},
|
||||
"description": "Minimal plugin contract and SDK example.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.hello.sayHello",
|
||||
"onProvider:com.netcatty.hello.completion",
|
||||
"onProvider:com.netcatty.hello.decoration",
|
||||
"onProvider:com.netcatty.hello.theme"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"provider.terminal",
|
||||
"terminal.complete",
|
||||
"terminal.output",
|
||||
"terminal.decorate"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.hello.greeting",
|
||||
"label": "Greeting",
|
||||
"description": "Text used by the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello from Netcatty"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.hello.sayHello",
|
||||
"title": "Say Hello",
|
||||
"category": "Examples"
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.hello.sayHello",
|
||||
"location": "commandPalette",
|
||||
"group": "examples",
|
||||
"order": 10
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"id": "com.netcatty.hello.completion",
|
||||
"label": "Hello completion",
|
||||
"description": "Suggests the example hello command.",
|
||||
"kind": "terminal.completion"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.decoration",
|
||||
"label": "Hello decoration",
|
||||
"description": "Highlights the example greeting in terminal output.",
|
||||
"kind": "terminal.decoration"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.theme",
|
||||
"label": "Hello theme",
|
||||
"description": "Adds a subtle cursor color to the example terminal theme.",
|
||||
"kind": "terminal.theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
|
||||
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
|
||||
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
|
||||
"plugins": [
|
||||
{
|
||||
"artifact": null,
|
||||
"avatar": null,
|
||||
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
|
||||
"iconDark": {
|
||||
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
|
||||
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
|
||||
"size": 873
|
||||
},
|
||||
"iconLight": {
|
||||
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
|
||||
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
|
||||
"size": 873
|
||||
},
|
||||
"id": "com.netcatty.starter",
|
||||
"readme": {
|
||||
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
|
||||
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
|
||||
"size": 2050
|
||||
},
|
||||
"repository": "binaricat/Netcatty",
|
||||
"version": "0.1.2"
|
||||
},
|
||||
{
|
||||
"artifact": null,
|
||||
"avatar": null,
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"iconDark": null,
|
||||
"iconLight": null,
|
||||
"id": "com.netcatty.hello",
|
||||
"readme": {
|
||||
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
|
||||
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
|
||||
"size": 2951
|
||||
},
|
||||
"repository": "binaricat/Netcatty",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
],
|
||||
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
|
||||
"schemaVersion": 1,
|
||||
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"plugins": [
|
||||
{
|
||||
"id": "com.netcatty.starter",
|
||||
"current": "0.1.2",
|
||||
"versions": [
|
||||
{
|
||||
"entry": {
|
||||
"id": "com.netcatty.starter",
|
||||
"version": "0.1.2",
|
||||
"name": "Plugin Starter",
|
||||
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/starter-netcatty/README.md",
|
||||
"manifest": {
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.starter",
|
||||
"name": "starter-netcatty",
|
||||
"displayName": {
|
||||
"en": "Plugin Starter",
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"version": "0.1.2",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal.1 <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.starter.greet",
|
||||
"onView:com.netcatty.starter.panel"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"views"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greeting",
|
||||
"label": {
|
||||
"en": "Greeting",
|
||||
"zh-CN": "问候语"
|
||||
},
|
||||
"description": "A local preference for the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello, synthetic world!"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greet",
|
||||
"title": {
|
||||
"en": "Starter: Read Greeting",
|
||||
"zh-CN": "入门:读取问候语"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.starter.greet",
|
||||
"location": "commandPalette",
|
||||
"group": "examples"
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.starter.panel",
|
||||
"title": {
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"metadata.brand-icon"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"kind": "package",
|
||||
"light": "assets/logo-light.png",
|
||||
"dark": "assets/logo-dark.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"distribution": "source-only",
|
||||
"withdrawal": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello",
|
||||
"current": "0.1.0",
|
||||
"versions": [
|
||||
{
|
||||
"entry": {
|
||||
"id": "com.netcatty.hello",
|
||||
"version": "0.1.0",
|
||||
"name": "Hello Netcatty",
|
||||
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/hello-netcatty/README.md",
|
||||
"manifest": {
|
||||
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.hello",
|
||||
"name": "hello-netcatty",
|
||||
"displayName": {
|
||||
"en": "Hello Netcatty",
|
||||
"zh-CN": "你好,Netcatty"
|
||||
},
|
||||
"description": "Minimal plugin contract and SDK example.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.hello.sayHello",
|
||||
"onProvider:com.netcatty.hello.completion",
|
||||
"onProvider:com.netcatty.hello.decoration",
|
||||
"onProvider:com.netcatty.hello.theme"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"provider.terminal",
|
||||
"terminal.complete",
|
||||
"terminal.output",
|
||||
"terminal.decorate"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.hello.greeting",
|
||||
"label": "Greeting",
|
||||
"description": "Text used by the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello from Netcatty"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.hello.sayHello",
|
||||
"title": "Say Hello",
|
||||
"category": "Examples"
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.hello.sayHello",
|
||||
"location": "commandPalette",
|
||||
"group": "examples",
|
||||
"order": 10
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"id": "com.netcatty.hello.completion",
|
||||
"label": "Hello completion",
|
||||
"description": "Suggests the example hello command.",
|
||||
"kind": "terminal.completion"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.decoration",
|
||||
"label": "Hello decoration",
|
||||
"description": "Highlights the example greeting in terminal output.",
|
||||
"kind": "terminal.decoration"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.theme",
|
||||
"label": "Hello theme",
|
||||
"description": "Adds a subtle cursor color to the example terminal theme.",
|
||||
"kind": "terminal.theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"distribution": "source-only",
|
||||
"withdrawal": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"catalog": {
|
||||
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
|
||||
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
|
||||
"size": 5739
|
||||
},
|
||||
"delivery": {
|
||||
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
|
||||
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
|
||||
"size": 1869
|
||||
},
|
||||
"index": {
|
||||
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
|
||||
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
|
||||
"size": 7726
|
||||
},
|
||||
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
|
||||
"schemaVersion": 1,
|
||||
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { createHash } = require("node:crypto");
|
||||
const root = path.join(__dirname, "static-distribution");
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const read = (name) => fs.readFileSync(path.join(root, name));
|
||||
const catalog = JSON.parse(read("catalog.json"));
|
||||
const delivery = JSON.parse(read("delivery.json"));
|
||||
function realFiles() {
|
||||
const pointer = JSON.parse(read("snapshot.json"));
|
||||
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
|
||||
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
|
||||
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
|
||||
return files;
|
||||
}
|
||||
function makeFiles(entries = catalog, mutate = () => {}) {
|
||||
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
|
||||
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
|
||||
mutate(index, sidecar);
|
||||
const catalogBytes = Buffer.from(JSON.stringify(entries));
|
||||
const indexBytes = Buffer.from(JSON.stringify(index));
|
||||
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
|
||||
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
|
||||
const snapshotId = digest(deliveryBytes);
|
||||
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
|
||||
const files = realFiles();
|
||||
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
|
||||
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
|
||||
pointer[name] = ref; files.set(ref.path, bytes);
|
||||
}
|
||||
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
|
||||
return files;
|
||||
}
|
||||
function responseFor(files, url) {
|
||||
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
|
||||
const bytes = files.get(new URL(url).pathname);
|
||||
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
|
||||
}
|
||||
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { MarketSourcesService } = require("./marketSourcesService.cjs");
|
||||
const { PluginThemeService } = require("./themeService.cjs");
|
||||
const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
@@ -401,6 +401,7 @@ function createPluginHostService(options) {
|
||||
: null;
|
||||
const themeService = new PluginThemeService({database,packageStore,contributionService});
|
||||
const manager = new PluginManager({
|
||||
secretStore,
|
||||
permissionEngine,
|
||||
database,
|
||||
packageStore,
|
||||
@@ -427,7 +428,7 @@ function createPluginHostService(options) {
|
||||
logService,
|
||||
themeService,
|
||||
interactionService,
|
||||
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
contributionService,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { createSourceFetch } = require("./sourceFetch.cjs");
|
||||
|
||||
class MarketSourcesService {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.manager = options.manager;
|
||||
this.registry = new PluginSourceRegistry(options.database);
|
||||
this.catalogs = new Map();
|
||||
}
|
||||
#catalog(sourceId = "official") {
|
||||
const source = this.registry.get(sourceId);
|
||||
let catalog = this.catalogs.get(source.id);
|
||||
if (!catalog) {
|
||||
catalog = new RegistryCatalogService({ ...this.options, source,
|
||||
fetch: source.id === "official" ? this.options.fetch : (this.options.createSourceFetch ?? createSourceFetch)(source.origin),
|
||||
sourceGuard: () => this.registry.get(source.id),
|
||||
});
|
||||
this.catalogs.set(source.id, catalog);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
list(sourceId) { return this.#catalog(sourceId).list(); }
|
||||
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
|
||||
install(id, token, sourceId = "official", acceptedSourceId) {
|
||||
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
|
||||
return this.#catalog(sourceId).install(id, token);
|
||||
}
|
||||
listSources() { return this.registry.list(); }
|
||||
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
|
||||
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
|
||||
async removeSource(payload) {
|
||||
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
|
||||
await this.manager.changeMarketSource(() => {
|
||||
this.registry.remove(payload.id);
|
||||
this.catalogs.delete(payload.id);
|
||||
}, payload.disablePlugins ? payload.id : undefined);
|
||||
}
|
||||
}
|
||||
module.exports = { MarketSourcesService };
|
||||
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { MarketSourcesService } = require("./marketSourcesService.cjs");
|
||||
const { realFiles } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
|
||||
test("source removal retains installed plugins, optional disabling is serialized, and no source silently takes over", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "market-sources-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const database = new PluginDatabase(path.join(root, "db.sqlite"));
|
||||
t.after(() => database.close());
|
||||
const stopped = [];
|
||||
const manager = new PluginManager({ database, packageStore: { async initialize() {} }, runtimeSupervisor: {
|
||||
async startEnabled() {}, async stop(id) { stopped.push(id); },
|
||||
} });
|
||||
const requests = [];
|
||||
const service = new MarketSourcesService({ database, manager, createSourceFetch: (origin) => async (url) => {
|
||||
assert.equal(new URL(url).origin, origin); requests.push(url);
|
||||
return new Response(realFiles().get(new URL(url).pathname));
|
||||
}, fetch: async () => { assert.fail("third-party browsing must not fetch official upstream"); } });
|
||||
const source = await service.addSource({ name: "Community", address: "https://community.example.com" });
|
||||
const entry = (await service.list(source.id)).entries[0];
|
||||
database.installVersion({ pluginId: entry.id, version: entry.version, manifest: entry.manifest, source, archiveSha256: "a".repeat(64), packageRelativePath: "test" }, { enable: true });
|
||||
await assert.rejects(async () => service.install(entry.id, entry.selectionToken, source.id), /confirm/);
|
||||
await service.updateSource({ id: source.id, name: "Renamed", enabled: false });
|
||||
const count = requests.length;
|
||||
assert.throws(() => service.list(source.id), /disabled/);
|
||||
assert.equal(database.getActivePlugin(entry.id).enabled, true);
|
||||
await service.removeSource({ id: source.id, disablePlugins: true });
|
||||
assert.equal(database.getActivePlugin(entry.id).enabled, false);
|
||||
assert.equal(database.getActivePlugin(entry.id).source.id, source.id);
|
||||
assert.deepEqual(stopped, [entry.id]);
|
||||
assert.throws(() => service.readme(entry.id, entry.selectionToken, source.id), /removed/);
|
||||
assert.equal(requests.length, count);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { LOCAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
|
||||
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const { constants } = fs;
|
||||
@@ -127,6 +129,7 @@ function validateInstallMetadata(value) {
|
||||
version,
|
||||
archiveSha256: value.archiveSha256,
|
||||
contentSha256: value.contentSha256,
|
||||
source: assertSource(value.source ?? LOCAL_SOURCE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +229,7 @@ class PackageStore {
|
||||
const pluginId = assertPluginStorageSegment(manifest.id, "ID");
|
||||
const version = assertPluginStorageSegment(manifest.version, "version");
|
||||
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
|
||||
const source = this.database.assertInstallSource(pluginId, options.source ?? LOCAL_SOURCE);
|
||||
const previousPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (options.catalog) {
|
||||
if (snapshot.sha256 !== options.catalog.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
|
||||
@@ -266,6 +270,7 @@ class PackageStore {
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
source,
|
||||
pluginId,
|
||||
version,
|
||||
manifest: existingValidation.manifest,
|
||||
@@ -282,6 +287,7 @@ class PackageStore {
|
||||
if (!(error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
const metadata = {
|
||||
source,
|
||||
pluginId,
|
||||
version,
|
||||
archiveSha256: snapshot.sha256,
|
||||
@@ -299,6 +305,7 @@ class PackageStore {
|
||||
);
|
||||
try {
|
||||
this.database.installVersion({
|
||||
source,
|
||||
pluginId,
|
||||
version,
|
||||
manifest,
|
||||
@@ -391,6 +398,7 @@ class PackageStore {
|
||||
if (metadata.pluginId !== pluginEntry.name || metadata.version !== versionEntry.name) {
|
||||
throw new Error("Plugin install metadata does not match its directory");
|
||||
}
|
||||
this.database.assertInstallSource(metadata.pluginId, metadata.source);
|
||||
if (committedVersion && committedVersion.archiveSha256 !== metadata.archiveSha256) {
|
||||
throw new Error("Plugin install archive hash does not match its database record");
|
||||
}
|
||||
@@ -406,6 +414,7 @@ class PackageStore {
|
||||
if (!committedVersion) {
|
||||
const packageDirectory = path.join(versionDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
source: metadata.source,
|
||||
pluginId: metadata.pluginId,
|
||||
version: metadata.version,
|
||||
manifest: validation.manifest,
|
||||
@@ -579,6 +588,12 @@ class PackageStore {
|
||||
}
|
||||
}
|
||||
|
||||
async clearUninstalledPrivateFiles(pluginId) {
|
||||
assertPluginStorageSegment(pluginId, "ID");
|
||||
if (this.database.getActivePlugin(pluginId)) throw new Error("Plugin is still installed");
|
||||
await rm(path.join(this.paths.data, pluginId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async uninstall(pluginId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin) return false;
|
||||
|
||||
@@ -201,7 +201,7 @@ class PluginPermissionEngine {
|
||||
}
|
||||
|
||||
#grantKey(pluginId, permission, declarationHash, resource) {
|
||||
return `${pluginId}\0${permission}\0${declarationHash}\0${resource}`;
|
||||
return `${this.database.privateOwner?.(pluginId) ?? pluginId}\0${permission}\0${declarationHash}\0${resource}`;
|
||||
}
|
||||
|
||||
#sessionGrantKey(sessionId, grantKey) {
|
||||
@@ -219,6 +219,7 @@ class PluginPermissionEngine {
|
||||
) {
|
||||
return Object.freeze({
|
||||
pluginId: context.pluginId,
|
||||
sourceOwner: this.database.privateOwner?.(context.pluginId) ?? context.pluginId,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
@@ -239,6 +240,7 @@ class PluginPermissionEngine {
|
||||
sessionId,
|
||||
) {
|
||||
return grant.pluginId === context.pluginId
|
||||
&& grant.sourceOwner === (this.database.privateOwner?.(context.pluginId) ?? context.pluginId)
|
||||
&& grant.permission === permission
|
||||
&& grant.declarationHash === declarationHash
|
||||
&& (grant.scope !== "session" || grant.sessionId === sessionId)
|
||||
|
||||
@@ -30,6 +30,12 @@ const CHANNELS = Object.freeze({
|
||||
list: "netcatty:plugins:list",
|
||||
permissions: "netcatty:plugins:permissions",
|
||||
revokePermissions: "netcatty:plugins:revoke-permissions",
|
||||
resetSource: "netcatty:plugins:reset-source",
|
||||
sourceBinding: "netcatty:plugins:source-binding",
|
||||
sources: "netcatty:plugins:sources",
|
||||
addSource: "netcatty:plugins:add-source",
|
||||
updateSource: "netcatty:plugins:update-source",
|
||||
removeSource: "netcatty:plugins:remove-source",
|
||||
catalog: "netcatty:plugins:catalog",
|
||||
catalogReadme: "netcatty:plugins:catalog-readme",
|
||||
catalogInstall: "netcatty:plugins:catalog-install",
|
||||
@@ -664,14 +670,18 @@ function registerPluginBridge(ipcMain, options) {
|
||||
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
|
||||
};
|
||||
});
|
||||
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
|
||||
handle(CHANNELS.sources, async () => options.catalogService?.listSources() ?? []);
|
||||
handle(CHANNELS.addSource, async (_manager, payload) => options.catalogService.addSource(payload));
|
||||
handle(CHANNELS.updateSource, async (_manager, payload) => options.catalogService.updateSource(payload));
|
||||
handle(CHANNELS.removeSource, async (_manager, payload) => options.catalogService.removeSource(payload));
|
||||
handle(CHANNELS.catalog, async (_manager, payload) => options.catalogService?.list(payload?.sourceId) ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
|
||||
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.readme(payload?.id, payload?.selectionToken);
|
||||
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
|
||||
});
|
||||
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.install(payload?.id, payload?.selectionToken);
|
||||
return options.catalogService.install(payload?.id, payload?.selectionToken, payload?.sourceId, payload?.acceptedSourceId);
|
||||
});
|
||||
handle(CHANNELS.interactionReady, async (_manager, payload, event) => {
|
||||
if (!options.interactionService) throw new Error("Host interactions are unavailable");
|
||||
@@ -682,6 +692,8 @@ function registerPluginBridge(ipcMain, options) {
|
||||
return options.interactionService.respond(event.sender, payload);
|
||||
});
|
||||
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
||||
handle(CHANNELS.sourceBinding, async (activeManager, payload) => activeManager.database.getRetainedPluginSource(assertManagedPluginId(payload)));
|
||||
handle(CHANNELS.resetSource, async (activeManager, payload) => activeManager.resetUninstalledSource(assertManagedPluginId(payload), payload?.expectedSourceId));
|
||||
const assertManagedPluginId = (payload) => {
|
||||
const id = payload?.pluginId;
|
||||
if (!validateManagedPluginId(id)) {
|
||||
|
||||
@@ -12,6 +12,7 @@ class PluginManager {
|
||||
};
|
||||
this.beforeClose = options.beforeClose ?? null;
|
||||
this.permissionEngine = options.permissionEngine ?? null;
|
||||
this.secretStore = options.secretStore ?? null;
|
||||
this.initialized = false;
|
||||
this.initializePromise = null;
|
||||
this.mutationTail = Promise.resolve();
|
||||
@@ -46,16 +47,37 @@ class PluginManager {
|
||||
return this.database.listPlugins();
|
||||
}
|
||||
|
||||
changeMarketSource(operation, disableSourceId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
const result = operation();
|
||||
if (disableSourceId) {
|
||||
const errors = [];
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (plugin.source?.id !== disableSourceId) continue;
|
||||
try { this.database.setEnabled(plugin.id, false); } catch (error) { errors.push(error); }
|
||||
try { this.contributionService.onPluginDisabled(plugin.id); } catch (error) { errors.push(error); }
|
||||
try { await this.runtimeSupervisor.stop(plugin.id); } catch (error) { errors.push(error); }
|
||||
}
|
||||
if (errors.length) throw new AggregateError(errors, "Source removed, but some plugin shutdown steps failed");
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
install(archivePath, options) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
options?.sourceGuard?.();
|
||||
let stoppedPlugin = null;
|
||||
let plugin;
|
||||
try {
|
||||
plugin = await this.packageStore.install(archivePath, {
|
||||
enable: options?.enable,
|
||||
catalog: options?.catalog,
|
||||
source: options?.source,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
options?.sourceGuard?.();
|
||||
if (!previousPlugin?.enabled) return;
|
||||
this.database.setEnabled(pluginId, false);
|
||||
this.contributionService.onPluginDisabled(pluginId);
|
||||
@@ -157,6 +179,19 @@ class PluginManager {
|
||||
});
|
||||
}
|
||||
|
||||
resetUninstalledSource(pluginId, expectedSourceId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
if (this.database.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing source");
|
||||
if (this.database.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
|
||||
await this.runtimeSupervisor.stop(pluginId);
|
||||
this.permissionEngine?.revokeAll(pluginId);
|
||||
this.secretStore?.clearPluginMemory(pluginId);
|
||||
await this.packageStore.clearUninstalledPrivateFiles(pluginId);
|
||||
this.database.clearUninstalledSourceData(pluginId, expectedSourceId);
|
||||
});
|
||||
}
|
||||
|
||||
uninstall(pluginId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
|
||||
@@ -35,11 +35,16 @@ class PluginSyncSidecarService {
|
||||
sync: setting.sync === true,
|
||||
scope: setting.scope,
|
||||
}));
|
||||
map.set(plugin.id, fields);
|
||||
map.set(this.database.privateOwner(plugin.id), fields);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
#isCurrentOwner(owner, publicId) {
|
||||
return publicId !== null && this.database.publicPluginId(owner) === publicId
|
||||
&& this.#declaredSettingsByPlugin().has(owner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
|
||||
*/
|
||||
@@ -170,12 +175,14 @@ class PluginSyncSidecarService {
|
||||
}
|
||||
}
|
||||
for (const entry of resetCandidates) {
|
||||
const publicId = this.database.publicPluginId(entry.pluginId);
|
||||
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
if (typeof this.contributionService?.resetSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.resetSetting(
|
||||
entry.pluginId,
|
||||
publicId,
|
||||
parsed.settingId,
|
||||
parsed.scopeId,
|
||||
);
|
||||
@@ -183,6 +190,7 @@ class PluginSyncSidecarService {
|
||||
// Validation rejected the reset (e.g. required setting) — still try
|
||||
// the stored-coordinate delete below for stale-scope rows.
|
||||
}
|
||||
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
|
||||
// resetSetting normalizes against the *current* declared scope. After a
|
||||
// scope migration (application → device), that may miss the stale row
|
||||
// still stored under the old coordinates; always delete those too.
|
||||
@@ -201,6 +209,8 @@ class PluginSyncSidecarService {
|
||||
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
|
||||
}
|
||||
for (const entry of safe) {
|
||||
const publicId = this.database.publicPluginId(entry.pluginId);
|
||||
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
|
||||
if (entry.kind !== "settings") continue;
|
||||
const fields = declared.get(entry.pluginId);
|
||||
if (!fields) continue;
|
||||
@@ -218,12 +228,13 @@ class PluginSyncSidecarService {
|
||||
if (typeof this.contributionService?.updateSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.updateSetting(
|
||||
entry.pluginId,
|
||||
publicId,
|
||||
parsed.settingId,
|
||||
entry.value,
|
||||
targetScopeId,
|
||||
{ source: "host" },
|
||||
);
|
||||
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
|
||||
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
|
||||
this.database.setSetting(
|
||||
entry.pluginId,
|
||||
@@ -312,12 +323,14 @@ class PluginSyncSidecarService {
|
||||
*/
|
||||
async hydrateInstalledPluginSettings(pluginId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) return;
|
||||
const declared = this.#declaredSettingsByPlugin().get(pluginId);
|
||||
const owner = this.database.privateOwner(pluginId);
|
||||
const declared = this.#declaredSettingsByPlugin().get(owner);
|
||||
if (!declared) return;
|
||||
const rows = typeof this.database.listSyncSidecars === "function"
|
||||
? this.database.listSyncSidecars(pluginId)
|
||||
: [];
|
||||
for (const entry of rows) {
|
||||
if (!this.#isCurrentOwner(owner, pluginId)) return;
|
||||
if (entry.kind !== "settings") continue;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
@@ -347,8 +360,9 @@ class PluginSyncSidecarService {
|
||||
targetScopeId,
|
||||
{ source: "host" },
|
||||
);
|
||||
if (!this.#isCurrentOwner(owner, pluginId)) return;
|
||||
this.database.setSetting(
|
||||
pluginId,
|
||||
owner,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
@@ -358,7 +372,7 @@ class PluginSyncSidecarService {
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
pluginId,
|
||||
owner,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
@@ -369,14 +383,14 @@ class PluginSyncSidecarService {
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
pluginId,
|
||||
owner,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
|
||||
this.database.deleteSyncSidecar(owner, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
|
||||
@@ -33,7 +33,7 @@ test("sidecar table has no package cascade and survives removePlugin", (context)
|
||||
database.removePlugin("com.example.sync");
|
||||
|
||||
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
|
||||
pluginId: "com.example.sync",
|
||||
pluginId: database.privateOwner("com.example.sync"),
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct" },
|
||||
@@ -68,7 +68,7 @@ test("collectForSync excludes secrets and preserves missing-plugin baselines", (
|
||||
const service = new PluginSyncSidecarService({ database, contributionService });
|
||||
const bundle = service.collectForSync();
|
||||
assert.equal(bundle.version, 1);
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === database.privateOwner("com.missing.plugin")));
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
||||
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
|
||||
// Collected settings must land in the non-cascade table for post-uninstall collect.
|
||||
@@ -187,7 +187,7 @@ test("collectForSync omits deleted settings for installed plugins but keeps miss
|
||||
const bundle = service.collectForSync();
|
||||
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
|
||||
assert.ok(bundle.entries.some((e) => e.value === "ok"));
|
||||
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
|
||||
assert.ok(bundle.entries.some((e) => e.pluginId === database.privateOwner("com.missing.plugin") && e.value === "keep"));
|
||||
});
|
||||
|
||||
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
|
||||
@@ -210,7 +210,7 @@ test("applyFromSync preserves remote updatedAt when writing settings", async (co
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
pluginId: database.privateOwner("com.example.sync"),
|
||||
kind: "settings",
|
||||
key: "com.example.sync.theme\0application\0application",
|
||||
value: "dark",
|
||||
@@ -234,7 +234,7 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.remote.plugin",
|
||||
pluginId: database.privateOwner("com.remote.plugin"),
|
||||
kind: "settings",
|
||||
key: "com.remote.plugin.theme\0application\0application",
|
||||
value: "light",
|
||||
@@ -243,8 +243,8 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
|
||||
});
|
||||
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
|
||||
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
|
||||
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.local.only")));
|
||||
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.remote.plugin") && entry.value === "light"));
|
||||
});
|
||||
|
||||
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
|
||||
@@ -449,7 +449,7 @@ test("applyFromSync does not resurrect missing-plugin keys deleted on another de
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.missing.plugin",
|
||||
pluginId: database.privateOwner("com.missing.plugin"),
|
||||
kind: "settings",
|
||||
key: "com.missing.plugin.kept\0application\0application",
|
||||
value: "remote-new",
|
||||
@@ -498,7 +498,7 @@ test("applyFromSync keeps newer local baseline over older remote (LWW)", async (
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.plugin",
|
||||
pluginId: database.privateOwner("com.example.plugin"),
|
||||
kind: "crdt_baseline",
|
||||
key: "crdt",
|
||||
value: { clock: 50 },
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
|
||||
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
|
||||
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
|
||||
|
||||
function prepareSnapshot(value) {
|
||||
const entries = structuredClone(validateCatalog(value));
|
||||
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
|
||||
return { entries, publicEntries };
|
||||
function prepareSnapshot(value, resources = new Map(), source) {
|
||||
const entries = structuredClone(validateCatalog(value, source?.origin));
|
||||
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([source?.id ?? "official", entry, resources.get(tuple(entry)) ?? null]) }));
|
||||
return { entries, publicEntries, resources: structuredClone(resources) };
|
||||
}
|
||||
|
||||
class RegistryCatalogService extends PluginCatalogService {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
|
||||
this.publicEntries = prepareSnapshot(this.entries, new Map(), this.marketSource).publicEntries;
|
||||
this.resources = new Map();
|
||||
this.source = "builtin";
|
||||
this.error = null;
|
||||
this.refreshing = null;
|
||||
@@ -24,11 +25,12 @@ class RegistryCatalogService extends PluginCatalogService {
|
||||
this.refreshing = (async () => {
|
||||
try {
|
||||
// No redirects: only this exact registry endpoint can replace the catalog.
|
||||
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
|
||||
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
|
||||
const distribution = await fetchDistribution(this.fetch, this.origin);
|
||||
const next = prepareSnapshot(distribution.entries, distribution.resources, this.marketSource);
|
||||
// Publish only after full validation, clones and every token succeeded.
|
||||
this.entries = next.entries;
|
||||
this.publicEntries = next.publicEntries;
|
||||
this.resources = next.resources;
|
||||
this.source = "registry";
|
||||
this.error = null;
|
||||
return true;
|
||||
@@ -50,15 +52,17 @@ class RegistryCatalogService extends PluginCatalogService {
|
||||
}
|
||||
selected(id, token) {
|
||||
const entry = this.get(id);
|
||||
if (typeof token !== "string" || token !== tokenFor(entry)) {
|
||||
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
|
||||
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
artifactSize(entry) { return this.resources.get(tuple(entry))?.artifact?.size; }
|
||||
async readme(id, token) {
|
||||
this.selected(id, token);
|
||||
// Base method captures the pinned entry synchronously before its first await.
|
||||
return super.readme(id);
|
||||
const entry = this.selected(id, token);
|
||||
const resource = this.resources.get(tuple(entry))?.readme;
|
||||
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
|
||||
return (await fetchVerified(resource, this.fetch, this.origin)).toString("utf8");
|
||||
}
|
||||
async install(id, token) {
|
||||
this.selected(id, token);
|
||||
|
||||
@@ -1,153 +1,93 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
|
||||
const builtin = require("./catalog.json");
|
||||
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
|
||||
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
|
||||
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
|
||||
let response = () => new Response(JSON.stringify([builtin[0]]));
|
||||
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
|
||||
let files = realFiles();
|
||||
const service = new RegistryCatalogService({ fetch: async (url, options) => {
|
||||
assert.equal(url, REGISTRY_URL);
|
||||
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
|
||||
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
|
||||
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
|
||||
return response();
|
||||
return responseFor(files, url);
|
||||
} });
|
||||
const online = await service.list();
|
||||
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
|
||||
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
|
||||
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
|
||||
response = () => new Response(invalid);
|
||||
const good = await service.list();
|
||||
assert.equal(good.source, "registry"); assert.equal(good.entries.length, 2);
|
||||
assert.match(good.entries[0].selectionToken, /^[a-f0-9]{64}$/);
|
||||
for (const invalid of ["{", "{}", JSON.stringify({ schemaVersion: 2 }), '{"schemaVersion":1e999}', " ".repeat(16 * 1024 + 1)]) {
|
||||
files = realFiles(); files.set("/v1/snapshot.json", Buffer.from(invalid));
|
||||
const cached = await service.list();
|
||||
assert.equal(cached.source, "cached"); assert.ok(cached.error);
|
||||
assert.deepEqual(cached.entries, online.entries);
|
||||
assert.deepEqual(cached.entries, good.entries);
|
||||
}
|
||||
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
|
||||
assert.match((await service.list()).error, /size limit/);
|
||||
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
|
||||
assert.match((await service.list()).error, /redirects/);
|
||||
response = () => new Response("[]");
|
||||
const empty = await service.list();
|
||||
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
|
||||
files = makeFiles([]);
|
||||
assert.deepEqual((await service.list()).entries, []);
|
||||
});
|
||||
|
||||
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
|
||||
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
|
||||
test("offline first launch returns bundled source examples with no GitHub README fallback", async () => {
|
||||
let calls = 0;
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => { calls++; assert.equal(url, REGISTRY_URL); throw new Error("offline"); }, manager: {
|
||||
install() { assert.fail("offline must not install"); },
|
||||
} });
|
||||
const snapshot = await service.list();
|
||||
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
|
||||
assert.match(snapshot.error, /offline/);
|
||||
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
|
||||
assert.equal(calls, 1);
|
||||
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
|
||||
});
|
||||
|
||||
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
|
||||
let entries = structuredClone(builtin);
|
||||
let requests = 0;
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => {
|
||||
requests++;
|
||||
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
|
||||
return new Response("# Pinned README");
|
||||
}, manager: { install() { assert.fail("changed selection must not install"); } } });
|
||||
test("selection binds source and delivery metadata; changes and withdrawal cannot silently install", async () => {
|
||||
let files = realFiles(); const requests = [];
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); }, manager: { install() { assert.fail("source-only must not install"); } } });
|
||||
const selected = (await service.list()).entries[0];
|
||||
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
|
||||
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
|
||||
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
|
||||
const before = requests;
|
||||
await assert.rejects(service.install(selected.id), /changed/);
|
||||
assert.equal(requests, before);
|
||||
entries[0].description += " updated";
|
||||
const before = requests.length;
|
||||
await assert.rejects(service.install(selected.id), /changed/); assert.equal(requests.length, before);
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /no published package/);
|
||||
const changed = structuredClone(catalog); changed[0].description += " changed";
|
||||
files = makeFiles(changed);
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
|
||||
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
|
||||
entries = [];
|
||||
const next = (await service.list()).entries;
|
||||
assert.deepEqual(next, []);
|
||||
files = makeFiles([]);
|
||||
assert.deepEqual((await service.list()).entries, []);
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
|
||||
});
|
||||
|
||||
test("concurrent list refreshes share one request", async () => {
|
||||
let finish; let calls = 0;
|
||||
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
|
||||
test("concurrent list refreshes share one pointer request and complete document set", async () => {
|
||||
let finish; const requests = []; const files = realFiles();
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
|
||||
const first = service.list(); const second = service.list();
|
||||
finish(new Response(JSON.stringify(builtin)));
|
||||
assert.deepEqual(await first, await second); assert.equal(calls, 1);
|
||||
finish(responseFor(files, REGISTRY_URL));
|
||||
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
|
||||
});
|
||||
|
||||
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
|
||||
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
|
||||
const { createHash } = require("node:crypto");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic validated-by-manager archive");
|
||||
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
const requests = []; let installs = 0;
|
||||
const service = new RegistryCatalogService({
|
||||
getTempFilePath: (name) => path.join(root, name),
|
||||
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
|
||||
manager: { async install(archive, options) {
|
||||
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
|
||||
assert.deepEqual(options.catalog, entry); return { id: entry.id };
|
||||
} },
|
||||
});
|
||||
const selected = (await service.list()).entries[0];
|
||||
await service.install(selected.id, selected.selectionToken);
|
||||
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
|
||||
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
|
||||
});
|
||||
|
||||
|
||||
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
|
||||
let entries = structuredClone(builtin);
|
||||
const requested = [];
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => {
|
||||
requested.push(url); return new Response(JSON.stringify(entries));
|
||||
} });
|
||||
test("invalid sources and unknown JSON fields cannot poison good offline snapshots", async () => {
|
||||
let files = realFiles(); let offline = false;
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => { if (offline) throw new Error("offline"); return responseFor(files, url); } });
|
||||
const good = await service.list();
|
||||
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
|
||||
entries = [{ ...builtin[0], readme }];
|
||||
const snapshot = await service.list();
|
||||
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
|
||||
assert.deepEqual(snapshot.entries, good.entries);
|
||||
}
|
||||
entries = [{ ...builtin[0], repository: "example/.." }];
|
||||
assert.match((await service.list()).error, /escapes the pinned commit/);
|
||||
assert.ok(requested.every((url) => url === REGISTRY_URL));
|
||||
});
|
||||
|
||||
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
|
||||
let response = JSON.stringify(builtin);
|
||||
const service = new RegistryCatalogService({ fetch: async () => {
|
||||
if (response === null) throw new Error("offline");
|
||||
return new Response(response);
|
||||
} });
|
||||
const good = await service.list();
|
||||
let nested = null;
|
||||
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
|
||||
const nonfinite = structuredClone(builtin[0]);
|
||||
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
|
||||
for (const invalid of [
|
||||
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
|
||||
JSON.stringify([{ ...builtin[0], unknownField: true }]),
|
||||
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
|
||||
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
|
||||
]) {
|
||||
response = invalid;
|
||||
const rejected = await service.list();
|
||||
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
|
||||
let nested = null; for (let i = 0; i < 130; i++) nested = { child: nested };
|
||||
const bad = [
|
||||
{ ...catalog[0], readme: "\t../main/README.md" }, { ...catalog[0], repository: "example/.." },
|
||||
{ ...catalog[0], unknownNestedField: nested }, { ...catalog[0], unknownField: true },
|
||||
{ ...catalog[0], readmeAsset: { url: "https://plugins.netcatty.app/a", sha256: "a".repeat(64) } },
|
||||
];
|
||||
for (const entry of bad) {
|
||||
files = makeFiles([entry]); offline = false;
|
||||
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
|
||||
assert.deepEqual(rejected.entries, good.entries);
|
||||
response = null;
|
||||
const offline = await service.list();
|
||||
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
|
||||
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
|
||||
}
|
||||
});
|
||||
|
||||
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
|
||||
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
|
||||
const good = await service.list();
|
||||
test("snapshot preparation failure leaves private resources and public tokens unchanged", async () => {
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(realFiles(), url) });
|
||||
const good = await service.list(); const resources = service.resources;
|
||||
const originalClone = global.structuredClone;
|
||||
try {
|
||||
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
|
||||
assert.equal(await service.refresh(), false);
|
||||
} finally { global.structuredClone = originalClone; }
|
||||
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
|
||||
finally { global.structuredClone = originalClone; }
|
||||
assert.equal(service.resources, resources);
|
||||
service.fetch = async () => { throw new Error("offline"); };
|
||||
assert.deepEqual((await service.list()).entries, good.entries);
|
||||
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
|
||||
});
|
||||
@@ -41,7 +41,12 @@ class PluginSecretStore {
|
||||
}
|
||||
|
||||
#stashKey(pluginId, key) {
|
||||
return `${pluginId}\0${key}`;
|
||||
return `${this.database.privateOwner?.(pluginId) ?? pluginId}\0${key}`;
|
||||
}
|
||||
|
||||
clearPluginMemory(pluginId) {
|
||||
const prefix = `${this.database.privateOwner?.(pluginId) ?? pluginId}\0`;
|
||||
for (const key of this.#overwriteStash.keys()) if (key.startsWith(prefix)) this.#overwriteStash.delete(key);
|
||||
}
|
||||
|
||||
#assertAvailable() {
|
||||
@@ -163,7 +168,7 @@ class PluginSecretStore {
|
||||
} else {
|
||||
deleted = this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
|
||||
}
|
||||
const pluginPrefix = `${pluginId}\0`;
|
||||
const pluginPrefix = `${this.database.privateOwner?.(pluginId) ?? pluginId}\0`;
|
||||
for (const stashKey of [...this.#overwriteStash.keys()]) {
|
||||
if (!stashKey.startsWith(pluginPrefix)) continue;
|
||||
const secretKey = stashKey.slice(pluginPrefix.length);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { thirdPartySource } = require("./sourceIdentity.cjs");
|
||||
const { catalog, realFiles, makeFiles, digest } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
const first = thirdPartySource("First", "https://first.example.com");
|
||||
const second = thirdPartySource("Netcatty", "https://second.example.com");
|
||||
|
||||
test("identical mirrored catalogs never share selection tokens or offline cache", async () => {
|
||||
const service = (source) => new RegistryCatalogService({ source, fetch: async (url) => {
|
||||
assert.equal(new URL(url).origin, source.origin);
|
||||
return new Response(realFiles().get(new URL(url).pathname));
|
||||
} });
|
||||
const a = service(first); const b = service(second);
|
||||
const entryA = (await a.list()).entries[0];
|
||||
const entryB = (await b.list()).entries[0];
|
||||
assert.notEqual(entryA.selectionToken, entryB.selectionToken);
|
||||
await assert.rejects(b.readme(entryB.id, entryA.selectionToken), /changed/);
|
||||
const offline = new RegistryCatalogService({ source: second, fetch: async () => { throw new Error("offline"); } });
|
||||
assert.deepEqual((await offline.list()).entries, [], "third-party failure never substitutes the official bundled catalog");
|
||||
});
|
||||
|
||||
test("delivery v2 binds package bytes, size, source and fresh non-withdrawn index before install", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "source-catalog-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic package bytes; manager is stubbed");
|
||||
const sha256 = digest(bytes);
|
||||
const asset = `/assets/${sha256}.ncpkg`;
|
||||
const entry = { ...structuredClone(catalog[0]), artifact: { url: `${first.origin}${asset}`, sha256 } };
|
||||
let files = makeFiles([entry], (index, delivery) => {
|
||||
index.plugins[0].versions[0].distribution = "release";
|
||||
delivery.schemaVersion = 2;
|
||||
delivery.plugins[0].artifact = { path: asset, sha256, size: bytes.length };
|
||||
});
|
||||
files.set(asset, bytes);
|
||||
let calls = 0;
|
||||
const service = new RegistryCatalogService({ source: first, getTempFilePath: (name) => path.join(root, name), fetch: async (url) => {
|
||||
assert.equal(new URL(url).origin, first.origin);
|
||||
const body = files.get(new URL(url).pathname);
|
||||
return body ? new Response(body) : new Response("missing", { status: 404 });
|
||||
}, manager: { install(archive, options) {
|
||||
assert.deepEqual(fs.readFileSync(archive), bytes);
|
||||
assert.equal(options.enable, false);
|
||||
assert.equal(options.source.id, first.id);
|
||||
assert.equal(options.catalog.artifact.sha256, sha256);
|
||||
calls++;
|
||||
} } });
|
||||
const selected = (await service.list()).entries[0];
|
||||
assert.ok(selected, service.error);
|
||||
await service.install(selected.id, selected.selectionToken);
|
||||
assert.equal(calls, 1);
|
||||
files = makeFiles([]);
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const dns = require("node:dns/promises");
|
||||
const https = require("node:https");
|
||||
const { BlockList, isIPv4 } = require("node:net");
|
||||
const { Readable } = require("node:stream");
|
||||
const { canonicalSourceOrigin } = require("./sourceIdentity.cjs");
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [address, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]]) blocked.addSubnet(address, prefix, "ipv4");
|
||||
function isPublicAddress(address) { return isIPv4(address) && !blocked.check(address, "ipv4"); }
|
||||
|
||||
// This transport is deliberately separate from plugin network capability access.
|
||||
// No cookies, credentials, redirects or proxy. DNS is validated and pinned to
|
||||
// the actual connection, rather than checked separately before another lookup.
|
||||
function createSourceFetch(origin, options = {}) {
|
||||
canonicalSourceOrigin(origin);
|
||||
const resolve4 = options.resolve4 ?? dns.resolve4;
|
||||
const request = options.request ?? https.get;
|
||||
return async (value, init = {}) => {
|
||||
const url = new URL(value);
|
||||
if (url.origin !== origin || url.username || url.password || url.search || url.hash
|
||||
|| /[\\%\s]/u.test(value) || url.href !== value
|
||||
|| !/^\/(?:v1\/snapshot\.json|snapshots\/[a-f0-9]{64}\/(?:catalog|index|delivery)\.json|assets\/[a-f0-9]{64}\.(?:md|png|webp|ncpkg))$/u.test(url.pathname)) throw new Error("Source resource is outside its static origin or protocol paths");
|
||||
init.signal?.throwIfAborted();
|
||||
// IPv4-only initial transport: IPv6-only sources fail visibly, never fall
|
||||
// back to an unchecked resolver. This keeps address classification explicit.
|
||||
const addresses = await new Promise((resolve, reject) => {
|
||||
const abort = () => reject(init.signal.reason);
|
||||
init.signal?.addEventListener("abort", abort, { once: true });
|
||||
Promise.resolve().then(() => resolve4(url.hostname)).then(resolve, reject)
|
||||
.finally(() => init.signal?.removeEventListener("abort", abort));
|
||||
});
|
||||
init.signal?.throwIfAborted();
|
||||
if (!addresses.length || addresses.some((address) => !isPublicAddress(address))) throw new Error("Source DNS resolves to a non-public address");
|
||||
const address = addresses[0];
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(url, {
|
||||
agent: false, signal: init.signal,
|
||||
lookup(_host, opts, callback) {
|
||||
if (opts?.all) callback(null, [{ address, family: 4 }]);
|
||||
else callback(null, address, 4);
|
||||
},
|
||||
headers: { accept: "application/json, text/plain, application/octet-stream" },
|
||||
}, (response) => {
|
||||
try {
|
||||
const status = response.statusCode ?? 502;
|
||||
// fetchBounded rejects redirects/non-2xx and cancels the stream.
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(response.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||
if ([204, 205, 304].includes(status)) { response.destroy(); resolve(new Response(null, { status, headers })); }
|
||||
else resolve(new Response(Readable.toWeb(response), { status, headers }));
|
||||
} catch (error) { response.destroy(); reject(error); }
|
||||
});
|
||||
req.once("error", reject);
|
||||
});
|
||||
};
|
||||
}
|
||||
module.exports = { createSourceFetch, isPublicAddress };
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createSourceFetch, isPublicAddress } = require("./sourceFetch.cjs");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { Readable } = require("node:stream");
|
||||
|
||||
test("source transport refuses non-public resolution before any HTTP request", async () => {
|
||||
let requests = 0;
|
||||
for (const address of ["127.0.0.1", "10.1.2.3", "169.254.169.254", "172.16.1.1", "192.168.1.1", "100.64.1.1", "224.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
||||
assert.equal(isPublicAddress(address), false);
|
||||
const fetch = createSourceFetch("https://example.com", { resolve4: async () => [address], request() { requests++; } });
|
||||
await assert.rejects(fetch("https://example.com/v1/snapshot.json"), /non-public/);
|
||||
}
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(isPublicAddress("1.1.1.1"), true);
|
||||
});
|
||||
|
||||
test("static resources cannot redirect requests to unrelated origins or arbitrary paths", async () => {
|
||||
let resolutions = 0;
|
||||
const fetch = createSourceFetch("https://example.com", { resolve4: async () => { resolutions++; return ["1.1.1.1"]; } });
|
||||
for (const value of ["https://other.com/v1/snapshot.json", "https://example.com/admin", "https://example.com/assets/%2e%2e/secret", "https://example.com/v1/snapshot.json?token=secret", "https://user:pass@example.com/v1/snapshot.json"]) await assert.rejects(fetch(value));
|
||||
assert.equal(resolutions, 0);
|
||||
});
|
||||
|
||||
test("the HTTPS connection uses the checked address without a second DNS lookup or credentials", async () => {
|
||||
let resolutions = 0;
|
||||
const fetch = createSourceFetch("https://example.com", {
|
||||
resolve4: async () => { resolutions++; return resolutions === 1 ? ["1.1.1.1"] : ["127.0.0.1"]; },
|
||||
request(url, options, callback) {
|
||||
assert.equal(url.hostname, "example.com");
|
||||
assert.equal(options.agent, false);
|
||||
assert.equal(options.headers.cookie, undefined);
|
||||
assert.equal(options.headers.authorization, undefined);
|
||||
options.lookup("example.com", { all: true }, (error, addresses) => {
|
||||
assert.equal(error, null);
|
||||
assert.deepEqual(addresses, [{ address: "1.1.1.1", family: 4 }]);
|
||||
});
|
||||
const response = Readable.from([Buffer.from("synthetic metadata")]);
|
||||
response.statusCode = 200; response.headers = {};
|
||||
callback(response);
|
||||
return new EventEmitter();
|
||||
},
|
||||
});
|
||||
assert.equal(await (await fetch("https://example.com/v1/snapshot.json")).text(), "synthetic metadata");
|
||||
assert.equal(resolutions, 1);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const { isIP } = require("node:net");
|
||||
|
||||
const OFFICIAL_ORIGIN = "https://plugins.netcatty.app";
|
||||
const OFFICIAL_SOURCE = Object.freeze({ id: "official", kind: "official", name: "Netcatty marketplace", origin: OFFICIAL_ORIGIN });
|
||||
const LOCAL_SOURCE = Object.freeze({ id: "local", kind: "local", name: "Local import", origin: null });
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
// Source identity is host-owned. Neither a manifest nor a catalog supplies kind/id.
|
||||
function canonicalSourceOrigin(value) {
|
||||
if (typeof value !== "string" || value.length > 2048 || /[\s\\%?#]/u.test(value)) throw new Error("Use a public HTTPS origin without credentials, query or fragment");
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port
|
||||
|| !["/", "/v1/snapshot.json"].includes(url.pathname)) throw new Error("Use an HTTPS origin or its /v1/snapshot.json endpoint");
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (isIP(hostname) || hostname.includes(":") || !hostname.includes(".") || hostname.endsWith(".")
|
||||
|| /(?:^|\.)(?:localhost|local|internal|invalid|test)$/u.test(hostname)) throw new Error("Market sources must use a public DNS hostname");
|
||||
// Explicitly reject textual credentials/normalization tricks before canonicalizing case.
|
||||
if (!/^https:\/\/[^/@]+(?:\/|\/v1\/snapshot\.json)?$/iu.test(value)) throw new Error("Invalid market source address");
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function thirdPartySource(name, address) {
|
||||
if (typeof name !== "string" || !name.trim() || name.length > 100 || /[\u0000-\u001f\u007f]/u.test(name)) throw new Error("Source name must contain 1-100 printable characters");
|
||||
const origin = canonicalSourceOrigin(address);
|
||||
if (origin === OFFICIAL_ORIGIN) throw new Error("The official source is already built in");
|
||||
return Object.freeze({ id: `third-party:${hash(origin)}`, kind: "third-party", name: name.trim(), origin });
|
||||
}
|
||||
|
||||
function assertSource(source) {
|
||||
if (!source || typeof source !== "object") throw new Error("Missing host source identity");
|
||||
const trusted = source.id === "official" ? OFFICIAL_SOURCE : source.id === "local" ? LOCAL_SOURCE : thirdPartySource(source.name, source.origin);
|
||||
if (["id", "kind", "origin"].some((key) => trusted[key] !== source[key])) throw new Error("Invalid host source identity");
|
||||
return { ...trusted };
|
||||
}
|
||||
|
||||
function privateOwner(source, pluginId) {
|
||||
if (typeof pluginId !== "string" || !/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) throw new Error("Invalid plugin identity");
|
||||
return `source:${hash(assertSource(source).id)}:${pluginId}`;
|
||||
}
|
||||
|
||||
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner };
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner } = require("./sourceIdentity.cjs");
|
||||
|
||||
test("renames preserve source identity; public plugin IDs do not define private ownership", () => {
|
||||
const a = thirdPartySource("First", "https://EXAMPLE.com/v1/snapshot.json");
|
||||
const renamed = thirdPartySource("Renamed", "https://example.com/");
|
||||
assert.equal(a.id, renamed.id);
|
||||
const owners = [a, thirdPartySource("Other", "https://other.example.com"), OFFICIAL_SOURCE, LOCAL_SOURCE].map((source) => privateOwner(source, "example.plugin"));
|
||||
assert.equal(new Set(owners).size, 4);
|
||||
assert.equal(privateOwner(a, "example.plugin"), privateOwner(renamed, "example.plugin"));
|
||||
assert.throws(() => privateOwner(a, owners[0]));
|
||||
});
|
||||
|
||||
test("remote metadata cannot promote a source to official or local", () => {
|
||||
const source = thirdPartySource("Netcatty", "https://example.com");
|
||||
for (const kind of ["official", "local"]) assert.throws(() => assertSource({ ...source, kind }));
|
||||
assert.throws(() => assertSource({ ...source, id: "official" }));
|
||||
assert.throws(() => thirdPartySource("Mirror", "https://plugins.netcatty.app"));
|
||||
});
|
||||
|
||||
test("source addresses exclude secrets, ambiguous paths and local literals", () => {
|
||||
for (const url of ["http://example.com", "https://user:secret@example.com", "https://example.com?token=x", "https://example.com/#x", "https://127.0.0.1", "https://[::1]", "https://localhost", "https://a.local", "https://example.com:8443", "https://example.com/../v1/snapshot.json", "https://example.com/%2e", "https://example.com\\@other.com", "https://example.com."]) {
|
||||
assert.throws(() => canonicalSourceOrigin(url), url);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { thirdPartySource, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
const { settingsSidecarKey } = require("./pluginSyncSidecarHelpers.cjs");
|
||||
const id = "example.plugin";
|
||||
const first = thirdPartySource("First", "https://first.example.com");
|
||||
const second = thirdPartySource("Second", "https://second.example.com");
|
||||
function install(db, source) {
|
||||
db.installVersion({ pluginId: id, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: "example.plugin/1.0.0", source });
|
||||
}
|
||||
test("schema-4 local provenance survives upgrade and uninstall until explicit reset", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-upgrade-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "db.sqlite");
|
||||
let db = new PluginDatabase(file);
|
||||
install(db, LOCAL_SOURCE);
|
||||
db.setSetting(id, "private", "application", "application", "retained");
|
||||
db.db.exec("DROP TABLE plugin_source_bindings; PRAGMA user_version = 4");
|
||||
db.close();
|
||||
db = new PluginDatabase(file);
|
||||
t.after(() => db.close());
|
||||
db.removePlugin(id);
|
||||
assert.equal(db.getRetainedPluginSource(id).id, "local");
|
||||
assert.throws(() => install(db, first), /source differs/);
|
||||
assert.equal(db.getSetting(id, "private", "application", "application"), "retained");
|
||||
db.clearUninstalledSourceData(id, "local");
|
||||
install(db, first);
|
||||
assert.equal(db.getSetting(id, "private", "application", "application"), undefined);
|
||||
});
|
||||
|
||||
test("failed provenance backfill does not publish the new schema version", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-upgrade-failure-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "db.sqlite");
|
||||
const db = new PluginDatabase(file);
|
||||
t.after(() => db.close());
|
||||
install(db, LOCAL_SOURCE);
|
||||
db.db.exec("DELETE FROM plugin_source_bindings; PRAGMA user_version = 5; CREATE TRIGGER fail_source_backfill BEFORE INSERT ON plugin_source_bindings BEGIN SELECT RAISE(ABORT, 'synthetic backfill failure'); END;");
|
||||
assert.throws(() => new PluginDatabase(file, { database: db.db }), /synthetic backfill failure/);
|
||||
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 5);
|
||||
db.db.exec("DROP TRIGGER fail_source_backfill");
|
||||
new PluginDatabase(file, { database: db.db });
|
||||
db.removePlugin(id);
|
||||
assert.equal(db.getRetainedPluginSource(id).id, "local");
|
||||
assert.throws(() => install(db, first), /source differs/);
|
||||
});
|
||||
test("same ID cannot change source during install, uninstall or database restart without explicit private reset", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-isolation-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "db.sqlite");
|
||||
let db = new PluginDatabase(file);
|
||||
install(db, first);
|
||||
db.setValue(id, "key", "old KV");
|
||||
assert.deepEqual(db.listKeys(id), ["key"]);
|
||||
db.setSetting(id, "greeting", "application", "application", "old setting");
|
||||
db.setViewState(id, "view", "self", { private: true });
|
||||
db.upsertPermissionGrant({ pluginId: id, permission: "views", resource: "self", resourceKind: "exact", declarationHash: "same-code" });
|
||||
db.upsertSecret({ pluginId: id, key: "secret", secretRef: "old-ref", ciphertext: Buffer.from("ciphertext") });
|
||||
db.setSyncSidecar(id, "account_baseline", "account", "old baseline");
|
||||
assert.equal(db.getSecretByRef(id, "old-ref").pluginId, id);
|
||||
assert.deepEqual(db.listPluginIdsWithSecretKeyPrefix("secret"), [id], "host provider recovery receives the current public plugin ID, not its opaque storage owner");
|
||||
assert.equal(db.listPermissionGrants(id)[0].pluginId, id);
|
||||
assert.throws(() => install(db, second), /source differs/);
|
||||
assert.throws(() => db.clearUninstalledSourceData(id, first.id), /Uninstall/);
|
||||
db.removePlugin(id);
|
||||
db.close();
|
||||
db = new PluginDatabase(file);
|
||||
t.after(() => db.close());
|
||||
assert.throws(() => install(db, second), /source differs/);
|
||||
assert.throws(() => install(db, LOCAL_SOURCE), /source differs/);
|
||||
assert.throws(() => db.clearUninstalledSourceData(id, second.id), /changed/);
|
||||
db.clearUninstalledSourceData(id, first.id);
|
||||
install(db, second);
|
||||
assert.equal(db.getActivePlugin(id).source.id, second.id);
|
||||
assert.equal(db.getValue(id, "key"), undefined);
|
||||
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
|
||||
assert.equal(db.getViewState(id, "view", "self"), undefined);
|
||||
assert.deepEqual(db.listPermissionGrants(id), []);
|
||||
assert.equal(db.getSecretByRef(id, "old-ref"), null);
|
||||
assert.equal(db.getSyncSidecar(id, "account_baseline", "account"), undefined);
|
||||
});
|
||||
|
||||
test("old-source cloud settings remain orphaned and cannot hydrate a new source", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-sync-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const db = new PluginDatabase(path.join(root, "db.sqlite"));
|
||||
t.after(() => db.close());
|
||||
install(db, first);
|
||||
const oldOwner = db.privateOwner(id);
|
||||
db.removePlugin(id);
|
||||
db.clearUninstalledSourceData(id, first.id);
|
||||
install(db, second);
|
||||
const calls = [];
|
||||
const service = new PluginSyncSidecarService({ database: db, contributionService: {
|
||||
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
|
||||
updateSetting: (...args) => calls.push(args),
|
||||
} });
|
||||
const entry = { pluginId: oldOwner, kind: "settings", key: settingsSidecarKey("greeting", "application", "application"), value: "old remote data", updatedAt: 1000 };
|
||||
await service.applyFromSync({ version: 1, entries: [entry] });
|
||||
await service.hydrateInstalledPluginSettings(id);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
|
||||
await service.applyFromSync({ version: 1, entries: [{ ...entry, pluginId: db.privateOwner(id), value: "new source" }] });
|
||||
assert.equal(calls[0][0], id);
|
||||
assert.equal(db.getSetting(id, "greeting", "application", "application"), "new source");
|
||||
});
|
||||
|
||||
test("in-flight sync cannot apply local B after A yields and B changes source", { timeout: 10000 }, async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-sync-race-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const db = new PluginDatabase(path.join(root, "db.sqlite"));
|
||||
t.after(() => db.close());
|
||||
const a = "aa.plugin", b = "bb.plugin";
|
||||
const put = (pluginId, source) => db.installVersion({ pluginId, version: "1.0.0", manifest: {
|
||||
id: pluginId, version: "1.0.0", main: { browser: "index.js" },
|
||||
contributes: { settings: [{ id: `${pluginId}.greeting`, label: "Greeting", control: "text", scope: "application", sync: true }] },
|
||||
}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
|
||||
put(a, LOCAL_SOURCE); put(b, LOCAL_SOURCE);
|
||||
let release, entered;
|
||||
const waiting = new Promise(resolve => { entered = resolve; });
|
||||
const paused = new Promise(resolve => { release = resolve; });
|
||||
const calls = [];
|
||||
const contributionService = new PluginContributionService({ database: db, runtimeSupervisor: {
|
||||
async notify(pluginId) {
|
||||
calls.push(pluginId);
|
||||
if (pluginId === a) { entered(); await paused; }
|
||||
},
|
||||
} });
|
||||
const service = new PluginSyncSidecarService({ database: db, contributionService });
|
||||
const entries = [a, b].map(pluginId => ({ pluginId: db.privateOwner(pluginId), kind: "settings", key: settingsSidecarKey(`${pluginId}.greeting`, "application", "application"), value: `OLD ${pluginId}`, updatedAt: 1000 }));
|
||||
const applying = service.applyFromSync({ version: 1, entries });
|
||||
await waiting;
|
||||
db.removePlugin(b); db.clearUninstalledSourceData(b, "local"); put(b, first);
|
||||
release(); await applying;
|
||||
assert.deepEqual(calls, [a]);
|
||||
assert.equal(db.getSetting(b, `${b}.greeting`, "application", "application"), undefined);
|
||||
});
|
||||
|
||||
for (const mode of ["apply", "hydrate", "reset"]) {
|
||||
test(`${mode} drops post-await writes after the current plugin changes source`, async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-await-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const db = new PluginDatabase(path.join(root, "db.sqlite"));
|
||||
t.after(() => db.close());
|
||||
install(db, LOCAL_SOURCE);
|
||||
const oldOwner = db.privateOwner(id);
|
||||
const key = settingsSidecarKey("greeting", "application", "application");
|
||||
db.setSyncSidecar(id, "settings", key, "OLD", 1000);
|
||||
db.setSetting(id, "greeting", "application", "application", "OLD", 1000);
|
||||
let release, entered;
|
||||
const waiting = new Promise(resolve => { entered = resolve; });
|
||||
const paused = new Promise(resolve => { release = resolve; });
|
||||
const service = new PluginSyncSidecarService({ database: db, contributionService: {
|
||||
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
|
||||
async updateSetting(pluginId, settingId, value) {
|
||||
db.setSetting(pluginId, settingId, "application", "application", value);
|
||||
entered(); await paused;
|
||||
},
|
||||
async resetSetting(pluginId, settingId) {
|
||||
db.deleteSetting(pluginId, settingId, "application", "application");
|
||||
entered(); await paused;
|
||||
},
|
||||
} });
|
||||
const applying = mode === "hydrate" ? service.hydrateInstalledPluginSettings(id)
|
||||
: service.applyFromSync({ version: 1, entries: mode === "reset" ? [] : [{ pluginId: oldOwner, kind: "settings", key, value: "OLD REMOTE", updatedAt: 2000 }] });
|
||||
await waiting;
|
||||
db.removePlugin(id); db.clearUninstalledSourceData(id, "local"); install(db, first);
|
||||
db.setSetting(id, "greeting", "application", "application", "NEW", 3000);
|
||||
release(); await applying;
|
||||
assert.equal(db.getSetting(id, "greeting", "application", "application"), "NEW");
|
||||
assert.equal(db.listAllSettings().some(row => row.pluginId === oldOwner), false, "a completed reset must not be undone by stale LWW writes");
|
||||
});
|
||||
}
|
||||
|
||||
test("local owners are encoded and raw remote IDs never resolve into another source", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-local-owner-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const db = new PluginDatabase(path.join(root, "db.sqlite"));
|
||||
t.after(() => db.close());
|
||||
install(db, LOCAL_SOURCE);
|
||||
const owner = db.privateOwner(id);
|
||||
assert.notEqual(owner, id);
|
||||
assert.equal(db.publicPluginId(id), null);
|
||||
assert.equal(db.publicPluginId(owner), id);
|
||||
db.removePlugin(id); db.clearUninstalledSourceData(id, "local"); install(db, first);
|
||||
assert.equal(db.publicPluginId(owner), null);
|
||||
const calls = [];
|
||||
const service = new PluginSyncSidecarService({ database: db, contributionService: {
|
||||
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
|
||||
updateSetting: (...args) => calls.push(args),
|
||||
} });
|
||||
await service.applyFromSync({ version: 1, entries: [{ pluginId: id, kind: "settings", key: settingsSidecarKey("greeting", "application", "application"), value: "RAW OLD LOCAL", updatedAt: 1000 }] });
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
|
||||
});
|
||||
|
||||
test("schema-6 raw local data converts atomically without a runtime fallback", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-owner-upgrade-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "db.sqlite");
|
||||
let db = new PluginDatabase(file);
|
||||
install(db, LOCAL_SOURCE);
|
||||
db.setSetting(id, "greeting", "application", "application", "RETAIN");
|
||||
db.setValue(id, "key", "KEEP KV");
|
||||
const owner = db.privateOwner(id);
|
||||
db.db.prepare("UPDATE plugin_settings SET plugin_id = ? WHERE plugin_id = ?").run(id, owner);
|
||||
db.db.prepare("UPDATE plugin_kv SET key = 'key' WHERE plugin_id = ?").run(id);
|
||||
db.db.exec("PRAGMA user_version = 6");
|
||||
db.close();
|
||||
db = new PluginDatabase(file); t.after(() => db.close());
|
||||
assert.equal(db.getSetting(id, "greeting", "application", "application"), "RETAIN");
|
||||
assert.equal(db.getValue(id, "key"), "KEEP KV");
|
||||
assert.deepEqual(db.listKeys(id), ["key"]);
|
||||
assert.equal(db.listAllSettings()[0].pluginId, owner);
|
||||
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 7);
|
||||
});
|
||||
|
||||
test("owner conversion conflict preserves schema-6 rows and does not publish schema 7", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-owner-conflict-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "db.sqlite");
|
||||
const db = new PluginDatabase(file); t.after(() => db.close());
|
||||
install(db, LOCAL_SOURCE);
|
||||
db.setSetting(id, "greeting", "application", "application", "ENCODED");
|
||||
db.db.prepare("INSERT INTO plugin_settings(plugin_id,setting_id,scope,scope_id,value_json,updated_at) VALUES(?, 'greeting', 'application', 'application', ?, 1)").run(id, JSON.stringify("RAW"));
|
||||
db.db.exec("PRAGMA user_version = 6");
|
||||
assert.throws(() => new PluginDatabase(file, { database: db.db }), /UNIQUE constraint/);
|
||||
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 6);
|
||||
assert.equal(db.db.prepare("SELECT count(*) AS count FROM plugin_settings").get().count, 2);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
const { OFFICIAL_SOURCE, thirdPartySource, assertSource } = require("./sourceIdentity.cjs");
|
||||
const PREFERENCE_KEY = "market-sources-v1";
|
||||
|
||||
// Only this host service creates source records. Remote catalogs cannot mutate them.
|
||||
class PluginSourceRegistry {
|
||||
constructor(database) { this.database = database; }
|
||||
#read() {
|
||||
const row = this.database.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = ?").get(PREFERENCE_KEY);
|
||||
if (!row) return [];
|
||||
const values = JSON.parse(row.value_json);
|
||||
if (!Array.isArray(values) || values.length > 32) throw new Error("Invalid saved plugin sources");
|
||||
const ids = new Set();
|
||||
return values.map((value) => {
|
||||
const source = assertSource(value);
|
||||
if (source.kind !== "third-party" || ids.has(source.id) || typeof value.enabled !== "boolean") throw new Error("Invalid saved plugin source");
|
||||
ids.add(source.id);
|
||||
return { ...source, enabled: value.enabled };
|
||||
});
|
||||
}
|
||||
#write(values) {
|
||||
this.database.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
|
||||
.run(PREFERENCE_KEY, JSON.stringify(values), this.database.clock());
|
||||
}
|
||||
list() { return [{ ...OFFICIAL_SOURCE, enabled: true }, ...this.#read()]; }
|
||||
get(id) {
|
||||
const source = this.list().find((item) => item.id === id);
|
||||
if (!source || !source.enabled) throw new Error("This market source was removed or disabled; no alternate source will be used");
|
||||
return source;
|
||||
}
|
||||
add(name, address) {
|
||||
const source = thirdPartySource(name, address);
|
||||
this.database.transaction(() => {
|
||||
const values = this.#read();
|
||||
if (values.some((item) => item.id === source.id)) throw new Error("This market source already exists");
|
||||
if (values.length >= 32) throw new Error("At most 32 third-party sources are supported");
|
||||
this.#write([...values, { ...source, enabled: true }]);
|
||||
});
|
||||
return this.get(source.id);
|
||||
}
|
||||
update(id, name, enabled) {
|
||||
if (typeof enabled !== "boolean") throw new Error("Source enabled state must be boolean");
|
||||
this.database.transaction(() => {
|
||||
const values = this.#read();
|
||||
const index = values.findIndex((source) => source.id === id);
|
||||
if (index < 0) throw new Error("Third-party source does not exist");
|
||||
values[index] = { ...thirdPartySource(name, values[index].origin), enabled };
|
||||
this.#write(values);
|
||||
});
|
||||
return this.list().find((source) => source.id === id);
|
||||
}
|
||||
remove(id) {
|
||||
this.database.transaction(() => {
|
||||
const values = this.#read();
|
||||
if (!values.some((source) => source.id === id)) throw new Error("Third-party source does not exist");
|
||||
this.#write(values.filter((source) => source.id !== id));
|
||||
});
|
||||
// Package lifetime and optional disabling belong to serialized PluginManager.
|
||||
}
|
||||
}
|
||||
module.exports = { PluginSourceRegistry };
|
||||
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
|
||||
|
||||
test("sources persist, rename without changing identity, and fail closed when disabled or removed", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sources-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "db.sqlite");
|
||||
let db = new PluginDatabase(file);
|
||||
let registry = new PluginSourceRegistry(db);
|
||||
assert.equal(registry.list().length, 1);
|
||||
const source = registry.add("Example", "https://example.com");
|
||||
assert.throws(() => registry.add("Duplicate", "https://example.com/v1/snapshot.json"));
|
||||
registry.update(source.id, "Renamed", false);
|
||||
assert.throws(() => registry.get(source.id), /removed or disabled/);
|
||||
db.close();
|
||||
db = new PluginDatabase(file);
|
||||
t.after(() => db.close());
|
||||
registry = new PluginSourceRegistry(db);
|
||||
assert.equal(registry.list()[1].name, "Renamed");
|
||||
registry.update(source.id, "Again", true);
|
||||
assert.equal(registry.get(source.id).origin, source.origin);
|
||||
registry.remove(source.id);
|
||||
assert.throws(() => registry.get(source.id), /removed or disabled/);
|
||||
assert.throws(() => registry.remove("official"));
|
||||
assert.throws(() => registry.update("official", "Impersonated", false));
|
||||
assert.equal(registry.get("official").origin, "https://plugins.netcatty.app");
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { assertDownloadUrl, fetchBounded, validateCatalog } = require("./catalogService.cjs");
|
||||
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
|
||||
const { fetchVerified, assertAsset } = require("./distributionSnapshot.cjs");
|
||||
const { read, digest, catalog, delivery, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
|
||||
test("exact registry PR3 bootstrap hashes and all README/icon assets verify without GitHub requests", async () => {
|
||||
assert.equal(digest(read("snapshot.json")), "2878885057a01585e34897c226bcfd81615345d843ea94297980de7e397b4617");
|
||||
assert.equal(digest(read("delivery.json")), "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55");
|
||||
const requests = []; const files = realFiles();
|
||||
const fetch = async (url, options) => {
|
||||
requests.push(url); assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
|
||||
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
|
||||
return responseFor(files, url);
|
||||
};
|
||||
const service = new RegistryCatalogService({ fetch });
|
||||
const snapshot = await service.list();
|
||||
assert.equal(snapshot.error, null); assert.equal(snapshot.source, "registry");
|
||||
assert.equal(requests.length, 4); assert.equal(requests[0], REGISTRY_URL);
|
||||
for (const entry of snapshot.entries) {
|
||||
const resource = delivery.plugins.find((item) => item.id === entry.id);
|
||||
assert.equal(await service.readme(entry.id, entry.selectionToken), read(resource.readme.path.slice(1)).toString());
|
||||
for (const icon of [resource.iconLight, resource.iconDark]) if (icon) {
|
||||
assertAsset(icon, ["png", "webp"]);
|
||||
const bytes = await fetchVerified(icon, fetch);
|
||||
assert.equal(bytes.length, icon.size); assert.equal(digest(bytes), icon.sha256);
|
||||
}
|
||||
assert.equal(entry.artifact, undefined);
|
||||
assert.equal(resource.avatar, null); assert.equal(resource.artifact, null);
|
||||
}
|
||||
assert.ok(requests.every((url) => !/github/u.test(url)));
|
||||
});
|
||||
|
||||
test("GitHub, tracking and noncanonical URLs are rejected before any upstream request", async () => {
|
||||
for (const url of [
|
||||
"https://api.github.com/repos/a/b", "https://raw.githubusercontent.com/a/b/main/README.md",
|
||||
"https://avatars.githubusercontent.com/u/1", "https://github.com/a/b/releases/download/v1/a.ncpkg",
|
||||
"https://release-assets.githubusercontent.com/a", "https://objects.githubusercontent.com/a",
|
||||
"https://plugins.netcatty.app.attacker.invalid/a", "https://plugins.netcatty.app@attacker.invalid/a",
|
||||
"http://plugins.netcatty.app/a", "https://plugins.netcatty.app/a?track=1", "https://plugins.netcatty.app/a#fragment",
|
||||
"https://plugins.netcatty.app/a/../b", "https://plugins.netcatty.app/%2e%2e/b", "https://plugins.netcatty.app/\ta",
|
||||
]) {
|
||||
assert.throws(() => assertDownloadUrl(url));
|
||||
assert.throws(() => validateCatalog([{ ...catalog[0], artifact: { url, sha256: "a".repeat(64) } }]));
|
||||
await assert.rejects(fetchBounded(url, 1024, () => assert.fail("invalid source must not be requested")));
|
||||
}
|
||||
});
|
||||
|
||||
test("official responses never redirect to GitHub or another official path", async () => {
|
||||
for (const location of ["https://github.com/a", "https://raw.githubusercontent.com/a/b/main/catalog.json", "https://plugins.netcatty.app/another.json"]) {
|
||||
const requests = [];
|
||||
await assert.rejects(fetchBounded(REGISTRY_URL, 1024, async (url) => {
|
||||
requests.push(url); return new Response(null, { status: 302, headers: { location } });
|
||||
}), /redirects/);
|
||||
assert.deepEqual(requests, [REGISTRY_URL]);
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-document mismatches, withdrawn versions and unsupported assets preserve the complete good cache", async () => {
|
||||
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
|
||||
const good = await service.list(); const goodResources = service.resources;
|
||||
const mutations = [
|
||||
(index, sidecar) => { sidecar.registryCommit = "a".repeat(40); },
|
||||
(index, sidecar) => { sidecar.plugins[0].commit = "a".repeat(40); },
|
||||
(index, sidecar) => { sidecar.plugins.push(sidecar.plugins[0]); },
|
||||
(index, sidecar) => { sidecar.plugins.pop(); },
|
||||
(index, sidecar) => { sidecar.plugins[0].readme.path = "https://raw.githubusercontent.com/a/b/main/README.md"; },
|
||||
(index, sidecar) => { sidecar.plugins[0].readme.path = `/assets/${sidecar.plugins[0].readme.sha256}.png`; },
|
||||
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
|
||||
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
|
||||
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
|
||||
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
|
||||
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
|
||||
(index) => { index.plugins[0].current = "9.0.0"; },
|
||||
(index) => { index.plugins.push(index.plugins[0]); },
|
||||
(index, sidecar) => { sidecar.unknown = true; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
files = makeFiles(catalog, mutate);
|
||||
// Preserve the pointer's trusted registry identity when testing delivery mismatch.
|
||||
const pointer = JSON.parse(files.get("/v1/snapshot.json")); pointer.registryCommit = delivery.registryCommit;
|
||||
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
|
||||
const rejected = await service.list();
|
||||
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
|
||||
assert.deepEqual(rejected.entries, good.entries); assert.equal(service.resources, goodResources);
|
||||
}
|
||||
});
|
||||
|
||||
test("pointer/document/resource byte mismatches and partial availability never publish partial state", async () => {
|
||||
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
|
||||
const good = await service.list();
|
||||
const pointer = JSON.parse(read("snapshot.json"));
|
||||
for (const name of ["catalog", "index", "delivery"]) {
|
||||
files = realFiles(); files.set(pointer[name].path, Buffer.from("{}"));
|
||||
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.deepEqual(rejected.entries, good.entries);
|
||||
files = realFiles(); files.delete(pointer[name].path);
|
||||
assert.deepEqual((await service.list()).entries, good.entries);
|
||||
}
|
||||
files = realFiles(); const altered = structuredClone(pointer); altered.catalog.path = "/catalog.json";
|
||||
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(altered)));
|
||||
assert.match((await service.list()).error, /resource reference/);
|
||||
files = realFiles(); files.set(delivery.plugins[0].readme.path, Buffer.from("x".repeat(delivery.plugins[0].readme.size)));
|
||||
await assert.rejects(service.readme(good.entries[0].id, good.entries[0].selectionToken), /SHA-256 mismatch/);
|
||||
});
|
||||
@@ -99,9 +99,15 @@ function createPreloadApi(ctx) {
|
||||
with (ctx) {
|
||||
return {
|
||||
getPluginRuntimeStatus: () => ipcRenderer.invoke("netcatty:plugins:status"),
|
||||
listPluginCatalog: () => ipcRenderer.invoke("netcatty:plugins:catalog"),
|
||||
getPluginCatalogReadme: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken }),
|
||||
installCatalogPlugin: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken }),
|
||||
getPluginSourceBinding: (pluginId) => ipcRenderer.invoke("netcatty:plugins:source-binding", { pluginId }),
|
||||
resetPluginSource: (pluginId, expectedSourceId) => ipcRenderer.invoke("netcatty:plugins:reset-source", { pluginId, expectedSourceId }),
|
||||
listPluginSources: () => ipcRenderer.invoke("netcatty:plugins:sources"),
|
||||
addPluginSource: (source) => ipcRenderer.invoke("netcatty:plugins:add-source", source),
|
||||
updatePluginSource: (source) => ipcRenderer.invoke("netcatty:plugins:update-source", source),
|
||||
removePluginSource: (id, disablePlugins) => ipcRenderer.invoke("netcatty:plugins:remove-source", { id, disablePlugins }),
|
||||
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
|
||||
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
|
||||
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
|
||||
setPluginInteractionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:interaction-ready", { ready }),
|
||||
respondPluginInteraction: (response) => ipcRenderer.invoke("netcatty:plugins:interaction-response", response),
|
||||
onPluginInteraction: (callback) => {
|
||||
|
||||
Vendored
+17
-3
@@ -364,7 +364,15 @@ declare global {
|
||||
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
|
||||
}
|
||||
|
||||
interface NetcattyPluginSource {
|
||||
id: string;
|
||||
kind: 'official' | 'third-party' | 'local';
|
||||
name: string;
|
||||
origin: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
interface NetcattyInstalledPlugin {
|
||||
source?: NetcattyPluginSource;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
activeVersion: string | null;
|
||||
@@ -456,9 +464,15 @@ declare global {
|
||||
exportPluginLogs?(): Promise<NetcattyPluginLogStatus>;
|
||||
clearPluginLogs?(): Promise<NetcattyPluginLogStatus>;
|
||||
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
|
||||
listPluginCatalog?(): Promise<NetcattyPluginCatalogSnapshot>;
|
||||
getPluginCatalogReadme?(id: string, selectionToken?: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string, selectionToken?: string): Promise<NetcattyInstalledPlugin>;
|
||||
getPluginSourceBinding?(pluginId: string): Promise<NetcattyPluginSource | null>;
|
||||
resetPluginSource?(pluginId: string, expectedSourceId: string): Promise<void>;
|
||||
listPluginSources?(): Promise<NetcattyPluginSource[]>;
|
||||
addPluginSource?(source: { name: string; address: string }): Promise<NetcattyPluginSource>;
|
||||
updatePluginSource?(source: { id: string; name: string; enabled: boolean }): Promise<NetcattyPluginSource>;
|
||||
removePluginSource?(id: string, disablePlugins: boolean): Promise<void>;
|
||||
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
|
||||
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
|
||||
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
|
||||
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
|
||||
|
||||
Reference in new issue
Block a user