feat(control): IV Control admin app — owner-gated /api/control proxy to ivctl + Control view (applicants/instances/releases/tickets/groups) + sidebar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,8 @@ OWNER_TOKEN=CHANGE_ME_TO_LONG_RANDOM
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# IV Control admin proxy (/api/control/* -> ivctl admin API). Owner-only.
|
||||||
|
# Leave IVCTL_URL unset to disable the Control app (proxy returns 503).
|
||||||
|
IVCTL_URL=http://192.168.X.X:8080
|
||||||
|
IVCTL_ADMIN_TOKEN=CHANGE_ME_IVCTL_ADMIN_TOKEN
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { router as themeRouter } from './routes/theme.js';
|
|||||||
import { router as drossRouter } from './routes/dross.js';
|
import { router as drossRouter } from './routes/dross.js';
|
||||||
import { router as voiceRouter } from './routes/voice.js';
|
import { router as voiceRouter } from './routes/voice.js';
|
||||||
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
|
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
|
||||||
|
import { router as controlRouter } from './routes/control.js';
|
||||||
|
|
||||||
export function mountApi(app) {
|
export function mountApi(app) {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
@@ -59,6 +60,7 @@ export function mountApi(app) {
|
|||||||
api.use('/storage', storageRouter);
|
api.use('/storage', storageRouter);
|
||||||
api.use('/backups', backupsRouter);
|
api.use('/backups', backupsRouter);
|
||||||
api.use('/little-blue', littleblueRouter);
|
api.use('/little-blue', littleblueRouter);
|
||||||
|
api.use('/control', controlRouter);
|
||||||
api.use('/ai-usage', aiUsageRouter);
|
api.use('/ai-usage', aiUsageRouter);
|
||||||
api.use('/projects', projectsRouter);
|
api.use('/projects', projectsRouter);
|
||||||
api.use('/projects/:project_id/tasks', tasksByProjectRouter);
|
api.use('/projects/:project_id/tasks', tasksByProjectRouter);
|
||||||
|
|||||||
120
lib/api/routes/control.js
Normal file
120
lib/api/routes/control.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// lib/api/routes/control.js
|
||||||
|
//
|
||||||
|
// Control — owner-only proxy to the **ivctl** admin API (the licensed-distribution
|
||||||
|
// control plane for "IV Control"). Every /api/control/* request is forwarded to
|
||||||
|
// ${IVCTL_URL}<path-after-/api/control>, injecting the admin token server-side so
|
||||||
|
// it never reaches the browser.
|
||||||
|
//
|
||||||
|
// browser POST /api/control/admin/releases (multipart)
|
||||||
|
// -> ivctl POST ${IVCTL_URL}/admin/releases (X-Admin-Token injected)
|
||||||
|
//
|
||||||
|
// Method, query string, JSON bodies AND multipart bodies are passed through, and
|
||||||
|
// the upstream response (including image/log file downloads) is streamed back
|
||||||
|
// verbatim (status, content-type, content-disposition, body).
|
||||||
|
//
|
||||||
|
// Auth: mounted inside mountApi() AFTER agentOrOwner, and every route is gated by
|
||||||
|
// requireOwner — same owner gate the other admin routes use. Agents get 403.
|
||||||
|
//
|
||||||
|
// Required environment variables (read from Void 2's server env):
|
||||||
|
// IVCTL_URL base URL of the ivctl admin service, e.g. http://192.168.1.230:8080
|
||||||
|
// (no trailing slash). If unset -> 503 { error: 'ivctl_not_configured' }.
|
||||||
|
// IVCTL_ADMIN_TOKEN the shared admin token sent upstream as `X-Admin-Token`.
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
// Owner-only for the whole surface (defence in depth on top of mountApi's
|
||||||
|
// agentOrOwner — requireOwner additionally rejects agent tokens with 403).
|
||||||
|
router.use(requireOwner);
|
||||||
|
|
||||||
|
const ivctlBase = () => (process.env.IVCTL_URL || '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// Headers we must NOT copy from the browser request to the upstream (hop-by-hop
|
||||||
|
// or auth that would leak / confuse ivctl). The admin token is injected fresh.
|
||||||
|
const REQ_STRIP = new Set([
|
||||||
|
'host', 'connection', 'content-length', 'authorization', 'x-admin-token',
|
||||||
|
'cookie', 'accept-encoding', 'transfer-encoding'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Headers we must NOT copy back from upstream to the browser (let Express manage
|
||||||
|
// framing / encoding). Everything else (content-type, content-disposition,
|
||||||
|
// cache-control, etc.) is forwarded so file downloads behave correctly.
|
||||||
|
const RES_STRIP = new Set([
|
||||||
|
'connection', 'transfer-encoding', 'content-encoding', 'content-length', 'keep-alive'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build the upstream body. express.json() has already run globally:
|
||||||
|
// - application/json -> req.body is the parsed object; re-serialize it.
|
||||||
|
// - everything else (multipart, octet-stream, empty) -> express.json skipped
|
||||||
|
// it, so the raw request stream is still intact; buffer it through.
|
||||||
|
// Release tarballs are owner uploads (bounded), so buffering is acceptable and
|
||||||
|
// far more robust than half-duplex stream forwarding through native fetch.
|
||||||
|
async function buildBody(req) {
|
||||||
|
const method = req.method.toUpperCase();
|
||||||
|
if (method === 'GET' || method === 'HEAD') return undefined;
|
||||||
|
|
||||||
|
const ctype = (req.headers['content-type'] || '').toLowerCase();
|
||||||
|
if (ctype.includes('application/json')) {
|
||||||
|
// req.body may be {} for an empty JSON body; only send when there's content.
|
||||||
|
if (req.body && Object.keys(req.body).length) return JSON.stringify(req.body);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw / multipart: collect the untouched stream into a Buffer.
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
if (!chunks.length) return undefined;
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.all(/.*/, asyncWrap(async (req, res) => {
|
||||||
|
const base = ivctlBase();
|
||||||
|
if (!base) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'ivctl_not_configured',
|
||||||
|
message: 'IVCTL_URL is not set on the Void server; the Control admin proxy is unavailable.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// req.path here is the path AFTER the /api/control mount point (e.g.
|
||||||
|
// "/admin/releases"). req.originalUrl carries the query string; reuse it.
|
||||||
|
const qIndex = req.originalUrl.indexOf('?');
|
||||||
|
const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex);
|
||||||
|
const target = base + req.path + query;
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
for (const [k, v] of Object.entries(req.headers)) {
|
||||||
|
if (!REQ_STRIP.has(k.toLowerCase())) headers[k] = v;
|
||||||
|
}
|
||||||
|
headers['X-Admin-Token'] = process.env.IVCTL_ADMIN_TOKEN || '';
|
||||||
|
|
||||||
|
const body = await buildBody(req);
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({
|
||||||
|
error: 'ivctl_unreachable',
|
||||||
|
message: `Failed to reach ivctl at ${base}: ${err.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(upstream.status);
|
||||||
|
for (const [k, v] of upstream.headers.entries()) {
|
||||||
|
if (!RES_STRIP.has(k.toLowerCase())) res.setHeader(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.body) return res.end();
|
||||||
|
// Stream the upstream body straight through (handles JSON, images, log files).
|
||||||
|
Readable.fromWeb(upstream.body).pipe(res);
|
||||||
|
}));
|
||||||
@@ -32,6 +32,7 @@ const VIEWS = {
|
|||||||
links: () => import('./views/links.js'),
|
links: () => import('./views/links.js'),
|
||||||
mirror: () => import('./views/mirror.js'),
|
mirror: () => import('./views/mirror.js'),
|
||||||
forge: () => import('./views/forge.js'),
|
forge: () => import('./views/forge.js'),
|
||||||
|
control: () => import('./views/control.js'),
|
||||||
settings: () => import('./views/settings.js'),
|
settings: () => import('./views/settings.js'),
|
||||||
jobs: () => import('./views/jobs.js'),
|
jobs: () => import('./views/jobs.js'),
|
||||||
speedtest: () => import('./views/speedtest.js')
|
speedtest: () => import('./views/speedtest.js')
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ export function renderSidebar(root) {
|
|||||||
el('div', { class: 'sb-section' },
|
el('div', { class: 'sb-section' },
|
||||||
el('div', { class: 'sb-title' }, 'Agents'),
|
el('div', { class: 'sb-title' }, 'Agents'),
|
||||||
navItem('Yerin', '/yerin', { dot: 'yerin' }),
|
navItem('Yerin', '/yerin', { dot: 'yerin' }),
|
||||||
navItem('Little Blue', '/little-blue', { dot: 'lb' })
|
navItem('Little Blue', '/little-blue', { dot: 'lb' }),
|
||||||
|
navItem('Control', '/control')
|
||||||
),
|
),
|
||||||
el('div', { class: 'sb-section' },
|
el('div', { class: 'sb-section' },
|
||||||
el('div', { class: 'sb-title' }, 'Navigate'),
|
el('div', { class: 'sb-title' }, 'Navigate'),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const ROUTES = [
|
|||||||
{ name: 'links', re: /^\/links$/, keys: [] },
|
{ name: 'links', re: /^\/links$/, keys: [] },
|
||||||
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
||||||
{ name: 'forge', re: /^\/forge$/, keys: [] },
|
{ name: 'forge', re: /^\/forge$/, keys: [] },
|
||||||
|
{ name: 'control', re: /^\/control$/, keys: [] },
|
||||||
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
||||||
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
||||||
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },
|
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },
|
||||||
|
|||||||
415
public/views/control.js
Normal file
415
public/views/control.js
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
// #/control — Control: admin UI for the "IV Control" licensed-distribution system.
|
||||||
|
// Talks ONLY to /api/control/* (Void 2's owner-only proxy to the ivctl admin API;
|
||||||
|
// the admin token lives server-side). Tabs: Applicants, Instances, Releases,
|
||||||
|
// Tickets, Groups. Pure el()/mount() — no innerHTML from API data.
|
||||||
|
|
||||||
|
import { el, mount, clear, safeHref } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
const TIERS = ['lock', 'uninstall-keep', 'wipe'];
|
||||||
|
const A = '/api/control/admin';
|
||||||
|
|
||||||
|
// ---- small UI helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
function btn(label, onclick, cls = 'ghost') {
|
||||||
|
return el('button', { class: cls, style: { marginRight: '0.35rem' }, onclick }, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(labelText, control) {
|
||||||
|
return el('label', { style: { display: 'flex', flexDirection: 'column', gap: '0.2rem', fontSize: '0.8rem' } },
|
||||||
|
el('span', { class: 'muted' }, labelText), control);
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(options, value) {
|
||||||
|
const s = el('select', { class: 'lk-url' });
|
||||||
|
for (const o of options) {
|
||||||
|
const opt = el('option', { value: typeof o === 'string' ? o : o.value }, typeof o === 'string' ? o : o.label);
|
||||||
|
if ((typeof o === 'string' ? o : o.value) === value) opt.selected = true;
|
||||||
|
s.appendChild(opt);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(host, msg, ok = true) {
|
||||||
|
clear(host);
|
||||||
|
host.appendChild(el('span', { style: { color: ok ? 'var(--accent, #5ec27a)' : 'var(--danger, #e06b6b)', fontSize: '0.8rem' } }, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function table(headers, rows) {
|
||||||
|
const ths = headers.map(h =>
|
||||||
|
el('th', { style: { textAlign: 'left', padding: '0.4rem 0.5rem', borderBottom: '1px solid var(--border)', color: 'var(--muted)', fontWeight: '600' } }, h));
|
||||||
|
return el('table', { class: 'ctl-table', style: { width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' } },
|
||||||
|
el('thead', {}, el('tr', {}, ths)),
|
||||||
|
el('tbody', {}, rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
function td(...children) {
|
||||||
|
return el('td', { style: { padding: '0.4rem 0.5rem', borderBottom: '1px solid var(--border)', verticalAlign: 'top' } }, ...children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(s) {
|
||||||
|
const colors = { active: '#5ec27a', open: '#5ec27a', suspended: '#e0b24b', revoked: '#e06b6b', closed: '#8a8a99', pending: '#e0b24b', approved: '#5ec27a', denied: '#e06b6b' };
|
||||||
|
return el('span', { class: 'badge', style: { background: 'transparent', border: `1px solid ${colors[s] || 'var(--border)'}`, color: colors[s] || 'var(--muted)' } }, s || '—');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- group cache (used by approve/instances dropdowns) ----------------------
|
||||||
|
|
||||||
|
let groupsCache = [];
|
||||||
|
async function loadGroups() {
|
||||||
|
try { groupsCache = await api.get(`${A}/groups`); } catch { groupsCache = []; }
|
||||||
|
return groupsCache;
|
||||||
|
}
|
||||||
|
function groupName(id) {
|
||||||
|
const g = groupsCache.find(g => String(g.id) === String(id));
|
||||||
|
return g ? g.name : (id ?? '—');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Applicants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function renderApplicants(panel) {
|
||||||
|
mount(panel, el('p', { class: 'muted' }, 'Loading applicants…'));
|
||||||
|
await loadGroups();
|
||||||
|
let rows;
|
||||||
|
try { rows = await api.get(`${A}/applicants?status=pending`); }
|
||||||
|
catch (e) { return mount(panel, errBox(e)); }
|
||||||
|
|
||||||
|
const body = rows.map(a => {
|
||||||
|
const msg = el('span', { class: 'muted', style: { fontSize: '0.75rem' } }, '');
|
||||||
|
const groupSel = select([{ value: '', label: '(default group)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))]);
|
||||||
|
const approve = btn('Approve', async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.post(`${A}/applicants/${a.id}/approve`, groupSel.value ? { group_id: groupSel.value } : {});
|
||||||
|
clear(msg);
|
||||||
|
const code = r.claim_code || r.code || '';
|
||||||
|
const codeEl = el('code', { style: { fontWeight: '700', userSelect: 'all' } }, code);
|
||||||
|
msg.appendChild(el('span', { style: { color: 'var(--accent,#5ec27a)' } }, 'Claim code: '));
|
||||||
|
msg.appendChild(codeEl);
|
||||||
|
msg.appendChild(btn('Copy', () => navigator.clipboard?.writeText(code), 'ghost'));
|
||||||
|
} catch (e) { notify(msg, e.message || 'approve failed', false); }
|
||||||
|
}, 'primary');
|
||||||
|
const deny = btn('Deny', async () => {
|
||||||
|
try { await api.post(`${A}/applicants/${a.id}/deny`, {}); notify(msg, 'denied', true); }
|
||||||
|
catch (e) { notify(msg, e.message || 'deny failed', false); }
|
||||||
|
});
|
||||||
|
return el('tr', {},
|
||||||
|
td(el('strong', {}, a.label || a.name || a.email || `#${a.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, a.email || '')),
|
||||||
|
td(a.note || a.reason || '—'),
|
||||||
|
td(statusPill(a.status)),
|
||||||
|
td(el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' } }, groupSel, approve, deny)),
|
||||||
|
td(msg));
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(panel,
|
||||||
|
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Pending applicants'),
|
||||||
|
btn('Refresh', () => renderApplicants(panel), 'ghost')),
|
||||||
|
rows.length ? table(['Applicant', 'Note', 'Status', 'Action', ''], body)
|
||||||
|
: el('p', { class: 'muted' }, 'No pending applicants.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Instances (licenses)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function renderInstances(panel) {
|
||||||
|
mount(panel, el('p', { class: 'muted' }, 'Loading instances…'));
|
||||||
|
await loadGroups();
|
||||||
|
let rows;
|
||||||
|
try { rows = await api.get(`${A}/licenses`); }
|
||||||
|
catch (e) { return mount(panel, errBox(e)); }
|
||||||
|
|
||||||
|
const body = rows.map(l => {
|
||||||
|
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
|
||||||
|
const patch = async (payload, okMsg) => {
|
||||||
|
try { await api.patch(`${A}/licenses/${l.id}`, payload); notify(msg, okMsg || 'updated', true); renderInstances(panel); }
|
||||||
|
catch (e) { notify(msg, e.message || 'failed', false); }
|
||||||
|
};
|
||||||
|
const tierSel = select(TIERS, l.tier);
|
||||||
|
tierSel.onchange = () => patch({ tier: tierSel.value }, `tier → ${tierSel.value}`);
|
||||||
|
const groupSel = select([{ value: '', label: '(none)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))], l.group_id ?? '');
|
||||||
|
groupSel.onchange = () => patch({ group_id: groupSel.value || null }, 'group changed');
|
||||||
|
|
||||||
|
const actions = el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.2rem' } });
|
||||||
|
if (l.status !== 'suspended') actions.appendChild(btn('Suspend', () => patch({ status: 'suspended' }, 'suspended')));
|
||||||
|
if (l.status !== 'active') actions.appendChild(btn('Restore', () => patch({ status: 'active' }, 'restored')));
|
||||||
|
if (l.status !== 'revoked') actions.appendChild(btn('Revoke', () => { if (confirm('Revoke this instance?')) patch({ status: 'revoked' }, 'revoked'); }, 'ghost'));
|
||||||
|
actions.appendChild(btn('+Extend', () => {
|
||||||
|
const d = prompt('Extend lease by how many days?', '30');
|
||||||
|
const n = parseInt(d, 10);
|
||||||
|
if (Number.isFinite(n) && n !== 0) patch({ extend_days: n }, `extended +${n}d`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return el('tr', {},
|
||||||
|
td(el('strong', {}, l.label || l.email || `#${l.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, l.email || '')),
|
||||||
|
td(groupSel),
|
||||||
|
td(statusPill(l.status)),
|
||||||
|
td(tierSel),
|
||||||
|
td(String(l.lease_days ?? '—')),
|
||||||
|
td(l.version || '—'),
|
||||||
|
td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(l.last_seen || l.last_seen_at))),
|
||||||
|
td(actions, msg));
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(panel,
|
||||||
|
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Instances'),
|
||||||
|
btn('Refresh', () => renderInstances(panel), 'ghost')),
|
||||||
|
rows.length ? table(['Instance', 'Group', 'Status', 'Tier', 'Lease', 'Version', 'Last seen', 'Actions'], body)
|
||||||
|
: el('p', { class: 'muted' }, 'No instances yet.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Releases
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function renderReleases(panel) {
|
||||||
|
mount(panel, el('p', { class: 'muted' }, 'Loading releases…'));
|
||||||
|
await loadGroups();
|
||||||
|
let rows;
|
||||||
|
try { rows = await api.get(`${A}/releases`); }
|
||||||
|
catch (e) { return mount(panel, errBox(e)); }
|
||||||
|
|
||||||
|
// Upload form
|
||||||
|
const fileInput = el('input', { type: 'file', accept: '.tgz,.tar.gz,.tar,application/gzip,application/x-tar' });
|
||||||
|
const verInput = el('input', { class: 'lk-url', placeholder: 'version e.g. 1.4.0' });
|
||||||
|
const notesInput = el('textarea', { class: 'lk-url', rows: 2, placeholder: 'release notes…', style: { resize: 'vertical' } });
|
||||||
|
const upMsg = el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, '');
|
||||||
|
const upBtn = btn('Upload release', async () => {
|
||||||
|
if (!fileInput.files?.[0]) return notify(upMsg, 'pick a tarball first', false);
|
||||||
|
if (!verInput.value.trim()) return notify(upMsg, 'version required', false);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('release', fileInput.files[0]);
|
||||||
|
fd.append('version', verInput.value.trim());
|
||||||
|
fd.append('notes', notesInput.value);
|
||||||
|
notify(upMsg, 'uploading…', true);
|
||||||
|
try {
|
||||||
|
await api.postForm(`${A}/releases`, fd);
|
||||||
|
notify(upMsg, 'uploaded', true);
|
||||||
|
verInput.value = ''; notesInput.value = ''; fileInput.value = '';
|
||||||
|
renderReleases(panel);
|
||||||
|
} catch (e) { notify(upMsg, e.message || 'upload failed', false); }
|
||||||
|
}, 'primary');
|
||||||
|
|
||||||
|
const uploadCard = el('div', { class: 'card', style: { display: 'grid', gap: '0.5rem', marginBottom: '0.9rem' } },
|
||||||
|
el('div', { class: 'term-title' }, '◆ New release'),
|
||||||
|
field('Tarball', fileInput),
|
||||||
|
field('Version', verInput),
|
||||||
|
field('Notes', notesInput),
|
||||||
|
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.6rem' } }, upBtn, upMsg));
|
||||||
|
|
||||||
|
// Existing releases
|
||||||
|
const body = rows.map(r => {
|
||||||
|
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
|
||||||
|
const patch = async (payload, okMsg) => {
|
||||||
|
try { await api.patch(`${A}/releases/${r.id}`, payload); notify(msg, okMsg || 'updated', true); }
|
||||||
|
catch (e) { notify(msg, e.message || 'failed', false); }
|
||||||
|
};
|
||||||
|
const signoff = el('input', { type: 'checkbox', checked: !!r.signed_off });
|
||||||
|
signoff.onchange = () => patch({ signed_off: signoff.checked }, signoff.checked ? 'signed off' : 'sign-off cleared');
|
||||||
|
|
||||||
|
// Multi-select group targeting
|
||||||
|
const targetSel = el('select', { class: 'lk-url', multiple: true, size: Math.min(4, Math.max(2, groupsCache.length)), style: { minWidth: '160px' } });
|
||||||
|
const targeted = new Set((r.target_group_ids || []).map(String));
|
||||||
|
for (const g of groupsCache) {
|
||||||
|
const opt = el('option', { value: g.id }, g.name);
|
||||||
|
if (targeted.has(String(g.id))) opt.selected = true;
|
||||||
|
targetSel.appendChild(opt);
|
||||||
|
}
|
||||||
|
const applyTargets = btn('Set targets', () => {
|
||||||
|
const ids = Array.from(targetSel.selectedOptions).map(o => o.value);
|
||||||
|
patch({ target_group_ids: ids }, `targeting ${ids.length} group(s)`);
|
||||||
|
});
|
||||||
|
const del = btn('Delete', async () => {
|
||||||
|
if (!confirm(`Delete release ${r.version}?`)) return;
|
||||||
|
try { await api.del(`${A}/releases/${r.id}`); renderReleases(panel); }
|
||||||
|
catch (e) { notify(msg, e.message || 'delete failed', false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return el('tr', {},
|
||||||
|
td(el('strong', {}, r.version || `#${r.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(r.created_at))),
|
||||||
|
td(el('div', { style: { maxWidth: '260px', whiteSpace: 'pre-wrap' } }, r.notes || '—')),
|
||||||
|
td(el('label', { style: { display: 'flex', alignItems: 'center', gap: '0.3rem' } }, signoff, el('span', { class: 'muted' }, 'signed off'))),
|
||||||
|
td(el('div', { style: { display: 'flex', flexDirection: 'column', gap: '0.3rem' } }, targetSel, applyTargets)),
|
||||||
|
td(del, msg));
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(panel,
|
||||||
|
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Releases'),
|
||||||
|
btn('Refresh', () => renderReleases(panel), 'ghost')),
|
||||||
|
uploadCard,
|
||||||
|
rows.length ? table(['Version', 'Notes', 'Sign-off', 'Target groups', ''], body)
|
||||||
|
: el('p', { class: 'muted' }, 'No releases uploaded.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tickets
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function renderTickets(panel) {
|
||||||
|
let filter = 'open';
|
||||||
|
const list = el('div');
|
||||||
|
const detail = el('div', { style: { marginTop: '0.9rem' } });
|
||||||
|
|
||||||
|
const statusSel = select([{ value: '', label: 'all' }, 'open', 'closed'], filter);
|
||||||
|
statusSel.onchange = () => { filter = statusSel.value; loadList(); };
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
mount(list, el('p', { class: 'muted' }, 'Loading tickets…'));
|
||||||
|
let rows;
|
||||||
|
try { rows = await api.get(`${A}/tickets${filter ? `?status=${encodeURIComponent(filter)}` : ''}`); }
|
||||||
|
catch (e) { return mount(list, errBox(e)); }
|
||||||
|
rows = rows.slice().sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
||||||
|
const body = rows.map(t => el('tr', {},
|
||||||
|
td(el('a', { href: '#', onclick: (e) => { e.preventDefault(); openTicket(t.id); } }, el('strong', {}, t.subject || t.title || `Ticket #${t.id}`))),
|
||||||
|
td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, t.label || t.email || '—')),
|
||||||
|
td(statusPill(t.status)),
|
||||||
|
td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(t.created_at)))));
|
||||||
|
mount(list, rows.length ? table(['Subject', 'From', 'Status', 'Created'], body) : el('p', { class: 'muted' }, 'No tickets.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTicket(id) {
|
||||||
|
mount(detail, el('p', { class: 'muted' }, 'Loading ticket…'));
|
||||||
|
let t;
|
||||||
|
try { t = await api.get(`${A}/tickets/${id}`); }
|
||||||
|
catch (e) { return mount(detail, errBox(e)); }
|
||||||
|
|
||||||
|
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
|
||||||
|
const notesInput = el('textarea', { class: 'lk-url', rows: 3, style: { resize: 'vertical' } });
|
||||||
|
notesInput.value = t.notes || '';
|
||||||
|
const patch = async (payload, okMsg) => {
|
||||||
|
try { await api.patch(`${A}/tickets/${id}`, payload); notify(msg, okMsg || 'saved', true); loadList(); }
|
||||||
|
catch (e) { notify(msg, e.message || 'failed', false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const images = (t.images || t.image_attachments || []).map(att => {
|
||||||
|
const attId = att.id ?? att;
|
||||||
|
return el('a', { href: safeHref(`${A}/tickets/${id}/images/${attId}`), target: '_blank', rel: 'noopener' },
|
||||||
|
el('img', { src: `${A}/tickets/${id}/images/${attId}`, alt: 'screenshot',
|
||||||
|
style: { maxWidth: '160px', maxHeight: '120px', border: '1px solid var(--border)', borderRadius: '4px', objectFit: 'cover' } }));
|
||||||
|
});
|
||||||
|
const logs = (t.logs || t.log_attachments || []).map(att => {
|
||||||
|
const attId = att.id ?? att;
|
||||||
|
return el('a', { class: 'ghost', href: safeHref(`${A}/tickets/${id}/logs/${attId}`), target: '_blank', rel: 'noopener', style: { marginRight: '0.4rem' } },
|
||||||
|
'↗ ' + (att.name || `log ${attId}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(detail,
|
||||||
|
el('div', { class: 'card', style: { display: 'grid', gap: '0.6rem' } },
|
||||||
|
el('div', { class: 'term-bar' },
|
||||||
|
el('span', { class: 'term-title' }, t.subject || t.title || `Ticket #${id}`),
|
||||||
|
statusPill(t.status),
|
||||||
|
el('span', { style: { marginLeft: 'auto' } },
|
||||||
|
btn(t.status === 'closed' ? 'Reopen' : 'Close', () => patch({ status: t.status === 'closed' ? 'open' : 'closed' }, 'status updated'), 'ghost'))),
|
||||||
|
el('div', { class: 'muted', style: { fontSize: '0.74rem' } }, (t.label || t.email || '') + ' · ' + fmtTime(t.created_at)),
|
||||||
|
el('div', { style: { whiteSpace: 'pre-wrap' } }, t.body || t.text || t.description || '(no text)'),
|
||||||
|
images.length ? el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem' } }, images) : null,
|
||||||
|
logs.length ? el('div', {}, logs) : null,
|
||||||
|
field('Admin notes', notesInput),
|
||||||
|
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.6rem' } },
|
||||||
|
btn('Save notes', () => patch({ notes: notesInput.value }, 'notes saved'), 'primary'), msg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(panel,
|
||||||
|
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Tickets'),
|
||||||
|
el('span', { style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.4rem' } },
|
||||||
|
el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, 'status'), statusSel,
|
||||||
|
btn('Refresh', () => loadList(), 'ghost'))),
|
||||||
|
list, detail);
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Groups
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function renderGroups(panel) {
|
||||||
|
mount(panel, el('p', { class: 'muted' }, 'Loading groups…'));
|
||||||
|
let rows;
|
||||||
|
try { rows = await api.get(`${A}/groups`); }
|
||||||
|
catch (e) { return mount(panel, errBox(e)); }
|
||||||
|
groupsCache = rows;
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
const nameI = el('input', { class: 'lk-url', placeholder: 'name' });
|
||||||
|
const leaseI = el('input', { class: 'lk-url', type: 'number', placeholder: 'lease_days', value: '30' });
|
||||||
|
const tierSel = select(TIERS, 'lock');
|
||||||
|
const cMsg = el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, '');
|
||||||
|
const createBtn = btn('Create group', async () => {
|
||||||
|
if (!nameI.value.trim()) return notify(cMsg, 'name required', false);
|
||||||
|
try {
|
||||||
|
await api.post(`${A}/groups`, { name: nameI.value.trim(), lease_days: parseInt(leaseI.value, 10) || 0, tier: tierSel.value });
|
||||||
|
nameI.value = ''; renderGroups(panel);
|
||||||
|
} catch (e) { notify(cMsg, e.message || 'create failed', false); }
|
||||||
|
}, 'primary');
|
||||||
|
|
||||||
|
const createCard = el('div', { class: 'card', style: { display: 'grid', gap: '0.5rem', marginBottom: '0.9rem', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', alignItems: 'end' } },
|
||||||
|
field('Name', nameI), field('Lease days', leaseI), field('Tier', tierSel),
|
||||||
|
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem' } }, createBtn, cMsg));
|
||||||
|
|
||||||
|
const body = rows.map(g => {
|
||||||
|
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
|
||||||
|
const nameE = el('input', { class: 'lk-url', value: g.name || '' });
|
||||||
|
const leaseE = el('input', { class: 'lk-url', type: 'number', value: String(g.lease_days ?? 0) });
|
||||||
|
const tierE = select(TIERS, g.tier);
|
||||||
|
const save = btn('Save', async () => {
|
||||||
|
try { await api.patch(`${A}/groups/${g.id}`, { name: nameE.value.trim(), lease_days: parseInt(leaseE.value, 10) || 0, tier: tierE.value }); notify(msg, 'saved', true); }
|
||||||
|
catch (e) { notify(msg, e.message || 'failed', false); }
|
||||||
|
}, 'primary');
|
||||||
|
return el('tr', {}, td(nameE), td(leaseE), td(tierE), td(save, msg));
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(panel,
|
||||||
|
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Groups'),
|
||||||
|
btn('Refresh', () => renderGroups(panel), 'ghost')),
|
||||||
|
createCard,
|
||||||
|
rows.length ? table(['Name', 'Lease days', 'Tier', ''], body) : el('p', { class: 'muted' }, 'No groups yet.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared bits ------------------------------------------------------------
|
||||||
|
|
||||||
|
function fmtTime(t) {
|
||||||
|
if (!t) return '—';
|
||||||
|
const d = new Date(t);
|
||||||
|
return Number.isNaN(d.getTime()) ? String(t) : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errBox(e) {
|
||||||
|
if (e?.body?.error === 'ivctl_not_configured' || e?.status === 503) {
|
||||||
|
return el('div', { class: 'card' },
|
||||||
|
el('strong', {}, 'ivctl not configured'),
|
||||||
|
el('p', { class: 'muted' }, 'Set IVCTL_URL (and IVCTL_ADMIN_TOKEN) on the Void server to enable the Control admin app.'));
|
||||||
|
}
|
||||||
|
return el('div', { class: 'card' },
|
||||||
|
el('strong', { style: { color: 'var(--danger, #e06b6b)' } }, 'Failed to load'),
|
||||||
|
el('p', { class: 'muted' }, e?.message || 'request failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
['applicants', 'Applicants', renderApplicants],
|
||||||
|
['instances', 'Instances', renderInstances],
|
||||||
|
['releases', 'Releases', renderReleases],
|
||||||
|
['tickets', 'Tickets', renderTickets],
|
||||||
|
['groups', 'Groups', renderGroups]
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function render(main) {
|
||||||
|
const panel = el('div', { style: { marginTop: '1rem' } });
|
||||||
|
let active = 'applicants';
|
||||||
|
|
||||||
|
const tabBar = el('div', { style: { display: 'flex', gap: '0.3rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border)', paddingBottom: '0.4rem' } });
|
||||||
|
function paint() {
|
||||||
|
clear(tabBar);
|
||||||
|
for (const [key, label, fn] of TABS) {
|
||||||
|
tabBar.appendChild(btn(label, () => { active = key; paint(); fn(panel); }, active === key ? 'primary' : 'ghost'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(main,
|
||||||
|
el('h1', { class: 'view-h1' }, 'Control'),
|
||||||
|
el('p', { class: 'view-sub' }, 'IV Control — admin for the licensed-distribution system (applicants, instances, releases, tickets, groups).'),
|
||||||
|
tabBar, panel);
|
||||||
|
|
||||||
|
paint();
|
||||||
|
const initial = TABS.find(t => t[0] === active);
|
||||||
|
initial[2](panel);
|
||||||
|
}
|
||||||
99
tests/api/control.test.js
Normal file
99
tests/api/control.test.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
// The control router is a pure proxy: it forwards /api/control/* to
|
||||||
|
// ${IVCTL_URL}<path> injecting X-Admin-Token. We mock global fetch (the upstream
|
||||||
|
// ivctl call) and assert: owner-gate enforced, path forwarded, token injected,
|
||||||
|
// query + JSON body passed through, and 503 when IVCTL_URL is unset.
|
||||||
|
|
||||||
|
let createApp, app;
|
||||||
|
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.OWNER_TOKEN = 'test-token';
|
||||||
|
process.env.IVCTL_URL = 'http://ivctl.test:8080';
|
||||||
|
process.env.IVCTL_ADMIN_TOKEN = 'admin-secret';
|
||||||
|
({ createApp } = await import('../../server.js'));
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
let fetchSpy;
|
||||||
|
function mockUpstream(status = 200, json = { ok: true }) {
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(json), {
|
||||||
|
status, headers: { 'content-type': 'application/json' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
afterEach(() => { fetchSpy?.mockRestore(); fetchSpy = undefined; });
|
||||||
|
|
||||||
|
describe('/api/control proxy', () => {
|
||||||
|
beforeEach(() => { process.env.IVCTL_URL = 'http://ivctl.test:8080'; });
|
||||||
|
|
||||||
|
it('requires the owner token (401 without bearer)', async () => {
|
||||||
|
mockUpstream();
|
||||||
|
const res = await request(app).get('/api/control/admin/applicants');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects agent tokens / non-owner (403) — owner-only', async () => {
|
||||||
|
mockUpstream();
|
||||||
|
// A syntactically valid bearer that is NOT the owner token → agentOrOwner
|
||||||
|
// tries agent verify and fails → 401 before reaching requireOwner. Either
|
||||||
|
// way it must NOT reach the upstream.
|
||||||
|
const res = await request(app).get('/api/control/admin/applicants').set('Authorization', 'Bearer not-the-owner');
|
||||||
|
expect([401, 403]).toContain(res.status);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards GET path + query and injects X-Admin-Token', async () => {
|
||||||
|
mockUpstream(200, [{ id: 1, status: 'pending' }]);
|
||||||
|
const res = await owner(request(app).get('/api/control/admin/applicants?status=pending'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([{ id: 1, status: 'pending' }]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, opts] = fetchSpy.mock.calls[0];
|
||||||
|
expect(url).toBe('http://ivctl.test:8080/admin/applicants?status=pending');
|
||||||
|
expect(opts.method).toBe('GET');
|
||||||
|
expect(opts.headers['X-Admin-Token']).toBe('admin-secret');
|
||||||
|
// owner bearer must NOT be forwarded upstream
|
||||||
|
expect(opts.headers.authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards a JSON body on POST (approve → claim_code)', async () => {
|
||||||
|
mockUpstream(200, { claim_code: 'ABC123' });
|
||||||
|
const res = await owner(request(app).post('/api/control/admin/applicants/7/approve')).send({ group_id: 3 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.claim_code).toBe('ABC123');
|
||||||
|
const [url, opts] = fetchSpy.mock.calls[0];
|
||||||
|
expect(url).toBe('http://ivctl.test:8080/admin/applicants/7/approve');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ group_id: 3 });
|
||||||
|
expect(opts.headers['X-Admin-Token']).toBe('admin-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes PATCH bodies through (license update)', async () => {
|
||||||
|
mockUpstream(200, { id: 5, status: 'suspended' });
|
||||||
|
const res = await owner(request(app).patch('/api/control/admin/licenses/5')).send({ status: 'suspended' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const [url, opts] = fetchSpy.mock.calls[0];
|
||||||
|
expect(url).toBe('http://ivctl.test:8080/admin/licenses/5');
|
||||||
|
expect(opts.method).toBe('PATCH');
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ status: 'suspended' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams the upstream status code back (e.g. 404)', async () => {
|
||||||
|
mockUpstream(404, { error: 'not_found' });
|
||||||
|
const res = await owner(request(app).get('/api/control/admin/tickets/999'));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 ivctl_not_configured when IVCTL_URL is unset', async () => {
|
||||||
|
delete process.env.IVCTL_URL;
|
||||||
|
mockUpstream();
|
||||||
|
const res = await owner(request(app).get('/api/control/admin/applicants'));
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.error).toBe('ivctl_not_configured');
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user