Stage 5: admin dashboard (Next.js App Router) + completes Stage 2.3 handlers

Operator console for the Publishing Engine — cool/forge Nine Laws temperature
(forest + citrus, amber deliberately absent).

- Auth: Authentik forward-auth header parsing (x-authentik-username / -groups ->
  roles) with a dev-stub principal (AUTH_DEV_PRINCIPAL). Pure auth-core is
  unit-tested; getPrincipal binds it to next/headers.
- API route handlers (closes Stage 2.3): /api/content, /api/approvals,
  /api/publications/cancel, /api/feature-flags. Each: Zod parse -> principal ->
  role authz (requireAllowed) -> DB write + append-only audit. handleMutation
  maps errors to 401/403/404/409/422/500 with a correlation id.
- Pages (force-dynamic, DB-backed): /queue (approve/cancel), /publications/[id]
  (detail + metrics), /metrics (AST day x hour heatmap with text+color per WCAG
  1.4.1), /auth/linkedin (OAuth status + per-outlet kill-switch toggle).
- A11y: semantic landmarks, skip-link, scoped table headers, aria-live error
  alerts, heatmap text redundancy.
- Tests: 10 pure (auth-core, heatmap) + 5 gated handler-integration (authz tiers
  + happy paths vs real Postgres). Strict tsc + next build (standalone) green.

Deferred (need a browser/IdP runner): live Authentik binding, axe-core/Playwright
+ NVDA CI, Lighthouse gates (Stage 5.3/5.4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Angelo B. J. Luidens
2026-06-03 15:30:16 -04:00
parent c0dad39d55
commit 1d1494a77c
30 changed files with 1440 additions and 3 deletions
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
reactStrictMode: true,
// The admin app is server-rendered (DB-backed, auth-gated); never statically exported.
transpilePackages: ["@stargue/schema", "@stargue/sanitize", "@stargue/observability"],
};
export default config;
+5
View File
@@ -15,17 +15,22 @@
"@stargue/schema": "workspace:*",
"@stargue/sanitize": "workspace:*",
"@stargue/observability": "workspace:*",
"drizzle-orm": "^0.36.0",
"next": "^16.0.0",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"server-only": "^0.0.1",
"zod": "^3.23.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.2.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"axe-core": "^4.10.0",
"tailwindcss": "^4.2.2",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+12
View File
@@ -0,0 +1,12 @@
import { handleMutation } from "@/lib/api";
import { getDb } from "@/lib/db";
import { createApproval } from "@/lib/handlers";
import { CreateApprovalRequestSchema } from "@stargue/schema";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
return handleMutation(req, CreateApprovalRequestSchema, (input, principal, corr) =>
createApproval(getDb(), principal, input, corr),
);
}
+12
View File
@@ -0,0 +1,12 @@
import { handleMutation } from "@/lib/api";
import { getDb } from "@/lib/db";
import { createContent } from "@/lib/handlers";
import { CreateContentRequestSchema } from "@stargue/schema";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
return handleMutation(req, CreateContentRequestSchema, (input, principal, corr) =>
createContent(getDb(), principal, input, corr),
);
}
@@ -0,0 +1,12 @@
import { handleMutation } from "@/lib/api";
import { getDb } from "@/lib/db";
import { toggleFeatureFlag } from "@/lib/handlers";
import { ToggleFeatureFlagRequestSchema } from "@stargue/schema";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
return handleMutation(req, ToggleFeatureFlagRequestSchema, (input, principal, corr) =>
toggleFeatureFlag(getDb(), principal, input, corr),
);
}
@@ -0,0 +1,12 @@
import { handleMutation } from "@/lib/api";
import { getDb } from "@/lib/db";
import { cancelPublication } from "@/lib/handlers";
import { CancelPublicationRequestSchema } from "@stargue/schema";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
return handleMutation(req, CancelPublicationRequestSchema, (input, principal, corr) =>
cancelPublication(getDb(), principal, input, corr),
);
}
+90
View File
@@ -0,0 +1,90 @@
import { getDb } from "@/lib/db";
import { listFeatureFlags } from "@/lib/handlers";
import { getPrincipal } from "@/lib/auth";
import { FlagToggle } from "@/components/FlagToggle";
import { linkedin_tokens } from "@stargue/schema/db";
import { fmtAst } from "@/lib/format";
export const dynamic = "force-dynamic";
export default async function LinkedInAuthPage() {
const principal = await getPrincipal();
if (!principal) {
return (
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
);
}
const db = getDb();
const flags = await listFeatureFlags(db);
const tokens = await db
.select({
subject_type: linkedin_tokens.subject_type,
subject_urn: linkedin_tokens.subject_urn,
access_expires_at: linkedin_tokens.access_expires_at,
refresh_expires_at: linkedin_tokens.refresh_expires_at,
scopes: linkedin_tokens.scopes,
})
.from(linkedin_tokens);
const canToggle = principal.roles.includes("operator");
return (
<section className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">LinkedIn connection</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
OAuth status and per-outlet kill-switch.
</p>
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
<h2 className="text-lg font-semibold">Authorization</h2>
{tokens.length === 0 ? (
<p className="mt-2 text-sm text-[var(--color-muted)]">
Not connected. Community Management API access is in review; once granted,
authorize the app here to begin publishing.
</p>
) : (
<ul className="mt-3 space-y-2 text-sm">
{tokens.map((t) => (
<li key={t.subject_urn} className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="font-mono text-xs">{t.subject_urn}</span>
<span className="text-[var(--color-muted)]">({t.subject_type})</span>
<span className="text-[var(--color-muted)]">
access expires {fmtAst(t.access_expires_at)}
</span>
<span className="text-[var(--color-muted)]">
refresh expires {fmtAst(t.refresh_expires_at)}
</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
<h2 className="text-lg font-semibold">Kill-switch</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Disable an outlet to halt dispatch within ~30s without a redeploy. Toggling is
audit-logged.
</p>
{flags.length === 0 ? (
<p className="mt-3 text-sm text-[var(--color-muted)]">
No outlet flags set yet (outlets default to enabled).
</p>
) : (
<ul className="mt-4 space-y-3">
{flags.map((f) => (
<li key={f.outlet} className="flex flex-wrap items-center gap-3">
<span className="w-24 font-medium">{f.outlet}</span>
<span className="text-sm text-[var(--color-muted)]">
{f.enabled ? "enabled" : `disabled — ${f.reason ?? "no reason"}`}
</span>
{canToggle && <FlagToggle outlet={f.outlet} enabled={f.enabled} />}
</li>
))}
</ul>
)}
</div>
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
@import "tailwindcss";
/*
* Nine Laws of the Hearth — admin/operator temperature (cool / forge).
* Forest (#4E8211) + Citrus (#99CC00) on near-black green. Amber is reserved
* for Hearth content surfaces and never appears here (Law 4).
*/
@theme {
--color-bg: #0e1303;
--color-surface: #18200a;
--color-surface-2: #212b0f;
--color-border: #34401b;
--color-forest: #4e8211;
--color-citrus: #99cc00;
--color-ink: #e9ede0;
--color-muted: #9aa089;
--color-danger: #ef4444;
--color-slate: #64748b;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
}
:root {
color-scheme: dark;
}
body {
background-color: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
}
a {
color: var(--color-citrus);
}
a:hover {
color: var(--color-forest);
}
+64
View File
@@ -0,0 +1,64 @@
import "./globals.css";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import Link from "next/link";
export const metadata: Metadata = {
title: "Stargue Publishing Engine — Admin",
description: "Operator console for the Stargue Publishing Engine.",
robots: "noindex, nofollow",
};
const NAV = [
{ href: "/queue", label: "Queue" },
{ href: "/metrics", label: "Metrics" },
{ href: "/auth/linkedin", label: "LinkedIn" },
];
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen antialiased">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-[var(--color-forest)] focus:px-3 focus:py-2 focus:text-white"
>
Skip to content
</a>
<header className="border-b border-[var(--color-border)] bg-[var(--color-surface)]">
<nav
aria-label="Primary"
className="mx-auto flex max-w-6xl items-center justify-between px-6 py-3"
>
<Link href="/queue" className="flex items-center gap-2 !text-[var(--color-ink)]">
<span
aria-hidden="true"
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: "var(--color-citrus)" }}
/>
<span className="font-semibold tracking-tight">Stargue PE</span>
<span className="text-xs text-[var(--color-muted)]">· operator</span>
</Link>
<ul className="flex items-center gap-1 text-sm">
{NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="rounded px-3 py-2 !text-[var(--color-muted)] transition-colors hover:bg-[var(--color-surface-2)] hover:!text-[var(--color-ink)]"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
</header>
<main id="main" className="mx-auto max-w-6xl px-6 py-8">
{children}
</main>
</body>
</html>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { getDb } from "@/lib/db";
import { listPublishedWithMetrics } from "@/lib/handlers";
import { getPrincipal } from "@/lib/auth";
import { buildHeatmap, DAY_LABELS } from "@/lib/heatmap";
export const dynamic = "force-dynamic";
const HOURS = Array.from({ length: 24 }, (_, h) => h);
export default async function MetricsPage() {
const principal = await getPrincipal();
if (!principal) {
return (
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
);
}
const rows = await listPublishedWithMetrics(getDb());
const cells = buildHeatmap(rows);
const byKey = new Map(cells.map((c) => [`${c.day}-${c.hour}`, c]));
const maxEng = Math.max(1, ...cells.map((c) => c.engagement));
return (
<section>
<h1 className="text-2xl font-semibold tracking-tight">Engagement heatmap</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Published posts by AST day × hour. Each cell shows the post count; background
intensity is total engagement (reactions + comments + shares). {rows.length} published.
</p>
<div className="mt-6 overflow-x-auto">
<table className="border-collapse text-xs">
<caption className="sr-only">
Posts and engagement by day of week and hour, Atlantic Standard Time
</caption>
<thead>
<tr>
<th scope="col" className="p-1" />
{HOURS.map((h) => (
<th key={h} scope="col" className="p-1 font-normal text-[var(--color-muted)]">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{DAY_LABELS.map((label, day) => (
<tr key={day}>
<th scope="row" className="p-1 pr-2 text-right font-normal text-[var(--color-muted)]">
{label}
</th>
{HOURS.map((h) => {
const c = byKey.get(`${day}-${h}`);
const intensity = c ? c.engagement / maxEng : 0;
const hh = String(h).padStart(2, "0");
const labelText = c
? `${label} ${hh}:00 — ${c.count} post${c.count === 1 ? "" : "s"}, ${c.engagement} engagement`
: `${label} ${hh}:00 — no posts`;
return (
<td
key={h}
title={labelText}
aria-label={labelText}
className="h-7 w-7 border border-[var(--color-border)] text-center"
style={{
backgroundColor: c ? `rgba(153,204,0,${0.15 + intensity * 0.6})` : "transparent",
color: c ? "#0e1303" : "transparent",
}}
>
{c ? c.count : ""}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/queue");
}
@@ -0,0 +1,116 @@
import { notFound } from "next/navigation";
import { getDb } from "@/lib/db";
import { getPublicationDetail } from "@/lib/handlers";
import { getPrincipal } from "@/lib/auth";
import { StatusBadge } from "@/components/StatusBadge";
import { fmtAst } from "@/lib/format";
export const dynamic = "force-dynamic";
export default async function PublicationDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const principal = await getPrincipal();
if (!principal) {
return (
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
);
}
const { id } = await params;
const pid = Number(id);
if (!Number.isInteger(pid) || pid <= 0) notFound();
const detail = await getPublicationDetail(getDb(), pid);
if (!detail) notFound();
const { pub, metrics } = detail;
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{pub.title}</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">{pub.slug}</p>
</div>
<dl className="grid grid-cols-2 gap-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-6 sm:grid-cols-3">
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Outlet</dt>
<dd className="mt-1">{pub.outlet}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Status</dt>
<dd className="mt-1">
<StatusBadge status={pub.status} />
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Scheduled</dt>
<dd className="mt-1 text-[var(--color-muted)]">{fmtAst(pub.scheduled_at)}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Published</dt>
<dd className="mt-1 text-[var(--color-muted)]">{fmtAst(pub.published_at)}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">External</dt>
<dd className="mt-1">
{pub.external_url ? (
<a href={pub.external_url} target="_blank" rel="noopener noreferrer">
{pub.external_id ?? "view"}
</a>
) : (
<span className="text-[var(--color-muted)]"></span>
)}
</dd>
</div>
<div className="col-span-2 sm:col-span-3">
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
Idempotency key
</dt>
<dd className="mt-1 font-mono text-xs text-[var(--color-muted)]">
{pub.idempotency_key}
</dd>
</div>
{pub.error && (
<div className="col-span-2 sm:col-span-3">
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Error</dt>
<dd className="mt-1 text-sm text-[var(--color-danger)]">{pub.error}</dd>
</div>
)}
</dl>
<div>
<h2 className="text-lg font-semibold">Metrics</h2>
{metrics.length === 0 ? (
<p className="mt-2 text-sm text-[var(--color-muted)]">No metrics collected yet.</p>
) : (
<table className="mt-3 w-full text-sm">
<thead className="text-left text-[var(--color-muted)]">
<tr>
<th scope="col" className="py-1 font-medium">Collected</th>
<th scope="col" className="py-1 font-medium">Impr.</th>
<th scope="col" className="py-1 font-medium">React.</th>
<th scope="col" className="py-1 font-medium">Comments</th>
<th scope="col" className="py-1 font-medium">Shares</th>
<th scope="col" className="py-1 font-medium">Clicks</th>
</tr>
</thead>
<tbody>
{metrics.map((m) => (
<tr key={m.id} className="border-t border-[var(--color-border)]">
<td className="py-1 text-[var(--color-muted)]">{fmtAst(m.collected_at)}</td>
<td className="py-1">{m.impressions}</td>
<td className="py-1">{m.reactions}</td>
<td className="py-1">{m.comments}</td>
<td className="py-1">{m.shares}</td>
<td className="py-1">{m.clicks}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import { getDb } from "@/lib/db";
import { listQueue } from "@/lib/handlers";
import { getPrincipal } from "@/lib/auth";
import { StatusBadge } from "@/components/StatusBadge";
import { CancelButton } from "@/components/CancelButton";
import { fmtAst } from "@/lib/format";
export const dynamic = "force-dynamic";
const CANCELLABLE = new Set(["pending", "queued", "scheduled", "dispatching", "failed"]);
export default async function QueuePage() {
const principal = await getPrincipal();
if (!principal) {
return (
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
);
}
const rows = await listQueue(getDb());
const canCancel = principal.roles.includes("operator");
return (
<section>
<h1 className="text-2xl font-semibold tracking-tight">Publish queue</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Signed in as {principal.sub} · {principal.roles.join(", ")}
</p>
{rows.length === 0 ? (
<p className="mt-8 text-[var(--color-muted)]">Nothing queued.</p>
) : (
<div className="mt-6 overflow-x-auto rounded-lg border border-[var(--color-border)]">
<table className="w-full text-sm">
<thead className="bg-[var(--color-surface)] text-left text-[var(--color-muted)]">
<tr>
<th scope="col" className="px-4 py-2 font-medium">Title</th>
<th scope="col" className="px-4 py-2 font-medium">Outlet</th>
<th scope="col" className="px-4 py-2 font-medium">Status</th>
<th scope="col" className="px-4 py-2 font-medium">Scheduled</th>
<th scope="col" className="px-4 py-2 font-medium">Published</th>
<th scope="col" className="px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-[var(--color-border)]">
<td className="px-4 py-2">
<Link
href={`/publications/${r.id}`}
className="!text-[var(--color-ink)] underline-offset-2 hover:underline"
>
{r.title}
</Link>
<span className="ml-2 text-xs text-[var(--color-muted)]">{r.slug}</span>
</td>
<td className="px-4 py-2">{r.outlet}</td>
<td className="px-4 py-2">
<StatusBadge status={r.status} />
</td>
<td className="px-4 py-2 text-[var(--color-muted)]">{fmtAst(r.scheduled_at)}</td>
<td className="px-4 py-2 text-[var(--color-muted)]">{fmtAst(r.published_at)}</td>
<td className="px-4 py-2">
{canCancel && CANCELLABLE.has(r.status) ? (
<CancelButton publicationId={r.id} />
) : (
<span className="text-xs text-[var(--color-muted)]"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function CancelButton({ publicationId }: { publicationId: number }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
async function onCancel() {
const reason = window.prompt("Reason for cancelling this publication?");
if (!reason) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/publications/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publication_id: publicationId, reason }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? `Failed (${res.status})`);
return;
}
router.refresh();
} finally {
setBusy(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
aria-busy={busy}
className="rounded border border-[var(--color-border)] px-2 py-1 text-xs text-[var(--color-muted)] transition-colors hover:border-[var(--color-danger)] hover:text-[var(--color-danger)] disabled:opacity-50"
>
{busy ? "Cancelling…" : "Cancel"}
</button>
{error && (
<span role="alert" className="text-xs text-[var(--color-danger)]">
{error}
</span>
)}
</span>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function FlagToggle({
outlet,
enabled,
}: {
outlet: string;
enabled: boolean;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const next = !enabled;
async function onToggle() {
const reason =
window.prompt(
`Reason for turning ${outlet} publishing ${next ? "ON" : "OFF"}?`,
) ?? "";
if (!reason.trim()) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/feature-flags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outlet, enabled: next, reason }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? `Failed (${res.status})`);
return;
}
router.refresh();
} finally {
setBusy(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-busy={busy}
aria-pressed={enabled}
className="rounded px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50"
style={{
backgroundColor: enabled ? "rgba(78,130,17,0.25)" : "rgba(239,68,68,0.2)",
color: enabled ? "#bfe35a" : "#fca5a5",
}}
>
{busy ? "…" : next ? `Enable ${outlet}` : `Disable ${outlet}`}
</button>
{error && (
<span role="alert" className="text-xs text-[var(--color-danger)]">
{error}
</span>
)}
</span>
);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Status badge — color + text (never color alone, WCAG 1.4.1). Uses the cool
* operator palette: forest/citrus for healthy, slate for in-flight, red for
* failure. Amber is deliberately absent (reserved for Hearth content).
*/
const STYLES: Record<string, { bg: string; fg: string }> = {
published: { bg: "rgba(78,130,17,0.25)", fg: "#bfe35a" },
dispatching: { bg: "rgba(153,204,0,0.18)", fg: "#cdec7a" },
queued: { bg: "rgba(100,116,139,0.25)", fg: "#cbd5e1" },
scheduled: { bg: "rgba(100,116,139,0.25)", fg: "#cbd5e1" },
pending: { bg: "rgba(100,116,139,0.20)", fg: "#aab4c4" },
failed: { bg: "rgba(239,68,68,0.20)", fg: "#fca5a5" },
dlq: { bg: "rgba(239,68,68,0.28)", fg: "#fecaca" },
cancelled: { bg: "rgba(120,120,120,0.20)", fg: "#cfcfcf" },
};
export function StatusBadge({ status }: { status: string }) {
const s = STYLES[status] ?? { bg: "rgba(120,120,120,0.2)", fg: "#cfcfcf" };
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{ backgroundColor: s.bg, color: s.fg }}
>
{status}
</span>
);
}
+47
View File
@@ -0,0 +1,47 @@
import "server-only";
import { z } from "zod";
import { AuthzError, type Principal } from "@stargue/schema";
import { getPrincipal } from "./auth";
import { NotFoundError, ConflictError } from "./handlers";
export function jsonError(
status: number,
error: string,
extra?: Record<string, unknown>,
): Response {
return Response.json({ error, ...(extra ?? {}) }, { status });
}
/**
* Wraps a mutating route handler with the full gate chain:
* 401 unauthenticated → 403 forbidden (authz) → 422 invalid body →
* 404 not found → 409 conflict → 500.
* The handler itself enforces role authz via `requireAllowed`; this maps the
* resulting errors to HTTP status codes and threads a correlation id.
*/
export async function handleMutation<S extends z.ZodTypeAny>(
req: Request,
schema: S,
fn: (input: z.infer<S>, principal: Principal, correlationId: string) => Promise<unknown>,
): Promise<Response> {
const correlationId = crypto.randomUUID();
try {
const principal = await getPrincipal();
if (!principal) return jsonError(401, "unauthenticated");
const body = await req.json().catch(() => ({}));
const parsed = schema.safeParse(body);
if (!parsed.success) {
return jsonError(422, "validation_failed", { issues: parsed.error.issues });
}
const result = await fn(parsed.data, principal, correlationId);
return Response.json({ ok: true, data: result, correlationId }, { status: 200 });
} catch (err) {
if (err instanceof AuthzError) return jsonError(403, "forbidden");
if (err instanceof NotFoundError) return jsonError(404, err.message);
if (err instanceof ConflictError) return jsonError(409, err.message);
console.error(`[admin] route error (corr=${correlationId}):`, err);
return jsonError(500, "internal_error", { correlationId });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { principalFromAuthentik, principalFromDevEnv } from "./auth-core";
describe("principalFromAuthentik", () => {
it("maps Authentik groups to roles", () => {
const p = principalFromAuthentik(
"angelo",
"stargue-pe-operators|stargue-pe-approvers",
);
expect(p).toEqual({ sub: "angelo", roles: ["operator", "approver"] });
});
it("returns null when no username", () => {
expect(principalFromAuthentik(null, "stargue-pe-operators")).toBeNull();
});
it("returns null when no mappable groups", () => {
expect(principalFromAuthentik("angelo", "some-other-group")).toBeNull();
expect(principalFromAuthentik("angelo", "")).toBeNull();
});
});
describe("principalFromDevEnv", () => {
it("parses sub:roles", () => {
expect(principalFromDevEnv("angelo:operator,approver")).toEqual({
sub: "angelo",
roles: ["operator", "approver"],
});
});
it("defaults to operator when only a sub is given", () => {
expect(principalFromDevEnv("angelo")).toEqual({ sub: "angelo", roles: ["operator"] });
});
it("returns null for undefined / empty", () => {
expect(principalFromDevEnv(undefined)).toBeNull();
expect(principalFromDevEnv("")).toBeNull();
expect(principalFromDevEnv(":operator")).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Principal, Role } from "@stargue/schema";
/**
* Maps Authentik group names to Publishing-Engine roles. The forward-auth proxy
* (Authentik outpost) injects the authenticated user's groups; we translate them
* to the authz roles consumed by `@stargue/schema` `requireAllowed`.
*
* Pure module (no next/* imports) so it is unit-testable without a server.
*/
export const GROUP_ROLE_MAP: Record<string, Role> = {
"stargue-pe-operators": "operator",
"stargue-pe-approvers": "approver",
"stargue-pe-viewers": "viewer",
};
/** Resolve a principal from Authentik forward-auth headers (groups separated by `|`). */
export function principalFromAuthentik(
username: string | null,
groupsHeader: string | null,
): Principal | null {
if (!username) return null;
const groups = (groupsHeader ?? "")
.split("|")
.map((g) => g.trim())
.filter(Boolean);
const roles = groups
.map((g) => GROUP_ROLE_MAP[g])
.filter((r): r is Role => Boolean(r));
if (roles.length === 0) return null;
return { sub: username, roles };
}
/**
* Dev-only stub principal from `AUTH_DEV_PRINCIPAL` (format `sub:role1,role2`,
* e.g. `angelo:operator,approver`). Lets the dashboard run without a live IdP.
*/
export function principalFromDevEnv(dev: string | undefined): Principal | null {
if (!dev) return null;
const idx = dev.indexOf(":");
const sub = (idx === -1 ? dev : dev.slice(0, idx)).trim();
const rolesCsv = idx === -1 ? "operator" : dev.slice(idx + 1);
const roles = rolesCsv
.split(",")
.map((r) => r.trim())
.filter(Boolean) as Role[];
if (!sub || roles.length === 0) return null;
return { sub, roles };
}
+19
View File
@@ -0,0 +1,19 @@
import "server-only";
import { headers } from "next/headers";
import type { Principal } from "@stargue/schema";
import { principalFromAuthentik, principalFromDevEnv } from "./auth-core";
/**
* Resolve the current principal. Dev stub (`AUTH_DEV_PRINCIPAL`) takes precedence
* for local development; otherwise reads the Authentik forward-auth headers.
* Returns null when unauthenticated (callers map this to HTTP 401 / login).
*/
export async function getPrincipal(): Promise<Principal | null> {
const dev = principalFromDevEnv(process.env.AUTH_DEV_PRINCIPAL);
if (dev) return dev;
const h = await headers();
return principalFromAuthentik(
h.get("x-authentik-username"),
h.get("x-authentik-groups"),
);
}
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { createDb, type Db } from "@stargue/schema/client";
let _db: Db | null = null;
/** Lazily-created singleton DB handle for server components + route handlers. */
export function getDb(): Db {
if (!_db) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is required");
_db = createDb(url).db;
}
return _db;
}
+10
View File
@@ -0,0 +1,10 @@
import { AST_OFFSET_MS } from "./heatmap";
/** Format a UTC timestamp as `YYYY-MM-DD HH:MM AST` (Curaçao, no DST). */
export function fmtAst(d: Date | null | undefined): string {
if (!d) return "—";
return (
new Date(d.getTime() + AST_OFFSET_MS).toISOString().slice(0, 16).replace("T", " ") +
" AST"
);
}
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { eq } from "drizzle-orm";
import { createDb, type DbHandle } from "@stargue/schema/client";
import { AuthzError, type Principal, type CreateContentRequest } from "@stargue/schema";
import { content, publications, audit } from "@stargue/schema/db";
import {
createContent,
createApproval,
cancelPublication,
toggleFeatureFlag,
NotFoundError,
ConflictError,
} from "./handlers";
/**
* Admin handler tests — authz tiers + happy paths against real Postgres.
* Gated behind INTEGRATION=1. Covers the Stage 2.3 route-handler intent
* (401/403/404/409/200 logic) at the handler layer, testable without a server.
*/
const RUN = !!process.env.INTEGRATION;
const URL =
process.env.DATABASE_URL ??
"postgres://stargue:stargue_dev@localhost:5433/stargue_publishing_engine";
const operator: Principal = { sub: "angelo", roles: ["operator"] };
const approver: Principal = { sub: "carlo", roles: ["approver"] };
const viewer: Principal = { sub: "guest", roles: ["viewer"] };
const baseFrontmatter = {
status: "ready" as const,
language: "en" as const,
outlets: [{ outlet: "linkedin.org" as const, status: "queued" as const, published_url: null }],
scheduled: null,
category: "blog" as const,
canonical: "stargue.com",
sanitize: true,
version: 1,
};
const contentInput = (slug = "x"): CreateContentRequest => ({
vault_path: `Stargue/Projects/Publishing Engine/${slug}.md`,
slug,
title: "X",
body_sanitized: "body",
frontmatter: baseFrontmatter,
content_hash: "c".repeat(64),
});
describe.skipIf(!RUN)("admin handlers — real Postgres (Stage 2.3 / 5)", () => {
let h: DbHandle;
beforeAll(() => {
h = createDb(URL);
});
afterAll(async () => {
await h.sql.end();
});
beforeEach(async () => {
await h.sql`TRUNCATE content, publications, approvals, metrics, audit, outlet_feature_flags RESTART IDENTITY CASCADE`;
});
it("createContent: operator allowed → row + audit", async () => {
const row = await createContent(h.db, operator, contentInput(), "corr-1");
expect(row.slug).toBe("x");
const auditRows = await h.db.select().from(audit).where(eq(audit.correlation_id, "corr-1"));
expect(auditRows).toHaveLength(1);
expect(auditRows[0]!.action).toBe("content.create");
expect(auditRows[0]!.actor).toBe("angelo");
});
it("createContent: viewer forbidden (AuthzError) and writes nothing", async () => {
await expect(
createContent(h.db, viewer, contentInput("y"), "corr-2"),
).rejects.toBeInstanceOf(AuthzError);
expect(await h.db.select().from(content)).toHaveLength(0);
});
it("createApproval: approver allowed; missing content → NotFoundError", async () => {
const c = await createContent(h.db, operator, contentInput("z"), "corr-3");
const appr = await createApproval(
h.db,
approver,
{ content_id: c.id, outlet: "linkedin.org", notes: "lgtm" },
"corr-4",
);
expect(appr.approved_by).toBe("carlo");
await expect(
createApproval(h.db, approver, { content_id: 99999, outlet: "linkedin.org" }, "corr-5"),
).rejects.toBeInstanceOf(NotFoundError);
});
it("cancelPublication: operator cancels queued; published → ConflictError", async () => {
const c = await createContent(h.db, operator, contentInput("p"), "corr-6");
const [pub] = await h.db
.insert(publications)
.values({
content_id: c.id,
outlet: "linkedin.org",
status: "queued",
idempotency_key: `${c.id}|linkedin.org|immediate`,
})
.returning();
const cancelled = await cancelPublication(
h.db,
operator,
{ publication_id: pub!.id, reason: "typo" },
"corr-7",
);
expect(cancelled.status).toBe("cancelled");
const [pub2] = await h.db
.insert(publications)
.values({
content_id: c.id,
outlet: "linkedin.member",
status: "published",
idempotency_key: `${c.id}|linkedin.member|immediate`,
})
.returning();
await expect(
cancelPublication(h.db, operator, { publication_id: pub2!.id, reason: "x" }, "corr-8"),
).rejects.toBeInstanceOf(ConflictError);
});
it("toggleFeatureFlag: operator upserts the kill-switch", async () => {
const off = await toggleFeatureFlag(
h.db,
operator,
{ outlet: "linkedin.org", enabled: false, reason: "auth expired" },
"corr-9",
);
expect(off.enabled).toBe(false);
const on = await toggleFeatureFlag(
h.db,
operator,
{ outlet: "linkedin.org", enabled: true, reason: "restored" },
"corr-10",
);
expect(on.enabled).toBe(true);
expect(on.outlet).toBe("linkedin.org");
});
});
+243
View File
@@ -0,0 +1,243 @@
import { eq, desc, and, isNotNull } from "drizzle-orm";
import type { Db } from "@stargue/schema/client";
import {
content,
publications,
approvals,
outlet_feature_flags,
metrics,
audit,
} from "@stargue/schema/db";
import {
requireAllowed,
type Principal,
type CreateContentRequest,
type CreateApprovalRequest,
type CancelPublicationRequest,
type ToggleFeatureFlagRequest,
} from "@stargue/schema";
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "NotFoundError";
}
}
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "ConflictError";
}
}
/** Append-only audit write. Every state-changing handler calls this. */
async function writeAudit(
db: Db,
principal: Principal,
action: string,
subjectType: string,
subjectId: number | string,
correlationId: string,
payload: Record<string, unknown> | null,
): Promise<void> {
await db.insert(audit).values({
actor: principal.sub,
action,
subject_type: subjectType,
subject_id: String(subjectId),
correlation_id: correlationId,
payload_jsonb: payload,
});
}
// ---- Mutations (authz-gated, audited) ----
export async function createContent(
db: Db,
principal: Principal,
input: CreateContentRequest,
correlationId: string,
) {
requireAllowed(principal, "content.create");
const [row] = await db
.insert(content)
.values({
vault_path: input.vault_path,
slug: input.slug,
title: input.title,
body_sanitized: input.body_sanitized,
frontmatter_jsonb: input.frontmatter,
content_hash: input.content_hash,
})
.returning();
await writeAudit(db, principal, "content.create", "content", row!.id, correlationId, {
slug: input.slug,
});
return row!;
}
export async function createApproval(
db: Db,
principal: Principal,
input: CreateApprovalRequest,
correlationId: string,
) {
requireAllowed(principal, "approvals.create");
const [c] = await db.select().from(content).where(eq(content.id, input.content_id));
if (!c) throw new NotFoundError(`content ${input.content_id} not found`);
const [row] = await db
.insert(approvals)
.values({
content_id: input.content_id,
outlet: input.outlet,
approved_by: principal.sub,
notes: input.notes ?? null,
})
.returning();
await writeAudit(db, principal, "approvals.create", "approval", row!.id, correlationId, {
content_id: input.content_id,
outlet: input.outlet,
});
return row!;
}
export async function cancelPublication(
db: Db,
principal: Principal,
input: CancelPublicationRequest,
correlationId: string,
) {
requireAllowed(principal, "publications.cancel");
const [pub] = await db
.select()
.from(publications)
.where(eq(publications.id, input.publication_id));
if (!pub) throw new NotFoundError(`publication ${input.publication_id} not found`);
if (pub.status === "published" || pub.status === "cancelled") {
throw new ConflictError(`cannot cancel a ${pub.status} publication`);
}
const [row] = await db
.update(publications)
.set({ status: "cancelled", error: input.reason })
.where(eq(publications.id, input.publication_id))
.returning();
await writeAudit(
db,
principal,
"publications.cancel",
"publication",
input.publication_id,
correlationId,
{ reason: input.reason },
);
return row!;
}
export async function toggleFeatureFlag(
db: Db,
principal: Principal,
input: ToggleFeatureFlagRequest,
correlationId: string,
) {
requireAllowed(principal, "feature_flags.toggle");
const [row] = await db
.insert(outlet_feature_flags)
.values({
outlet: input.outlet,
enabled: input.enabled,
reason: input.reason,
updated_by: principal.sub,
})
.onConflictDoUpdate({
target: outlet_feature_flags.outlet,
set: {
enabled: input.enabled,
reason: input.reason,
updated_by: principal.sub,
updated_at: new Date(),
},
})
.returning();
await writeAudit(
db,
principal,
"feature_flags.toggle",
"outlet",
input.outlet,
correlationId,
{ enabled: input.enabled, reason: input.reason },
);
return row!;
}
// ---- Reads (for pages) ----
export async function listQueue(db: Db) {
return db
.select({
id: publications.id,
outlet: publications.outlet,
status: publications.status,
scheduled_at: publications.scheduled_at,
published_at: publications.published_at,
external_url: publications.external_url,
error: publications.error,
content_id: content.id,
title: content.title,
slug: content.slug,
})
.from(publications)
.innerJoin(content, eq(publications.content_id, content.id))
.orderBy(desc(publications.scheduled_at))
.limit(200);
}
export async function getPublicationDetail(db: Db, id: number) {
const [pub] = await db
.select({
id: publications.id,
outlet: publications.outlet,
status: publications.status,
scheduled_at: publications.scheduled_at,
published_at: publications.published_at,
external_id: publications.external_id,
external_url: publications.external_url,
error: publications.error,
idempotency_key: publications.idempotency_key,
title: content.title,
slug: content.slug,
})
.from(publications)
.innerJoin(content, eq(publications.content_id, content.id))
.where(eq(publications.id, id));
if (!pub) return null;
const metricRows = await db
.select()
.from(metrics)
.where(eq(metrics.publication_id, id))
.orderBy(desc(metrics.collected_at));
return { pub, metrics: metricRows };
}
export async function listFeatureFlags(db: Db) {
return db.select().from(outlet_feature_flags).orderBy(outlet_feature_flags.outlet);
}
export async function listPublishedWithMetrics(db: Db) {
return db
.select({
id: publications.id,
outlet: publications.outlet,
published_at: publications.published_at,
impressions: metrics.impressions,
reactions: metrics.reactions,
comments: metrics.comments,
shares: metrics.shares,
})
.from(publications)
.leftJoin(metrics, eq(metrics.publication_id, publications.id))
.where(and(eq(publications.status, "published"), isNotNull(publications.published_at)))
.orderBy(desc(publications.published_at))
.limit(500);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { buildHeatmap, AST_OFFSET_MS } from "./heatmap";
describe("buildHeatmap", () => {
it("buckets by AST day/hour and sums engagement", () => {
// 2026-06-04T12:00Z is Thursday; AST (-4) → 08:00 Thursday.
const t = new Date("2026-06-04T12:00:00Z");
const cells = buildHeatmap([
{ published_at: t, impressions: 100, reactions: 5, comments: 2, shares: 1 },
{ published_at: t, impressions: 50, reactions: 3, comments: 0, shares: 0 },
]);
expect(cells).toHaveLength(1);
expect(cells[0]).toMatchObject({ day: 4, hour: 8, count: 2, engagement: 11 });
});
it("skips rows with no published_at", () => {
expect(
buildHeatmap([
{ published_at: null, impressions: 1, reactions: 1, comments: 1, shares: 1 },
]),
).toEqual([]);
});
it("treats null metric components as zero", () => {
const t = new Date("2026-06-04T12:00:00Z");
const cells = buildHeatmap([
{ published_at: t, impressions: null, reactions: null, comments: null, shares: null },
]);
expect(cells[0]).toMatchObject({ count: 1, engagement: 0 });
});
it("AST offset is a constant UTC-4 (Curaçao, no DST)", () => {
expect(AST_OFFSET_MS).toBe(-4 * 60 * 60 * 1000);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Heatmap aggregation for the /metrics view. Pure + timezone-explicit so it is
* unit-testable. Buckets published posts by AST day-of-week × hour. Curaçao
* observes no DST, so the AST offset is a constant UTC-4.
*/
export const AST_OFFSET_MS = -4 * 60 * 60 * 1000;
export interface MetricRow {
published_at: Date | null;
impressions: number | null;
reactions: number | null;
comments: number | null;
shares: number | null;
}
export interface HeatCell {
day: number; // 0=Sun … 6=Sat (AST)
hour: number; // 0…23 (AST)
count: number;
engagement: number; // reactions + comments + shares
}
export function buildHeatmap(rows: MetricRow[]): HeatCell[] {
const cells = new Map<string, HeatCell>();
for (const r of rows) {
if (!r.published_at) continue;
const ast = new Date(r.published_at.getTime() + AST_OFFSET_MS);
const day = ast.getUTCDay();
const hour = ast.getUTCHours();
const key = `${day}-${hour}`;
const engagement = (r.reactions ?? 0) + (r.comments ?? 0) + (r.shares ?? 0);
const cell = cells.get(key) ?? { day, hour, count: 0, engagement: 0 };
cell.count += 1;
cell.engagement += engagement;
cells.set(key, cell);
}
return [...cells.values()].sort((a, b) => a.day - b.day || a.hour - b.hour);
}
export const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"noEmit": true,
"incremental": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"plugins": [{ "name": "next" }],
"types": ["node", "react", "react-dom"],
"paths": { "@/*": ["./src/*"] },
"exactOptionalPropertyTypes": false
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next"]
}