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"]
}
+96 -3
View File
@@ -19,17 +19,22 @@
"@stargue/observability": "workspace:*",
"@stargue/sanitize": "workspace:*",
"@stargue/schema": "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",
},
@@ -116,6 +121,8 @@
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@axe-core/playwright": ["@axe-core/playwright@4.11.2", "", { "dependencies": { "axe-core": "~4.11.3" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
@@ -234,8 +241,16 @@
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
@@ -344,6 +359,36 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
@@ -458,6 +503,8 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
@@ -484,6 +531,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="],
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
@@ -496,6 +545,32 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
@@ -598,7 +673,7 @@
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
@@ -634,6 +709,8 @@
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -670,6 +747,10 @@
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -740,14 +821,26 @@
"@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],