Community plugin admin UI: policy + WASM packages

This commit is contained in:
Marco Allegretti 2026-03-05 13:44:18 +01:00
parent 0c99fa253d
commit e16a36f13c

View file

@ -117,6 +117,277 @@ const { slug } = Astro.params;
return res.json(); return res.json();
} }
async function fetchPluginPolicy(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-policy`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Failed to load plugin policy');
}
return res.json();
}
async function fetchPluginPackages(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-packages`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Failed to load plugin packages');
}
return res.json();
}
function normalizeStringList(text) {
return String(text || '')
.split(/\r?\n/)
.map(v => v.trim())
.filter(v => v.length > 0);
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
function renderPolicyEditor(policy) {
const trust = policy?.trust_policy || 'signed_only';
const installSources = new Set(policy?.install_sources || []);
const allowOutbound = !!policy?.allow_outbound_http;
const httpAllowlist = (policy?.http_egress_allowlist || []).join('\n');
const registryAllowlist = (policy?.registry_allowlist || []).join('\n');
const allowBg = !!policy?.allow_background_jobs;
const publishers = (policy?.trusted_publishers || []).join('\n');
return `
<section class="ui-card policy-card">
<div class="policy-head">
<h2>Plugin Policy</h2>
<div class="policy-actions">
<button type="button" class="ui-btn ui-btn-primary" id="save-policy">Save Policy</button>
<span class="status" id="policy-status"></span>
</div>
</div>
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
<div class="form-group">
<label for="policy-trust">Trust policy</label>
<select id="policy-trust">
<option value="signed_only" ${trust === 'signed_only' ? 'selected' : ''}>Signed only</option>
<option value="unsigned_allowed" ${trust === 'unsigned_allowed' ? 'selected' : ''}>Unsigned allowed</option>
</select>
</div>
<div class="form-group">
<label>Install sources</label>
<div class="policy-checkboxes">
<label class="checkbox-label">
<input type="checkbox" id="policy-source-upload" ${installSources.has('upload') ? 'checked' : ''} />
<span>Upload</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="policy-source-registry" ${installSources.has('registry') ? 'checked' : ''} />
<span>Registry</span>
</label>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="policy-allow-outbound" ${allowOutbound ? 'checked' : ''} />
<span>Allow outbound HTTP (WASM)</span>
</label>
</div>
<div class="form-group">
<label for="policy-http-allowlist">HTTP egress allowlist (one per line)</label>
<textarea id="policy-http-allowlist" spellcheck="false" rows="4">${escapeHtml(httpAllowlist)}</textarea>
</div>
<div class="form-group">
<label for="policy-registry-allowlist">Registry allowlist (one host per line; exact or *.suffix)</label>
<textarea id="policy-registry-allowlist" spellcheck="false" rows="4">${escapeHtml(registryAllowlist)}</textarea>
</div>
<div class="form-group">
<label for="policy-trusted-publishers">Trusted publishers (one Ed25519 key per line)</label>
<textarea id="policy-trusted-publishers" spellcheck="false" rows="4">${escapeHtml(publishers)}</textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="policy-allow-background" ${allowBg ? 'checked' : ''} />
<span>Allow background jobs (WASM cron hooks)</span>
</label>
</div>
</div>
</section>
`;
}
function packageSettingsSchema(pkg) {
const schema = pkg?.manifest?.settings_schema;
if (schema && schema.properties) return schema;
return null;
}
function renderPackageSettingsForm(pkg) {
const schema = packageSettingsSchema(pkg);
const name = String(pkg?.package_id || '');
const settings = pkg?.settings || {};
if (!schema) {
const settingsText = JSON.stringify(settings ?? {}, null, 2);
return `
<div class="settings-body ui-form" style="--ui-form-group-mb: 0.75rem;">
<p class="settings-hint">No schema defined. Edit raw JSON:</p>
<textarea class="settings-json" spellcheck="false" data-package-id="${escapeHtml(name)}">${escapeHtml(settingsText)}</textarea>
<div class="settings-actions">
<button class="ui-btn ui-btn-primary js-save-package-json" data-package-id="${escapeHtml(name)}">Save Settings</button>
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
</div>
</div>
`;
}
const fields = Object.entries(schema.properties).map(([key, propSchema]) => {
return renderSchemaField(`pkg-${name}`, key, propSchema, settings?.[key]);
}).join('');
return `
<form class="settings-form ui-form pkg-settings-form" data-package-id="${escapeHtml(name)}" style="--ui-form-group-mb: 1rem;">
${fields}
<div class="settings-actions">
<button type="submit" class="ui-btn ui-btn-primary">Save Settings</button>
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
</div>
</form>
`;
}
function renderPackages(packages) {
const list = Array.isArray(packages) ? packages : [];
return `
<section class="ui-card packages-card">
<div class="packages-head">
<h2>WASM Plugin Packages</h2>
<div class="packages-actions">
<button type="button" class="ui-btn ui-btn-secondary" id="refresh-packages">Refresh</button>
</div>
</div>
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
<div class="form-group">
<label for="registry-url">Install from registry URL</label>
<div class="inline-row">
<input type="url" id="registry-url" placeholder="https://registry.example/plugin-bundle.json" />
<button type="button" class="ui-btn ui-btn-primary" id="install-registry">Install</button>
</div>
<span class="status" id="registry-status"></span>
</div>
<details class="panel ui-card" open>
<summary class="panel-summary">
<span class="panel-title">Upload package</span>
<span class="panel-meta"><span class="ui-badge">WASM</span></span>
</summary>
<div class="panel-body">
<form id="upload-form" class="ui-form" style="--ui-form-group-mb: 0.75rem;">
<div class="form-group">
<label for="upload-name">Name</label>
<input id="upload-name" type="text" required />
</div>
<div class="form-group">
<label for="upload-version">Version</label>
<input id="upload-version" type="text" required />
</div>
<div class="form-group">
<label for="upload-description">Description</label>
<input id="upload-description" type="text" />
</div>
<div class="form-group">
<label for="upload-publisher">Publisher (Ed25519 public key, base64)</label>
<input id="upload-publisher" type="text" />
</div>
<div class="form-group">
<label for="upload-signature">Signature (base64, optional)</label>
<input id="upload-signature" type="text" />
</div>
<div class="form-group">
<label for="upload-manifest">Manifest (JSON)</label>
<textarea id="upload-manifest" spellcheck="false" rows="6" required>{\n "name": "",\n "version": "",\n "description": "",\n "hooks": [],\n "capabilities": []\n}</textarea>
</div>
<div class="form-group">
<label for="upload-wasm">WASM file</label>
<input id="upload-wasm" type="file" accept=".wasm,application/wasm" required />
</div>
<div class="settings-actions">
<button type="submit" class="ui-btn ui-btn-primary" id="upload-btn">Upload</button>
<span class="status" id="upload-status"></span>
</div>
</form>
</div>
</details>
</div>
<div class="packages-list">
${list.length === 0 ? `
<div class="state-card ui-card">
<p class="empty">No packages installed.</p>
<p class="hint">Install from registry or upload a WASM bundle.</p>
</div>
` : list.map(pkg => {
const pkgId = String(pkg.package_id);
const title = `${escapeHtml(pkg.name)} <span class="version">v${escapeHtml(pkg.version)}</span>`;
return `
<div class="plugin-card ui-card" data-package-id="${escapeHtml(pkgId)}">
<div class="plugin-head">
<div class="plugin-title">
<h3>${title}</h3>
${pkg.signature_present ? `<span class="ui-pill ui-pill-core">signed</span>` : `<span class="ui-pill ui-pill-disabled">unsigned</span>`}
${pkg.source ? `<span class="ui-pill">${escapeHtml(pkg.source)}</span>` : ''}
</div>
<div class="plugin-controls">
<label class="switch" title="Activate/deactivate">
<input class="package-toggle" type="checkbox" data-package-id="${escapeHtml(pkgId)}" ${pkg.is_active ? 'checked' : ''} />
<span class="slider"></span>
</label>
</div>
</div>
<p class="desc">${escapeHtml(pkg.description || '')}</p>
<div class="pkg-meta">
<div><span class="k">publisher</span> <span class="v">${escapeHtml(pkg.publisher || '')}</span></div>
<div><span class="k">sha256</span> <span class="v">${escapeHtml(pkg.wasm_sha256 || '')}</span></div>
${pkg.registry_url ? `<div><span class="k">registry</span> <span class="v">${escapeHtml(pkg.registry_url)}</span></div>` : ''}
</div>
<details class="panel ui-card settings-panel">
<summary class="panel-summary">
<span class="panel-title">Settings</span>
<span class="panel-meta">${packageSettingsSchema(pkg) ? 'Schema' : '<span class="ui-badge">JSON</span>'}</span>
</summary>
<div class="panel-body settings-body-wrap">
${renderPackageSettingsForm(pkg)}
</div>
</details>
</div>
`;
}).join('')}
</div>
</section>
`;
}
async function fetchPlugins(communityId) { async function fetchPlugins(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, { const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
headers: { 'Authorization': `Bearer ${token}` }, headers: { 'Authorization': `Bearer ${token}` },
@ -207,7 +478,7 @@ const { slug } = Astro.params;
`; `;
} }
function renderPlugins(community, membership, plugins) { function renderPlugins(community, membership, policy, packages, plugins) {
const container = document.getElementById('plugins-content'); const container = document.getElementById('plugins-content');
if (!container) return; if (!container) return;
@ -218,21 +489,7 @@ const { slug } = Astro.params;
return; return;
} }
if (!plugins || plugins.length === 0) { const safePlugins = Array.isArray(plugins) ? plugins : [];
container.innerHTML = `
<div class="state-card ui-card">
<p class="empty">No plugins available.</p>
<p class="hint">If this seems wrong, try reloading the page.</p>
<div class="state-actions">
<button type="button" class="ui-btn ui-btn-primary" id="retry-plugins-empty">Retry</button>
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug || ''))}">Back to community</a>
</div>
</div>
`;
document.getElementById('retry-plugins-empty')?.addEventListener('click', load);
return;
}
container.innerHTML = ` container.innerHTML = `
<div class="community-badge ui-card"> <div class="community-badge ui-card">
@ -243,8 +500,16 @@ const { slug } = Astro.params;
<div class="community-badge-subtitle">Community plugin settings</div> <div class="community-badge-subtitle">Community plugin settings</div>
</div> </div>
${renderPolicyEditor(policy)}
${renderPackages(packages)}
<div class="plugins-list"> <div class="plugins-list">
${plugins.map(p => { ${safePlugins.length === 0 ? `
<div class="state-card ui-card">
<p class="empty">No built-in plugins available.</p>
</div>
` : safePlugins.map(p => {
const disabledGlobally = !p.global_is_active; const disabledGlobally = !p.global_is_active;
const isCore = !!p.is_core; const isCore = !!p.is_core;
const checked = !!p.community_is_active; const checked = !!p.community_is_active;
@ -286,6 +551,296 @@ const { slug } = Astro.params;
</div> </div>
`; `;
document.getElementById('refresh-packages')?.addEventListener('click', load);
document.getElementById('save-policy')?.addEventListener('click', async () => {
const statusEl = document.getElementById('policy-status');
const btn = document.getElementById('save-policy');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const current = {
trust_policy: (document.getElementById('policy-trust'))?.value,
install_sources: [
(document.getElementById('policy-source-upload'))?.checked ? 'upload' : null,
(document.getElementById('policy-source-registry'))?.checked ? 'registry' : null,
].filter(Boolean),
allow_outbound_http: (document.getElementById('policy-allow-outbound'))?.checked,
http_egress_allowlist: normalizeStringList((document.getElementById('policy-http-allowlist'))?.value),
registry_allowlist: normalizeStringList((document.getElementById('policy-registry-allowlist'))?.value),
trusted_publishers: normalizeStringList((document.getElementById('policy-trusted-publishers'))?.value),
allow_background_jobs: (document.getElementById('policy-allow-background'))?.checked,
};
const patch = {};
if (!policy || current.trust_policy !== policy.trust_policy) patch.trust_policy = current.trust_policy;
if (!policy || JSON.stringify(current.install_sources) !== JSON.stringify(policy.install_sources || [])) patch.install_sources = current.install_sources;
if (!policy || current.allow_outbound_http !== !!policy.allow_outbound_http) patch.allow_outbound_http = current.allow_outbound_http;
if (!policy || JSON.stringify(current.http_egress_allowlist) !== JSON.stringify(policy.http_egress_allowlist || [])) patch.http_egress_allowlist = current.http_egress_allowlist;
if (!policy || JSON.stringify(current.registry_allowlist) !== JSON.stringify(policy.registry_allowlist || [])) patch.registry_allowlist = current.registry_allowlist;
if (!policy || JSON.stringify(current.trusted_publishers) !== JSON.stringify(policy.trusted_publishers || [])) patch.trusted_publishers = current.trusted_publishers;
if (!policy || current.allow_background_jobs !== !!policy.allow_background_jobs) patch.allow_background_jobs = current.allow_background_jobs;
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-policy`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert(err?.message || 'Failed to save policy');
} finally {
if (btn) btn.disabled = false;
}
});
document.getElementById('install-registry')?.addEventListener('click', async () => {
const input = document.getElementById('registry-url');
const statusEl = document.getElementById('registry-status');
const btn = document.getElementById('install-registry');
const url = input?.value;
if (!url) return;
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Installing...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/install-registry`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ url }),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (statusEl) statusEl.textContent = 'Installed';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
if (input) input.value = '';
await load();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert(err?.message || 'Failed to install from registry');
} finally {
if (btn) btn.disabled = false;
}
});
document.getElementById('upload-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('upload-btn');
const statusEl = document.getElementById('upload-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Uploading...';
try {
const name = (document.getElementById('upload-name'))?.value;
const version = (document.getElementById('upload-version'))?.value;
const description = (document.getElementById('upload-description'))?.value || null;
const publisher = (document.getElementById('upload-publisher'))?.value || null;
const signature_base64 = (document.getElementById('upload-signature'))?.value || null;
const manifestText = (document.getElementById('upload-manifest'))?.value;
const wasmFile = (document.getElementById('upload-wasm'))?.files?.[0];
if (!name || !version || !manifestText || !wasmFile) {
throw new Error('Missing required fields');
}
let manifest;
try {
manifest = JSON.parse(manifestText);
} catch {
throw new Error('Invalid manifest JSON');
}
const wasmBuf = await wasmFile.arrayBuffer();
const wasm_base64 = arrayBufferToBase64(wasmBuf);
const body = {
name,
version,
description,
publisher,
manifest,
wasm_base64,
signature_base64,
};
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (statusEl) statusEl.textContent = 'Uploaded';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert(err?.message || 'Failed to upload package');
} finally {
if (btn) btn.disabled = false;
}
});
document.querySelectorAll('.package-toggle').forEach(el => {
el.addEventListener('change', async (e) => {
const input = e.target;
const packageId = input.dataset.packageId;
const next = input.checked;
input.disabled = true;
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ is_active: next }),
});
if (!res.ok) {
const err = await res.text();
input.checked = !next;
alert(err || 'Failed to update package');
} else {
await load();
}
} catch (err) {
input.checked = !next;
alert('Failed to update package');
} finally {
input.disabled = false;
}
});
});
document.querySelectorAll('.js-save-package-json').forEach(el => {
el.addEventListener('click', async (e) => {
const btn = e.target;
const packageId = btn.dataset.packageId;
const textarea = document.querySelector(`textarea.settings-json[data-package-id="${packageId}"]`);
const statusEl = document.getElementById(`pkg-status-${packageId}`);
if (!textarea) return;
let settings;
try {
settings = JSON.parse(textarea.value || '{}');
} catch {
if (statusEl) statusEl.textContent = 'Invalid JSON';
return;
}
btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ settings }),
});
if (res.ok) {
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} else {
const err = await res.text();
if (statusEl) statusEl.textContent = 'Error';
alert(err || 'Failed to save settings');
}
} catch {
if (statusEl) statusEl.textContent = 'Error';
alert('Failed to save settings');
} finally {
btn.disabled = false;
}
});
});
document.querySelectorAll('.pkg-settings-form').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const packageId = form.dataset.packageId;
const statusEl = document.getElementById(`pkg-status-${packageId}`);
const btn = form.querySelector('button[type="submit"]');
const settings = {};
form.querySelectorAll('[data-key]').forEach(input => {
const key = input.dataset.key;
const type = input.dataset.type;
if (type === 'boolean') {
settings[key] = input.checked;
} else if (type === 'integer') {
settings[key] = input.value ? parseInt(input.value, 10) : null;
} else if (type === 'number') {
settings[key] = input.value ? parseFloat(input.value) : null;
} else {
settings[key] = input.value;
}
});
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ settings }),
});
if (res.ok) {
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} else {
const err = await res.text();
if (statusEl) statusEl.textContent = 'Error';
alert(err || 'Failed to save settings');
}
} catch {
if (statusEl) statusEl.textContent = 'Error';
alert('Failed to save settings');
} finally {
if (btn) btn.disabled = false;
}
});
});
document.querySelectorAll('.plugin-toggle').forEach(el => { document.querySelectorAll('.plugin-toggle').forEach(el => {
el.addEventListener('change', async (e) => { el.addEventListener('change', async (e) => {
const input = e.target; const input = e.target;
@ -436,9 +991,19 @@ const { slug } = Astro.params;
} }
const membership = await fetchMembership(community.id); const membership = await fetchMembership(community.id);
const plugins = await fetchPlugins(community.id); const allowed = membership?.role === 'admin' || membership?.role === 'moderator';
if (!allowed) {
renderForbiddenState(community?.name || 'this community');
return;
}
renderPlugins(community, membership, plugins); const [policy, packages, plugins] = await Promise.all([
fetchPluginPolicy(community.id),
fetchPluginPackages(community.id),
fetchPlugins(community.id),
]);
renderPlugins(community, membership, policy, packages, plugins);
} catch (err) { } catch (err) {
const msg = err?.message || 'Failed to load plugins.'; const msg = err?.message || 'Failed to load plugins.';