feat(admin): serve Operator Console SPA at /admin (rust-embed)

Preact + Vite standalone admin-ui bundled into the server binary via
rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin
challenge); tabs cover instance status, admin keys, invite codes, and
the effective-config editor (live vs restart-required per field).

admin-ui/dist is committed because rust-embed reads it at compile time;
node_modules is gitignored (npm ci reproduces it). A lib test asserts
index.html and the /admin/ asset base are embedded so an unbuilt
console fails the build instead of an operator.

The console is served on the admin plane only (loopback bind), behind
the existing NIP-98 admin_guard + MAC allowlist.
This commit is contained in:
jwinterm
2026-07-18 16:03:03 -04:00
parent 8b408a063a
commit 35447868d4
16 changed files with 1912 additions and 34 deletions
+6
View File
@@ -0,0 +1,6 @@
# Node deps — reproducible from package-lock.json (`npm ci`).
node_modules/
# NOTE: dist/ is intentionally NOT ignored. rust-embed bakes admin-ui/dist/ into
# the server binary at compile time, so the built console is committed. Rebuild it
# with `npm run build` in this directory whenever src/ changes (CI checks freshness).
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
:root{--smirk-bg: #2c1e12;--smirk-bg-elevated: #3a2a1b;--smirk-bg-sunken: #241810;--smirk-fg: #f3e9dd;--smirk-fg-muted: #b9a891;--smirk-accent: #f5c542;--smirk-accent-fg: #2c1e12;--smirk-border: rgba(255, 255, 255, .12);--smirk-border-strong: rgba(255, 255, 255, .22);--smirk-danger: #ef4444;--smirk-ok: #22c55e;--smirk-warn: #f59e0b;--smirk-radius: 10px;--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--smirk-bg);color:var(--smirk-fg);font-family:var(--smirk-font);font-size:14px;line-height:1.5}h1,h2,h3{font-weight:700}button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--smirk-border);background:var(--smirk-bg-elevated);color:inherit;padding:8px 14px}button.primary{background:var(--smirk-accent);color:var(--smirk-accent-fg);border:none;font-weight:700}button.danger{color:var(--smirk-danger);border-color:var(--smirk-danger)}button:disabled{opacity:.5;cursor:default}input,textarea,select{font:inherit;color:inherit;background:var(--smirk-bg-sunken);border:1px solid var(--smirk-border);border-radius:8px;padding:8px 10px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}a{color:var(--smirk-accent)}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Smirk Operator Console</title>
<script type="module" crossorigin src="/admin/assets/index-DLOlKNyc.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Smirk Operator Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1023
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "smirk-admin-ui",
"private": true,
"version": "0.3.0",
"type": "module",
"description": "Operator console SPA, embedded into smirk-backend-core and served at /admin.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"preact": "^10.24.3"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+78
View File
@@ -0,0 +1,78 @@
// Fetch layer for the /admin JSON API. Same-origin (the SPA is served from the admin
// plane), bearer access token held in memory only (never localStorage). Admin routes
// are outside the public OpenAPI, so this is hand-written.
let accessToken: string | null = null;
export function setToken(t: string | null): void {
accessToken = t;
}
export function hasToken(): boolean {
return accessToken !== null;
}
async function req(method: string, path: string, body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {};
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const data: unknown = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg =
(data as { error?: string; message?: string })?.error ??
(data as { message?: string })?.message ??
`${res.status} ${res.statusText}`;
throw new Error(msg);
}
return data;
}
export interface ChallengeResp {
challenge: string;
url: string;
instance_id: string;
expires_in: number;
}
export interface TokenResp {
access_token: string;
refresh_token?: string;
expires_in: number;
}
export interface KeyRow {
id: string;
pubkey: string;
status: string;
}
export interface InviteView {
code_prefix: string;
label: string | null;
created_at: string;
status: string;
}
export const api = {
challenge: () => req('GET', '/admin/auth/challenge') as Promise<ChallengeResp>,
verify: (admin_token: string, challenge: string) =>
req('POST', '/admin/auth/verify', { admin_token, challenge }) as Promise<TokenResp>,
logout: () => req('POST', '/admin/auth/logout', {}),
me: () => req('GET', '/admin/me'),
features: () => req('GET', '/admin/features'),
keys: () => req('GET', '/admin/keys') as Promise<KeyRow[]>,
addKey: (pubkey: string, label?: string) =>
req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }),
revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`),
config: () => req('GET', '/admin/config'),
putConfig: (patch: unknown, expected_versions: Record<string, number>) =>
req('PUT', '/admin/config', { patch, expected_versions }),
invites: () =>
req('GET', '/admin/invites') as Promise<{ invites: InviteView[]; unused_count: number }>,
mintInvites: (count: number, label?: string) =>
req('POST', '/admin/invites', label ? { count, label } : { count }) as Promise<{
codes: string[];
}>,
};
+363
View File
@@ -0,0 +1,363 @@
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import './tokens.css';
import { api, setToken, type KeyRow, type InviteView } from './api';
import { buildAdminToken, nip07 } from './nip98';
// ── login ─────────────────────────────────────────────────────────────────────
function Login({ onDone }: { onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const signIn = async () => {
setBusy(true);
setErr(null);
try {
const signer = nip07();
if (!signer) {
throw new Error(
'No NIP-07 signer found. Enable your Smirk wallet (or nos2x/Alby) on this origin, then reload.',
);
}
const c = await api.challenge();
const token = await buildAdminToken(c, signer);
const { access_token } = await api.verify(token, c.challenge);
setToken(access_token);
onDone();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Sign-in failed');
} finally {
setBusy(false);
}
};
return (
<div style={{ maxWidth: 420, margin: '12vh auto', padding: 24, textAlign: 'center' }}>
<div style={{ fontSize: 40 }}>🐸</div>
<h1>Smirk Operator Console</h1>
<p style={{ color: 'var(--smirk-fg-muted)' }}>
Sign in with an admin Nostr key on your instance's allowlist.
</p>
<button class="primary" style={{ padding: '12px 20px', fontSize: 16 }} disabled={busy} onClick={signIn}>
{busy ? 'Signing in…' : 'Sign in with Smirk'}
</button>
{err && <p style={{ color: 'var(--smirk-danger)', marginTop: 16 }}>{err}</p>}
</div>
);
}
// ── shared ────────────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: unknown }) {
return (
<div
style={{
background: 'var(--smirk-bg-elevated)',
border: '1px solid var(--smirk-border)',
borderRadius: 'var(--smirk-radius)',
padding: 16,
marginBottom: 16,
}}
>
<h3 style={{ marginTop: 0 }}>{title}</h3>
{children}
</div>
);
}
function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null);
const [err, setErr] = useState<string | null>(null);
const reload = () => {
setErr(null);
fn()
.then(setData)
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, deps);
return { data, err, reload };
}
// ── status ────────────────────────────────────────────────────────────────────
function StatusTab() {
const { data, err } = useAsync(() => api.features());
return (
<Section title="Instance features">
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<pre style={{ overflow: 'auto', fontSize: 12 }}>{JSON.stringify(data, null, 2)}</pre>
</Section>
);
}
// ── admin keys ────────────────────────────────────────────────────────────────
function KeysTab() {
const { data, err, reload } = useAsync(() => api.keys());
const [pubkey, setPubkey] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const add = async () => {
setBusy(true);
setMsg(null);
try {
await api.addKey(pubkey.trim().toLowerCase());
setPubkey('');
reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const revoke = async (id: string) => {
if (!confirm('Revoke this admin key?')) return;
await api.revokeKey(id);
reload();
};
return (
<Section title="Admin keys">
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
{(data ?? []).map((k: KeyRow) => (
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{k.pubkey.slice(0, 16)}…</td>
<td style={{ padding: '6px 4px' }}>{k.status}</td>
<td style={{ padding: '6px 4px', textAlign: 'right' }}>
{k.status !== 'revoked' && (
<button class="danger" onClick={() => revoke(k.id)}>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input
style={{ flex: 1 }}
placeholder="new admin pubkey (64 hex)"
value={pubkey}
onInput={(e) => setPubkey((e.target as HTMLInputElement).value)}
/>
<button class="primary" disabled={busy || pubkey.trim().length !== 64} onClick={add}>
Add key
</button>
</div>
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
</Section>
);
}
// ── invites ───────────────────────────────────────────────────────────────────
function InvitesTab() {
const { data, err, reload } = useAsync(() => api.invites());
const [count, setCount] = useState(5);
const [label, setLabel] = useState('');
const [minted, setMinted] = useState<string[] | null>(null);
const [busy, setBusy] = useState(false);
const mint = async () => {
setBusy(true);
try {
const r = await api.mintInvites(count, label.trim() || undefined);
setMinted(r.codes);
reload();
} finally {
setBusy(false);
}
};
return (
<Section title={`Invite codes${data ? ` — ${data.unused_count} unused` : ''}`}>
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<input
type="number"
min={1}
max={1000}
style={{ width: 80 }}
value={count}
onInput={(e) => setCount(Number((e.target as HTMLInputElement).value))}
/>
<input
style={{ flex: 1 }}
placeholder="label (optional)"
value={label}
onInput={(e) => setLabel((e.target as HTMLInputElement).value)}
/>
<button class="primary" disabled={busy} onClick={mint}>
Mint
</button>
</div>
{minted && (
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
<b>New codes — copy now, they are shown once:</b>
<pre style={{ margin: '6px 0 0', whiteSpace: 'pre-wrap' }}>{minted.join('\n')}</pre>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
{(data?.invites ?? []).map((i: InviteView) => (
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{i.code_prefix}…</td>
<td style={{ padding: '6px 4px' }}>{i.label ?? ''}</td>
<td style={{ padding: '6px 4px', color: 'var(--smirk-fg-muted)' }}>{i.created_at.slice(0, 10)}</td>
<td style={{ padding: '6px 4px' }}>{i.status}</td>
</tr>
))}
</tbody>
</table>
</Section>
);
}
// ── config ────────────────────────────────────────────────────────────────────
type Overlay = Record<string, Record<string, unknown>>;
interface ConfigResp {
effective: Overlay;
overlay: Overlay;
versions: Record<string, number>;
runtime_class: Record<string, string>;
restart_pending: boolean;
}
function ConfigTab() {
const { data, err, reload } = useAsync(() => api.config() as Promise<ConfigResp>);
const [edit, setEdit] = useState<Overlay>({});
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => {
if (data) setEdit(structuredClone(data.effective));
}, [data]);
if (err) return <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>;
if (!data) return <Section title="Config">Loading…</Section>;
const setField = (section: string, field: string, value: unknown) =>
setEdit((e) => ({ ...e, [section]: { ...(e[section] ?? {}), [field]: value } }));
const save = async (section: string) => {
setMsg(null);
try {
await api.putConfig(
{ [section]: edit[section] },
{ [section]: data.versions[section] ?? 0 },
);
setMsg(`Saved ${section}.`);
reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
};
return (
<div>
{data.restart_pending && (
<div
style={{
background: 'rgba(245,158,11,0.12)',
border: '1px solid var(--smirk-warn)',
borderRadius: 8,
padding: 10,
marginBottom: 16,
}}
>
⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode).
</div>
)}
{msg && <p style={{ color: 'var(--smirk-accent)' }}>{msg}</p>}
{Object.keys(edit)
.sort()
.map((section) => (
<Section title={section}>
{Object.entries(edit[section] ?? {}).map(([field, value]) => {
const cls = data.runtime_class[`${section}.${field}`] ?? 'restart-required';
const overridden = data.overlay[section]?.[field] !== undefined;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<label style={{ flex: 1, minWidth: 0 }}>
{field}{' '}
<span style={{ fontSize: 11, color: 'var(--smirk-fg-muted)' }}>
· {cls === 'restart-required' ? 'restart' : 'live'}
{overridden ? ' · db' : ''}
</span>
</label>
{typeof value === 'boolean' ? (
<input
type="checkbox"
checked={value}
onChange={(e) => setField(section, field, (e.target as HTMLInputElement).checked)}
/>
) : (
<input
style={{ width: 220 }}
value={String(value ?? '')}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setField(section, field, typeof value === 'number' ? Number(v) : v);
}}
/>
)}
</div>
);
})}
<button class="primary" style={{ marginTop: 8 }} onClick={() => save(section)}>
Save {section}
</button>
</Section>
))}
</div>
);
}
// ── shell ─────────────────────────────────────────────────────────────────────
const TABS = ['Status', 'Keys', 'Invites', 'Config'] as const;
type Tab = (typeof TABS)[number];
function Console({ onLogout }: { onLogout: () => void }) {
const [tab, setTab] = useState<Tab>('Status');
return (
<div style={{ maxWidth: 760, margin: '0 auto', padding: 20 }}>
<header style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, flex: 1 }}>🐸 Smirk Operator Console</h2>
<button
onClick={() => {
api.logout().catch(() => {});
setToken(null);
onLogout();
}}
>
Sign out
</button>
</header>
<nav style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
{TABS.map((t) => (
<button class={t === tab ? 'primary' : ''} onClick={() => setTab(t)}>
{t}
</button>
))}
</nav>
{tab === 'Status' && <StatusTab />}
{tab === 'Keys' && <KeysTab />}
{tab === 'Invites' && <InvitesTab />}
{tab === 'Config' && <ConfigTab />}
</div>
);
}
function App() {
const [authed, setAuthed] = useState(false);
return authed ? <Console onLogout={() => setAuthed(false)} /> : <Login onDone={() => setAuthed(true)} />;
}
render(<App />, document.getElementById('app')!);
+60
View File
@@ -0,0 +1,60 @@
// Admin login = a NIP-98 kind-27235 "signed action" over a server-issued nonce.
// The backend (admin_verify) binds SIX tags exactly once and a request-descriptor
// hash over an EMPTY body. Get any of this wrong and verify returns an opaque 401,
// so this mirrors src/core/crypto/nip98.rs precisely.
async function sha256Hex(input: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export interface Challenge {
challenge: string;
url: string;
instance_id: string;
expires_in: number;
}
/** A NIP-07 signer: `window.nostr` (Smirk / Alby / nos2x). */
export interface Nip07 {
getPublicKey(): Promise<string>;
signEvent(event: {
kind: number;
created_at: number;
content: string;
tags: string[][];
}): Promise<{ id: string; pubkey: string; sig: string; [k: string]: unknown }>;
}
/**
* Build + sign the admin_login event for a challenge and return the
* `Nostr <base64(event)>` token the backend's `admin_token` field expects.
*/
export async function buildAdminToken(c: Challenge, signer: Nip07): Promise<string> {
// request_descriptor("POST", "/admin/auth/verify", "", b"") then its sha256.
const emptyBodyHash = await sha256Hex('');
const descriptor = `POST\n/admin/auth/verify\n\n${emptyBodyHash}`;
const payload = await sha256Hex(descriptor);
const signed = await signer.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', c.url],
['method', 'POST'],
['purpose', 'admin_login'],
['challenge', c.challenge],
['payload', payload],
['instance_id', c.instance_id],
],
});
return `Nostr ${btoa(JSON.stringify(signed))}`;
}
/** The page-injected NIP-07 provider, or null. Smirk installs `window.nostr`. */
export function nip07(): Nip07 | null {
const n = (window as unknown as { nostr?: Nip07 }).nostr;
return n && typeof n.signEvent === 'function' ? n : null;
}
+50
View File
@@ -0,0 +1,50 @@
/* Smirk brand tokens (vendored — the admin-ui is standalone, no @smirk/ui dep). */
:root {
--smirk-bg: #2c1e12;
--smirk-bg-elevated: #3a2a1b;
--smirk-bg-sunken: #241810;
--smirk-fg: #f3e9dd;
--smirk-fg-muted: #b9a891;
--smirk-accent: #f5c542;
--smirk-accent-fg: #2c1e12;
--smirk-border: rgba(255, 255, 255, 0.12);
--smirk-border-strong: rgba(255, 255, 255, 0.22);
--smirk-danger: #ef4444;
--smirk-ok: #22c55e;
--smirk-warn: #f59e0b;
--smirk-radius: 10px;
--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--smirk-bg);
color: var(--smirk-fg);
font-family: var(--smirk-font);
font-size: 14px;
line-height: 1.5;
}
h1, h2, h3 { font-weight: 700; }
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--smirk-border);
background: var(--smirk-bg-elevated);
color: inherit;
padding: 8px 14px;
}
button.primary { background: var(--smirk-accent); color: var(--smirk-accent-fg); border: none; font-weight: 700; }
button.danger { color: var(--smirk-danger); border-color: var(--smirk-danger); }
button:disabled { opacity: 0.5; cursor: default; }
input, textarea, select {
font: inherit;
color: inherit;
background: var(--smirk-bg-sunken);
border: 1px solid var(--smirk-border);
border-radius: 8px;
padding: 8px 10px;
}
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
a { color: var(--smirk-accent); }
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
// Served by the Rust backend at /admin (rust-embed). `base: '/admin/'` makes the
// built asset URLs absolute under /admin/assets/ so they resolve regardless of the
// trailing slash. Preact JSX via esbuild (no plugin, keeps the toolchain tiny).
export default defineConfig({
base: '/admin/',
build: {
outDir: 'dist',
target: 'es2022',
emptyOutDir: true,
assetsDir: 'assets',
},
esbuild: {
jsx: 'automatic',
jsxImportSource: 'preact',
},
});