From 1d1494a77c6bef355095db847ed0a90739469132 Mon Sep 17 00:00:00 2001 From: "Angelo B. J. Luidens" Date: Wed, 3 Jun 2026 15:30:16 -0400 Subject: [PATCH] Stage 5: admin dashboard (Next.js App Router) + completes Stage 2.3 handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/admin/next-env.d.ts | 6 + apps/admin/next.config.ts | 10 + apps/admin/package.json | 5 + apps/admin/postcss.config.mjs | 5 + apps/admin/src/app/api/approvals/route.ts | 12 + apps/admin/src/app/api/content/route.ts | 12 + apps/admin/src/app/api/feature-flags/route.ts | 12 + .../src/app/api/publications/cancel/route.ts | 12 + apps/admin/src/app/auth/linkedin/page.tsx | 90 +++++++ apps/admin/src/app/globals.css | 37 +++ apps/admin/src/app/layout.tsx | 64 +++++ apps/admin/src/app/metrics/page.tsx | 80 ++++++ apps/admin/src/app/page.tsx | 5 + apps/admin/src/app/publications/[id]/page.tsx | 116 +++++++++ apps/admin/src/app/queue/page.tsx | 78 ++++++ apps/admin/src/components/CancelButton.tsx | 50 ++++ apps/admin/src/components/FlagToggle.tsx | 65 +++++ apps/admin/src/components/StatusBadge.tsx | 27 ++ apps/admin/src/lib/api.ts | 47 ++++ apps/admin/src/lib/auth-core.test.ts | 40 +++ apps/admin/src/lib/auth-core.ts | 48 ++++ apps/admin/src/lib/auth.ts | 19 ++ apps/admin/src/lib/db.ts | 14 + apps/admin/src/lib/format.ts | 10 + .../src/lib/handlers.integration.test.ts | 141 ++++++++++ apps/admin/src/lib/handlers.ts | 243 ++++++++++++++++++ apps/admin/src/lib/heatmap.test.ts | 35 +++ apps/admin/src/lib/heatmap.ts | 40 +++ apps/admin/tsconfig.json | 21 ++ bun.lock | 99 ++++++- 30 files changed, 1440 insertions(+), 3 deletions(-) create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/admin/next.config.ts create mode 100644 apps/admin/postcss.config.mjs create mode 100644 apps/admin/src/app/api/approvals/route.ts create mode 100644 apps/admin/src/app/api/content/route.ts create mode 100644 apps/admin/src/app/api/feature-flags/route.ts create mode 100644 apps/admin/src/app/api/publications/cancel/route.ts create mode 100644 apps/admin/src/app/auth/linkedin/page.tsx create mode 100644 apps/admin/src/app/globals.css create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/metrics/page.tsx create mode 100644 apps/admin/src/app/page.tsx create mode 100644 apps/admin/src/app/publications/[id]/page.tsx create mode 100644 apps/admin/src/app/queue/page.tsx create mode 100644 apps/admin/src/components/CancelButton.tsx create mode 100644 apps/admin/src/components/FlagToggle.tsx create mode 100644 apps/admin/src/components/StatusBadge.tsx create mode 100644 apps/admin/src/lib/api.ts create mode 100644 apps/admin/src/lib/auth-core.test.ts create mode 100644 apps/admin/src/lib/auth-core.ts create mode 100644 apps/admin/src/lib/auth.ts create mode 100644 apps/admin/src/lib/db.ts create mode 100644 apps/admin/src/lib/format.ts create mode 100644 apps/admin/src/lib/handlers.integration.test.ts create mode 100644 apps/admin/src/lib/handlers.ts create mode 100644 apps/admin/src/lib/heatmap.test.ts create mode 100644 apps/admin/src/lib/heatmap.ts create mode 100644 apps/admin/tsconfig.json diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +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. diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts new file mode 100644 index 0000000..bf2f92e --- /dev/null +++ b/apps/admin/next.config.ts @@ -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; diff --git a/apps/admin/package.json b/apps/admin/package.json index 511a9c0..2f0c1be 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -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" } diff --git a/apps/admin/postcss.config.mjs b/apps/admin/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/apps/admin/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/admin/src/app/api/approvals/route.ts b/apps/admin/src/app/api/approvals/route.ts new file mode 100644 index 0000000..fbbe887 --- /dev/null +++ b/apps/admin/src/app/api/approvals/route.ts @@ -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), + ); +} diff --git a/apps/admin/src/app/api/content/route.ts b/apps/admin/src/app/api/content/route.ts new file mode 100644 index 0000000..929f2a5 --- /dev/null +++ b/apps/admin/src/app/api/content/route.ts @@ -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), + ); +} diff --git a/apps/admin/src/app/api/feature-flags/route.ts b/apps/admin/src/app/api/feature-flags/route.ts new file mode 100644 index 0000000..1af1e73 --- /dev/null +++ b/apps/admin/src/app/api/feature-flags/route.ts @@ -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), + ); +} diff --git a/apps/admin/src/app/api/publications/cancel/route.ts b/apps/admin/src/app/api/publications/cancel/route.ts new file mode 100644 index 0000000..b8c817e --- /dev/null +++ b/apps/admin/src/app/api/publications/cancel/route.ts @@ -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), + ); +} diff --git a/apps/admin/src/app/auth/linkedin/page.tsx b/apps/admin/src/app/auth/linkedin/page.tsx new file mode 100644 index 0000000..2307b65 --- /dev/null +++ b/apps/admin/src/app/auth/linkedin/page.tsx @@ -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 ( +

Not authenticated. Sign in via Authentik.

+ ); + } + 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 ( +
+
+

LinkedIn connection

+

+ OAuth status and per-outlet kill-switch. +

+
+ +
+

Authorization

+ {tokens.length === 0 ? ( +

+ Not connected. Community Management API access is in review; once granted, + authorize the app here to begin publishing. +

+ ) : ( +
    + {tokens.map((t) => ( +
  • + {t.subject_urn} + ({t.subject_type}) + + access expires {fmtAst(t.access_expires_at)} + + + refresh expires {fmtAst(t.refresh_expires_at)} + +
  • + ))} +
+ )} +
+ +
+

Kill-switch

+

+ Disable an outlet to halt dispatch within ~30s without a redeploy. Toggling is + audit-logged. +

+ {flags.length === 0 ? ( +

+ No outlet flags set yet (outlets default to enabled). +

+ ) : ( +
    + {flags.map((f) => ( +
  • + {f.outlet} + + {f.enabled ? "enabled" : `disabled — ${f.reason ?? "no reason"}`} + + {canToggle && } +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..79acdd1 --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -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); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..221f2c0 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -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 ( + + + + Skip to content + + +
+ +
+ +
+ {children} +
+ + + ); +} diff --git a/apps/admin/src/app/metrics/page.tsx b/apps/admin/src/app/metrics/page.tsx new file mode 100644 index 0000000..e6287ac --- /dev/null +++ b/apps/admin/src/app/metrics/page.tsx @@ -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 ( +

Not authenticated. Sign in via Authentik.

+ ); + } + 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 ( +
+

Engagement heatmap

+

+ Published posts by AST day × hour. Each cell shows the post count; background + intensity is total engagement (reactions + comments + shares). {rows.length} published. +

+ +
+ + + + + + ))} + + + + {DAY_LABELS.map((label, day) => ( + + + {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 ( + + ); + })} + + ))} + +
+ Posts and engagement by day of week and hour, Atlantic Standard Time +
+ {HOURS.map((h) => ( + + {h} +
+ {label} + + {c ? c.count : ""} +
+
+
+ ); +} diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx new file mode 100644 index 0000000..6f2a431 --- /dev/null +++ b/apps/admin/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/queue"); +} diff --git a/apps/admin/src/app/publications/[id]/page.tsx b/apps/admin/src/app/publications/[id]/page.tsx new file mode 100644 index 0000000..6744143 --- /dev/null +++ b/apps/admin/src/app/publications/[id]/page.tsx @@ -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 ( +

Not authenticated. Sign in via Authentik.

+ ); + } + 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 ( +
+
+

{pub.title}

+

{pub.slug}

+
+ +
+
+
Outlet
+
{pub.outlet}
+
+
+
Status
+
+ +
+
+
+
Scheduled
+
{fmtAst(pub.scheduled_at)}
+
+
+
Published
+
{fmtAst(pub.published_at)}
+
+
+
External
+
+ {pub.external_url ? ( + + {pub.external_id ?? "view"} ↗ + + ) : ( + + )} +
+
+
+
+ Idempotency key +
+
+ {pub.idempotency_key} +
+
+ {pub.error && ( +
+
Error
+
{pub.error}
+
+ )} +
+ +
+

Metrics

+ {metrics.length === 0 ? ( +

No metrics collected yet.

+ ) : ( + + + + + + + + + + + + + {metrics.map((m) => ( + + + + + + + + + ))} + +
CollectedImpr.React.CommentsSharesClicks
{fmtAst(m.collected_at)}{m.impressions}{m.reactions}{m.comments}{m.shares}{m.clicks}
+ )} +
+
+ ); +} diff --git a/apps/admin/src/app/queue/page.tsx b/apps/admin/src/app/queue/page.tsx new file mode 100644 index 0000000..7f3359b --- /dev/null +++ b/apps/admin/src/app/queue/page.tsx @@ -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 ( +

Not authenticated. Sign in via Authentik.

+ ); + } + const rows = await listQueue(getDb()); + const canCancel = principal.roles.includes("operator"); + + return ( +
+

Publish queue

+

+ Signed in as {principal.sub} · {principal.roles.join(", ")} +

+ + {rows.length === 0 ? ( +

Nothing queued.

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
TitleOutletStatusScheduledPublishedActions
+ + {r.title} + + {r.slug} + {r.outlet} + + {fmtAst(r.scheduled_at)}{fmtAst(r.published_at)} + {canCancel && CANCELLABLE.has(r.status) ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/admin/src/components/CancelButton.tsx b/apps/admin/src/components/CancelButton.tsx new file mode 100644 index 0000000..f9f3b3c --- /dev/null +++ b/apps/admin/src/components/CancelButton.tsx @@ -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(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 ( + + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/admin/src/components/FlagToggle.tsx b/apps/admin/src/components/FlagToggle.tsx new file mode 100644 index 0000000..37dd8f8 --- /dev/null +++ b/apps/admin/src/components/FlagToggle.tsx @@ -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(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 ( + + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/admin/src/components/StatusBadge.tsx b/apps/admin/src/components/StatusBadge.tsx new file mode 100644 index 0000000..da57c75 --- /dev/null +++ b/apps/admin/src/components/StatusBadge.tsx @@ -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 = { + 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 ( + + {status} + + ); +} diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts new file mode 100644 index 0000000..08e6478 --- /dev/null +++ b/apps/admin/src/lib/api.ts @@ -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, +): 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( + req: Request, + schema: S, + fn: (input: z.infer, principal: Principal, correlationId: string) => Promise, +): Promise { + 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 }); + } +} diff --git a/apps/admin/src/lib/auth-core.test.ts b/apps/admin/src/lib/auth-core.test.ts new file mode 100644 index 0000000..4db946e --- /dev/null +++ b/apps/admin/src/lib/auth-core.test.ts @@ -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(); + }); +}); diff --git a/apps/admin/src/lib/auth-core.ts b/apps/admin/src/lib/auth-core.ts new file mode 100644 index 0000000..0ad7589 --- /dev/null +++ b/apps/admin/src/lib/auth-core.ts @@ -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 = { + "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 }; +} diff --git a/apps/admin/src/lib/auth.ts b/apps/admin/src/lib/auth.ts new file mode 100644 index 0000000..f2fe355 --- /dev/null +++ b/apps/admin/src/lib/auth.ts @@ -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 { + 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"), + ); +} diff --git a/apps/admin/src/lib/db.ts b/apps/admin/src/lib/db.ts new file mode 100644 index 0000000..b3768a3 --- /dev/null +++ b/apps/admin/src/lib/db.ts @@ -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; +} diff --git a/apps/admin/src/lib/format.ts b/apps/admin/src/lib/format.ts new file mode 100644 index 0000000..ebda5e1 --- /dev/null +++ b/apps/admin/src/lib/format.ts @@ -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" + ); +} diff --git a/apps/admin/src/lib/handlers.integration.test.ts b/apps/admin/src/lib/handlers.integration.test.ts new file mode 100644 index 0000000..7751c4c --- /dev/null +++ b/apps/admin/src/lib/handlers.integration.test.ts @@ -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"); + }); +}); diff --git a/apps/admin/src/lib/handlers.ts b/apps/admin/src/lib/handlers.ts new file mode 100644 index 0000000..65de2f8 --- /dev/null +++ b/apps/admin/src/lib/handlers.ts @@ -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 | null, +): Promise { + 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); +} diff --git a/apps/admin/src/lib/heatmap.test.ts b/apps/admin/src/lib/heatmap.test.ts new file mode 100644 index 0000000..fddd30f --- /dev/null +++ b/apps/admin/src/lib/heatmap.test.ts @@ -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); + }); +}); diff --git a/apps/admin/src/lib/heatmap.ts b/apps/admin/src/lib/heatmap.ts new file mode 100644 index 0000000..b6eb109 --- /dev/null +++ b/apps/admin/src/lib/heatmap.ts @@ -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(); + 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; diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..8ab30f4 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -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"] +} diff --git a/bun.lock b/bun.lock index 393fdb4..735f1ab 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],