-
- Queue pressure
-
-
-
- Kind
- Topic
- Priority
-
-
-
- {demoTickets.map((ticket) => (
-
-
- {ticket.kind}
-
- {ticket.topic}
- {ticket.priority}
-
- ))}
-
-
-
-
-
- Organizer rhythm
-
-
-
-
-
-
-
Every 15 minutes
-
- Clear escalated mentor/tech tickets before they age into
- event-wide blockers.
-
-
-
Live
-
-
-
-
-
-
-
Before judging
-
- Export teams and verify every group has a demo queue status.
-
-
-
Ready
-
+
+
+
+
+
+ {session
+ ? `Connected as ${session.user.globalName ?? session.user.username}`
+ : "Discord is not connected"}
+
+
+ Only servers you own or can manage are shown. Permissions are
+ checked again before every change.
+
+
+
+
+
+
Servers you manage
+ {session?.guilds.length ?? 0}
-
-
+
+
PipHackLup installed
+
+ {botApiReady && !installationStatusError ? installedCount : "—"}
+
+
+
+
Bot management
+ {authReady && botApiReady ? "Available" : "Needs setup"}
+
+
+
+
+
);
}
diff --git a/apps/web/app/dev-fixtures/control-room/page.tsx b/apps/web/app/dev-fixtures/control-room/page.tsx
new file mode 100644
index 0000000..4b17411
--- /dev/null
+++ b/apps/web/app/dev-fixtures/control-room/page.tsx
@@ -0,0 +1,102 @@
+import { CheckCircle2 } from "lucide-react";
+import { notFound } from "next/navigation";
+import { AppShell } from "@/components/AppShell";
+import { PageHeader } from "@/components/PageHeader";
+import {
+ ServerManager,
+ type ManagedServerView,
+} from "@/components/ServerManager";
+import type { DiscordSession } from "@/lib/discord-auth";
+
+const session: DiscordSession = {
+ user: {
+ id: "1512918151313231983",
+ username: "eventorganizer",
+ globalName: "Event Organizer",
+ },
+ guilds: [
+ {
+ id: "1512918151313231984",
+ name: "North Star Hackathon",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+ },
+ {
+ id: "1512918151313231985",
+ name: "Weekend Builders",
+ isOwner: false,
+ permissions: "32",
+ canManage: true,
+ },
+ {
+ id: "1512918151313231986",
+ name: "Campus Demo Day",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+ },
+ ],
+ issuedAt: Date.now(),
+};
+
+const servers: ManagedServerView[] = session.guilds.map((guild, index) => ({
+ ...guild,
+ installed: index === 0 ? true : index === 1 ? false : null,
+ installUrl:
+ "https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands",
+}));
+
+export default function ControlRoomFixture() {
+ assertDevelopmentFixture();
+ return (
+
+
+
+
+
+
+ Connected as Event Organizer
+
+ Only servers you own or can manage are shown. Permissions are
+ checked again before every change.
+
+
+
+
+
+
Servers you manage
+ 3
+
+
+
PipHackLup installed
+ 1
+
+
+
Bot management
+ Available
+
+
+
+
+
+ );
+}
+
+function assertDevelopmentFixture(): void {
+ if (
+ process.env.NODE_ENV !== "development" ||
+ process.env.PIPHACKLUP_UI_TEST_MODE !== "1"
+ ) {
+ notFound();
+ }
+}
diff --git a/apps/web/app/dev-fixtures/training/page.tsx b/apps/web/app/dev-fixtures/training/page.tsx
new file mode 100644
index 0000000..430b5fa
--- /dev/null
+++ b/apps/web/app/dev-fixtures/training/page.tsx
@@ -0,0 +1,98 @@
+import { BookOpenCheck } from "lucide-react";
+import { notFound } from "next/navigation";
+import type {
+ HackathonKnowledgeEntry,
+ KnowledgeAssistantSettings,
+} from "@piphacklup/core";
+import { AppShell } from "@/components/AppShell";
+import { PageHeader } from "@/components/PageHeader";
+import { TrainingConsole } from "@/app/training/TrainingConsole";
+import type { DiscordSession, ManagedDiscordGuild } from "@/lib/discord-auth";
+
+const guild: ManagedDiscordGuild = {
+ id: "1512918151313231984",
+ name: "North Star Hackathon",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+};
+
+const session: DiscordSession = {
+ user: {
+ id: "1512918151313231983",
+ username: "eventorganizer",
+ globalName: "Event Organizer",
+ },
+ guilds: [guild],
+ issuedAt: Date.now(),
+};
+
+const entries: HackathonKnowledgeEntry[] = [
+ {
+ id: "know_fixture01",
+ guildId: guild.id,
+ title: "Where is participant check-in?",
+ answer: "Participant check-in is beside the main auditorium from 8:00 AM.",
+ tags: ["check-in", "registration"],
+ escalationTarget: "none",
+ createdBy: session.user.id,
+ createdAt: "2026-08-09T12:00:00.000Z",
+ updatedAt: "2026-08-09T12:00:00.000Z",
+ },
+];
+
+const settings: KnowledgeAssistantSettings = {
+ minConfidence: 45,
+ publicAnswers: true,
+};
+
+export default async function TrainingFixture({
+ searchParams,
+}: Readonly<{
+ searchParams: Promise<{ installation?: string }>;
+}>) {
+ assertDevelopmentFixture();
+ const { installation } = await searchParams;
+ const botInstallation = installation === "unknown" ? null : false;
+ return (
+
+
+
+
+
+ 1 saved answer
+
+ Changes apply only to North Star Hackathon. PipHackLup filters
+ instruction-override attempts before saving them.
+
+
+
+ {botInstallation === null
+ ? "Install status unavailable"
+ : "Bot not installed"}
+
+
+
+
+ );
+}
+
+function assertDevelopmentFixture(): void {
+ if (
+ process.env.NODE_ENV !== "development" ||
+ process.env.PIPHACKLUP_UI_TEST_MODE !== "1"
+ ) {
+ notFound();
+ }
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index a9fee06..0e64c6c 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -10,8 +10,8 @@
--button-text: #334155;
--button-hover-bg: #eef6fb;
--nav-text: #475569;
- --nav-bg: rgba(255, 255, 255, 0.72);
- --nav-border: rgba(47, 143, 216, 0.12);
+ --nav-bg: transparent;
+ --nav-border: transparent;
--nav-hover-bg: #eef6fb;
--nav-active-bg: #e0f2fe;
--nav-active-border: #8bd3ec;
@@ -49,6 +49,18 @@ html[data-dashboard-theme="dark"] .theme-label-light {
box-sizing: border-box;
}
+.visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
html,
body {
margin: 0;
@@ -89,8 +101,8 @@ textarea {
--button-text: #334155;
--button-hover-bg: #eef6fb;
--nav-text: #475569;
- --nav-bg: rgba(255, 255, 255, 0.72);
- --nav-border: rgba(47, 143, 216, 0.12);
+ --nav-bg: transparent;
+ --nav-border: transparent;
--nav-hover-bg: #eef6fb;
--nav-active-bg: #e0f2fe;
--nav-active-border: #8bd3ec;
@@ -120,10 +132,10 @@ html[data-dashboard-theme="dark"] .shell {
--button-text: #d9f7ff;
--button-hover-bg: #123651;
--nav-text: #b7d6e4;
- --nav-bg: rgba(13, 42, 66, 0.58);
- --nav-border: rgba(142, 231, 255, 0.11);
+ --nav-bg: transparent;
+ --nav-border: transparent;
--nav-hover-bg: #102f49;
- --nav-active-bg: linear-gradient(135deg, #153f5c, #0d2a42);
+ --nav-active-bg: #102f49;
--nav-active-border: #2f8fd8;
--nav-active-text: #f8f4df;
--field-bg: #071a2b;
@@ -135,6 +147,8 @@ html[data-dashboard-theme="dark"] .shell {
}
.sidebar {
+ display: flex;
+ flex-direction: column;
min-width: 0;
border-right: 1px solid var(--line);
background: var(--sidebar);
@@ -153,6 +167,7 @@ html[data-dashboard-theme="dark"] .shell {
display: flex;
align-items: center;
gap: 10px;
+ min-height: 44px;
font-weight: 800;
color: var(--ink);
}
@@ -162,11 +177,12 @@ html[data-dashboard-theme="dark"] .shell {
width: 36px;
height: 36px;
place-items: center;
- border: 1px solid #a8dcf0;
- border-radius: 8px;
- background: linear-gradient(135deg, #dff7ff, #2f8fd8 58%, #14213d);
+ border: 2px solid #8bd3ec;
+ border-radius: 7px;
+ background: #1e78b5;
color: white;
font-weight: 900;
+ box-shadow: 3px 3px 0 color-mix(in srgb, var(--ink) 22%, transparent);
}
.nav {
@@ -176,74 +192,77 @@ html[data-dashboard-theme="dark"] .shell {
margin-top: 26px;
}
-.nav a {
- position: relative;
+.nav a,
+.nav-more summary {
display: flex;
align-items: center;
gap: 10px;
- min-height: 42px;
+ min-height: 44px;
border: 1px solid var(--nav-border);
border-radius: 8px;
background: var(--nav-bg);
padding: 0 12px;
color: var(--nav-text);
- box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
- overflow: hidden;
- transform: translate3d(0, 0, 0);
+ box-shadow: none;
+ list-style: none;
+ cursor: pointer;
transition:
background 180ms ease,
border-color 180ms ease,
- box-shadow 180ms ease,
- color 180ms ease,
- transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
-}
-
-.nav a::before {
- position: absolute;
- inset: 0;
- content: "";
- background: radial-gradient(
- circle at 18% 20%,
- rgba(255, 255, 255, 0.46),
- transparent 34%
- );
- opacity: 0;
- transition: opacity 180ms ease;
+ color 180ms ease;
}
-.nav a svg,
-.nav a span {
- position: relative;
- z-index: 1;
+.nav-more summary::-webkit-details-marker {
+ display: none;
}
-.nav a:hover {
+.nav a:hover,
+.nav-more summary:hover {
background: var(--nav-hover-bg);
color: var(--ink);
- box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
- transform: translateY(-2px) scale(1.012);
-}
-
-.nav a:hover::before,
-.nav a.active::before {
- opacity: 1;
}
-.nav a:active {
- transform: translateY(0) scale(0.985);
+.nav a:active,
+.nav-more summary:active {
+ background: var(--soft-panel);
}
.nav a.active {
border-color: var(--nav-active-border);
background: var(--nav-active-bg);
color: var(--nav-active-text);
- box-shadow:
- 0 14px 28px rgba(47, 143, 216, 0.2),
- inset 0 1px 0 rgba(255, 255, 255, 0.24);
+ box-shadow: inset 3px 0 0 var(--blue);
}
.nav a.active svg {
- filter: drop-shadow(0 0 8px rgba(87, 199, 212, 0.45));
+ color: var(--blue);
+}
+
+.nav-more {
+ position: relative;
+}
+
+.nav-more-menu {
+ position: absolute;
+ z-index: 30;
+ right: 0;
+ bottom: calc(100% + 6px);
+ display: grid;
+ gap: 5px;
+ width: 210px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 7px;
+ box-shadow: 0 16px 44px rgba(2, 6, 23, 0.2);
+}
+
+.nav-more:not([open]) .nav-more-menu {
+ display: none;
+}
+
+.mobile-nav-only {
+ display: none !important;
}
.main {
@@ -253,7 +272,7 @@ html[data-dashboard-theme="dark"] .shell {
.page-surface {
min-width: 0;
- animation: page-settle 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ animation: page-settle 160ms ease-out;
transform-origin: 50% 18px;
}
@@ -299,7 +318,7 @@ html[data-dashboard-theme="dark"] .shell {
justify-content: center;
gap: 8px;
min-width: 0;
- min-height: 38px;
+ min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--button-bg);
@@ -320,12 +339,46 @@ html[data-dashboard-theme="dark"] .shell {
.button:hover {
background: var(--button-hover-bg);
color: var(--ink);
- box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
- transform: translateY(-1px);
+ box-shadow: none;
}
.button:active {
- transform: translateY(0) scale(0.98);
+ transform: translateY(1px);
+}
+
+.button:disabled {
+ opacity: 0.56;
+ cursor: not-allowed;
+ box-shadow: none;
+ transform: none;
+}
+
+.button.danger {
+ border-color: #be123c;
+ background: #be123c;
+ color: white;
+}
+
+.button.danger:hover {
+ border-color: #e11d48;
+ background: #e11d48;
+ color: white;
+}
+
+.button.danger-ghost {
+ border-color: color-mix(in srgb, var(--rose) 38%, var(--line));
+ color: var(--rose);
+}
+
+.button.danger-ghost:hover {
+ border-color: var(--rose);
+ background: color-mix(in srgb, var(--rose) 10%, var(--panel));
+ color: var(--rose);
+}
+
+:where(a, button, input, select, textarea):focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--blue) 70%, white);
+ outline-offset: 3px;
}
.button.primary {
@@ -335,52 +388,44 @@ html[data-dashboard-theme="dark"] .shell {
}
.button.primary:hover {
- border-color: #2f8fd8;
- background: #2f8fd8;
+ border-color: #176a9f;
+ background: #176a9f;
color: white;
}
+.shell[data-theme="dark"] .button.danger-ghost,
+html[data-dashboard-theme="dark"] .button.danger-ghost {
+ color: #ff9db2;
+}
+
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
- min-height: 38px;
+ min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
- background:
- radial-gradient(
- circle at 18% 20%,
- rgba(255, 255, 255, 0.3),
- transparent 34%
- ),
- var(--button-bg);
+ background: var(--button-bg);
color: var(--button-text);
- box-shadow:
- 0 10px 22px rgba(47, 143, 216, 0.12),
- inset 0 1px 0 rgba(255, 255, 255, 0.18);
+ box-shadow: none;
font-weight: 800;
cursor: pointer;
transition:
background 180ms ease,
border-color 180ms ease,
- box-shadow 180ms ease,
- color 180ms ease,
- transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ color 180ms ease;
}
.theme-toggle:hover {
background: var(--button-hover-bg);
color: var(--ink);
- box-shadow:
- 0 14px 26px rgba(47, 143, 216, 0.18),
- inset 0 1px 0 rgba(255, 255, 255, 0.24);
- transform: translateY(-1px) scale(1.01);
+ box-shadow: none;
}
.theme-toggle:active {
- transform: translateY(0) scale(0.985);
+ transform: translateY(1px);
}
.grid {
@@ -434,6 +479,7 @@ html[data-dashboard-theme="dark"] .shell {
.table {
width: 100%;
+ min-width: 680px;
border-collapse: collapse;
table-layout: fixed;
font-size: 14px;
@@ -547,6 +593,139 @@ html[data-dashboard-theme="dark"] .shell {
margin-top: 16px;
}
+.training-context {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ border-block: 1px solid var(--line);
+ padding: 14px 0;
+}
+
+.training-context > svg {
+ color: var(--green);
+}
+
+.training-context > div {
+ display: grid;
+ gap: 3px;
+}
+
+.training-context strong {
+ color: var(--ink);
+}
+
+.training-context span:not(.badge) {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.training-notice {
+ grid-column: 1 / -1;
+ border-left: 3px solid var(--blue);
+ border-radius: 4px;
+ background: var(--status-bg);
+ padding: 10px 12px;
+ color: var(--foreground);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.training-notice.error {
+ border-color: var(--rose);
+ background: color-mix(in srgb, var(--rose) 9%, var(--panel));
+}
+
+.training-help,
+.field-help {
+ margin: -5px 0 2px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.field-help {
+ margin: 0;
+ border-left: 2px solid var(--amber);
+ padding-left: 9px;
+}
+
+.training-library {
+ display: grid;
+ gap: 10px;
+}
+
+.training-library article {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ align-items: start;
+ border-top: 1px solid var(--line);
+ padding-top: 13px;
+}
+
+.training-library article:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.training-library h3,
+.training-library p {
+ margin: 0;
+}
+
+.training-library h3 {
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.training-library p {
+ margin-top: 4px;
+ color: var(--answer-text);
+ line-height: 1.5;
+}
+
+.training-delete-confirm {
+ display: grid;
+ gap: 9px;
+ width: min(320px, 100%);
+ border: 1px solid color-mix(in srgb, var(--rose) 36%, var(--line));
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--rose) 7%, var(--panel));
+ padding: 11px;
+}
+
+.training-delete-confirm > p {
+ margin: 0;
+ color: var(--foreground);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.training-delete-confirm > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.training-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 9px;
+}
+
+.training-tags span {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--soft-panel);
+ padding: 3px 8px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
.training-panel {
display: grid;
align-content: start;
@@ -571,6 +750,7 @@ html[data-dashboard-theme="dark"] .shell {
.field select,
.field textarea {
width: 100%;
+ min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--field-bg);
@@ -643,9 +823,11 @@ html[data-dashboard-theme="dark"] .shell {
}
.toggle-row {
+ min-height: 44px;
color: var(--button-text);
font-size: 14px;
font-weight: 700;
+ cursor: pointer;
}
.toggle-row input {
@@ -667,109 +849,1082 @@ html[data-dashboard-theme="dark"] .shell {
line-height: 1.5;
}
-.button.full {
- width: 100%;
+.skip-link {
+ position: fixed;
+ z-index: 100;
+ top: 10px;
+ left: 10px;
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ border-radius: 8px;
+ background: var(--ink);
+ padding: 10px 14px;
+ color: var(--panel);
+ font-weight: 800;
+ transform: translateY(-160%);
}
-@media (max-width: 900px) {
- .shell {
- grid-template-columns: 1fr;
- }
-
- .sidebar {
- position: sticky;
- top: 0;
- z-index: 10;
- width: 100%;
- overflow: hidden;
- border-right: 0;
- border-bottom: 1px solid var(--line);
- }
-
- .sidebar-head {
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- }
-
- .theme-toggle {
- width: auto;
- padding-inline: 12px;
- }
-
- .nav {
- grid-auto-flow: column;
- grid-auto-columns: max-content;
- width: 100%;
- max-width: 100%;
- overflow-x: auto;
- margin-top: 14px;
- padding-bottom: 2px;
- }
-
- .nav a {
- min-height: 38px;
- white-space: nowrap;
- }
+.skip-link:focus {
+ transform: translateY(0);
+}
- .topbar {
- display: grid;
- }
+.server-switcher {
+ display: grid;
+ gap: 7px;
+ margin-top: 18px;
+}
- .grid.metrics,
- .grid.two,
- .training-grid,
- .status-cards,
- .form-grid {
- grid-template-columns: 1fr;
- }
+.server-switcher > span {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
- .training-panel.wide {
- grid-column: auto;
- }
+.server-switcher select {
+ width: 100%;
+ min-height: 44px;
+ border: 1px solid var(--nav-active-border);
+ border-radius: 8px;
+ background: var(--field-bg);
+ padding: 0 34px 0 11px;
+ color: var(--ink);
+ font-weight: 800;
}
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 1ms !important;
- scroll-behavior: auto !important;
- transition-duration: 1ms !important;
- }
+.account-dock {
+ display: grid;
+ gap: 10px;
+ margin-top: auto;
+ padding-top: 20px;
}
-@keyframes page-settle {
- from {
- opacity: 0;
- transform: translateY(8px) scale(0.992);
- }
+.account-identity {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr);
+ gap: 10px;
+ align-items: center;
+ border-top: 1px solid var(--line);
+ padding-top: 16px;
+}
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
+.account-identity.signed-out {
+ color: var(--muted);
}
-.site {
- min-height: 100vh;
- background: #061423;
- color: white;
+.account-identity > span:last-child {
+ display: grid;
+ min-width: 0;
}
-.hero {
- position: relative;
- min-height: 88vh;
+.account-identity strong,
+.account-identity small {
overflow: hidden;
- background: #061423;
- color: white;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.hero-bg {
- position: absolute;
- inset: 0;
- background:
- linear-gradient(90deg, rgba(6, 20, 35, 0.92), rgba(6, 20, 35, 0.28)),
- url("/piphacklup-site-hero.png") center / cover no-repeat;
+.account-identity strong {
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.account-identity small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.account-action,
+.account-dock form {
+ width: 100%;
+}
+
+.auth-gate {
+ display: grid;
+ justify-items: start;
+ align-content: center;
+ max-width: 720px;
+ min-height: calc(100vh - 48px);
+ margin: 0 auto;
+ padding: 48px 24px;
+}
+
+.auth-gate-mark,
+.danger-mark {
+ display: grid;
+ width: 54px;
+ height: 54px;
+ place-items: center;
+ border: 1px solid var(--nav-active-border);
+ border-radius: 12px;
+ background: var(--nav-active-bg);
+ color: var(--nav-active-text);
+}
+
+.auth-gate h1 {
+ max-width: 620px;
+ margin: 4px 0 12px;
+ color: var(--ink);
+ font-size: clamp(32px, 5vw, 52px);
+ line-height: 1.05;
+}
+
+.auth-gate > p:not(.eyebrow, .auth-alert) {
+ max-width: 620px;
+ margin: 0 0 22px;
+ color: var(--muted);
+ font-size: 17px;
+ line-height: 1.6;
+}
+
+.auth-gate > small {
+ max-width: 560px;
+ margin-top: 16px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.auth-alert,
+.dependency-alert {
+ width: 100%;
+ max-width: 620px;
+ border: 1px solid color-mix(in srgb, var(--amber) 46%, var(--line));
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--amber) 9%, var(--panel));
+ padding: 12px 14px;
+ color: var(--foreground);
+}
+
+.dependency-alert {
+ display: grid;
+ gap: 4px;
+}
+
+.dependency-alert span {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.mini-status {
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ line-height: 1.45;
+}
+
+.mini-status svg {
+ flex: 0 0 auto;
+ color: var(--blue);
+}
+
+.server-manager-section {
+ margin-top: 24px;
+}
+
+.server-manager-filters {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(180px, 260px);
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.server-search,
+.server-filter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 44px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--field-bg);
+ padding: 0 11px;
+ color: var(--muted);
+}
+
+.server-search input,
+.server-filter select {
+ width: 100%;
+ min-width: 0;
+ min-height: 42px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+}
+
+.server-search:focus-within,
+.server-filter:focus-within {
+ border-color: var(--blue);
+ outline: 3px solid color-mix(in srgb, var(--blue) 70%, white);
+ outline-offset: 3px;
+}
+
+.server-filter > span {
+ flex: 0 0 auto;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.filtered-empty {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ margin-top: 14px;
+ padding: 16px;
+ color: var(--muted);
+}
+
+.workspace-state,
+.workspace-restoring,
+.friendly-empty,
+.setup-command-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 22px;
+ box-shadow: var(--shadow-soft);
+}
+
+.workspace-state > svg,
+.workspace-restoring > svg,
+.friendly-empty > svg,
+.setup-command-card > svg {
+ flex: 0 0 auto;
+ color: var(--blue);
+}
+
+.workspace-state > div,
+.workspace-restoring > div,
+.friendly-empty > div,
+.setup-command-card > div {
+ flex: 1;
+ min-width: 0;
+}
+
+.workspace-state h2,
+.workspace-state p,
+.workspace-restoring h1,
+.workspace-restoring p,
+.friendly-empty h2,
+.friendly-empty p,
+.setup-command-card h2,
+.setup-command-card p {
+ margin: 0;
+}
+
+.workspace-state h2,
+.workspace-restoring h1,
+.friendly-empty h2,
+.setup-command-card h2 {
+ color: var(--ink);
+ font-size: 19px;
+}
+
+.workspace-state p,
+.workspace-restoring p,
+.friendly-empty p,
+.setup-command-card p {
+ margin-top: 5px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.workspace-restoring {
+ max-width: 680px;
+}
+
+.setup-command-card {
+ border-color: var(--nav-active-border);
+ background: var(--status-bg);
+}
+
+.setup-command-card .eyebrow {
+ margin-bottom: 2px;
+}
+
+.setup-command-card h2 {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 24px;
+}
+
+.setup-checklist,
+.setup-details {
+ margin-top: 16px;
+}
+
+.section-heading,
+.record-card-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.section-heading h2,
+.section-heading p,
+.record-card-heading h2 {
+ margin: 0;
+}
+
+.section-heading p {
+ margin-top: 3px;
+}
+
+.detail-list,
+.record-meta {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+ margin: 0;
+}
+
+.detail-list > div,
+.record-meta > div {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.detail-list dt,
+.record-meta dt,
+.compact-records dt {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.detail-list dd,
+.record-meta dd,
+.compact-records dd {
+ margin: 0;
+ color: var(--ink);
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+
+.record-list {
+ display: grid;
+ gap: 12px;
+}
+
+.record-card {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 17px;
+ box-shadow: var(--shadow-soft);
+}
+
+.record-card-heading h2 {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 18px;
+}
+
+.record-card > p {
+ margin: 0 0 16px;
+ color: var(--foreground);
+ line-height: 1.55;
+}
+
+.record-id {
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 11px;
+}
+
+.empty-hint {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.compact-records {
+ display: grid;
+ gap: 10px;
+}
+
+.compact-records article {
+ display: grid;
+ gap: 12px;
+ border-top: 1px solid var(--line);
+ padding-top: 12px;
+}
+
+.compact-records article:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.compact-records h3,
+.compact-records p,
+.compact-records dl,
+.compact-records dt,
+.compact-records dd {
+ margin: 0;
+}
+
+.compact-records h3 {
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.compact-records p {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.compact-records dl {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.compact-records dl > div {
+ display: grid;
+ gap: 3px;
+}
+
+.inline-empty {
+ margin: 0;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ padding: 14px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.inline-empty.with-icon {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+
+.inline-empty.with-icon svg {
+ flex: 0 0 auto;
+ color: var(--green);
+}
+
+.inline-empty.with-icon p {
+ margin: 0;
+}
+
+.workspace-summary {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.25fr) minmax(360px, 1fr);
+ gap: 22px;
+ align-items: center;
+ border-block: 1px solid var(--line);
+ padding: 18px 0;
+}
+
+.workspace-summary-intro {
+ display: flex;
+ gap: 11px;
+ align-items: flex-start;
+}
+
+.workspace-summary-intro > svg {
+ flex: 0 0 auto;
+ margin-top: 1px;
+ color: var(--green);
+}
+
+.workspace-summary-intro > div {
+ display: grid;
+ gap: 4px;
+}
+
+.workspace-summary-intro strong {
+ color: var(--ink);
+}
+
+.workspace-summary-intro span,
+.workspace-summary dt {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.workspace-summary dl {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ margin: 0;
+}
+
+.workspace-summary dl > div {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+
+.workspace-summary dt,
+.workspace-summary dd {
+ margin: 0;
+}
+
+.workspace-summary dd {
+ color: var(--ink);
+ font-size: 17px;
+ font-weight: 850;
+ overflow-wrap: anywhere;
+}
+
+.server-manager-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 12px;
+}
+
+.server-manager-toolbar h2,
+.server-manager-toolbar p {
+ margin: 0;
+}
+
+.server-manager-toolbar h2 {
+ color: var(--ink);
+ font-size: 20px;
+}
+
+.server-manager-toolbar p {
+ margin-top: 4px;
+}
+
+.sr-status {
+ border-left: 3px solid var(--blue);
+ margin: 0 0 14px;
+ background: var(--status-bg);
+ padding: 9px 12px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.server-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.server-card {
+ display: grid;
+ gap: 16px;
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 17px;
+ box-shadow: var(--shadow-soft);
+}
+
+.server-card-head {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.server-card-head h3,
+.server-card-head p {
+ margin: 0;
+}
+
+.server-card-head h3 {
+ color: var(--ink);
+ font-size: 16px;
+ overflow-wrap: anywhere;
+}
+
+.server-card-head p {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.server-avatar {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ place-items: center;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--soft-panel);
+ color: var(--blue);
+ object-fit: cover;
+ font-weight: 900;
+}
+
+.server-permission-note {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.server-permission-note svg {
+ color: var(--green);
+}
+
+.server-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.server-actions .button {
+ flex: 1 1 150px;
+}
+
+.server-action-note {
+ flex: 1 0 100%;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+}
+
+.badge.gray {
+ background: var(--soft-panel);
+ color: var(--nav-text);
+}
+
+.empty-state {
+ display: grid;
+ justify-items: start;
+ gap: 8px;
+ max-width: 720px;
+ padding: 28px;
+}
+
+.empty-state h2,
+.empty-state p {
+ margin: 0;
+}
+
+.empty-state p {
+ max-width: 620px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.empty-state .button-row {
+ margin-top: 8px;
+}
+
+.confirm-dialog {
+ width: min(520px, calc(100% - 32px));
+ max-height: calc(100dvh - 32px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel);
+ padding: 0;
+ color: var(--foreground);
+ box-shadow: 0 24px 80px rgba(2, 6, 23, 0.38);
+}
+
+.confirm-dialog::backdrop {
+ background: rgba(2, 6, 23, 0.62);
+ backdrop-filter: blur(2px);
+}
+
+.confirm-dialog-body {
+ position: relative;
+ display: grid;
+ gap: 15px;
+ padding: 24px;
+}
+
+.confirm-dialog-body h2,
+.confirm-dialog-body p {
+ margin: 0;
+}
+
+.confirm-dialog-body h2 {
+ padding-right: 36px;
+ color: var(--ink);
+ font-size: 22px;
+}
+
+.confirm-dialog-body p {
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.confirm-dialog-body .dialog-error {
+ border-left: 3px solid var(--rose);
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--rose) 9%, var(--panel));
+ padding: 10px 12px;
+ color: var(--foreground);
+ font-weight: 700;
+}
+
+.danger-mark {
+ border-color: color-mix(in srgb, var(--rose) 35%, var(--line));
+ background: color-mix(in srgb, var(--rose) 10%, var(--panel));
+ color: var(--rose);
+}
+
+.dialog-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+}
+
+.dialog-close:hover {
+ background: var(--soft-panel);
+ color: var(--ink);
+}
+
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.button.full {
+ width: 100%;
+}
+
+@media (max-width: 900px) {
+ .shell {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ width: 100%;
+ overflow: visible;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .sidebar-head {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ }
+
+ .theme-toggle {
+ width: auto;
+ padding-inline: 12px;
+ }
+
+ .nav {
+ display: flex;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ width: 100%;
+ max-width: 100%;
+ margin-top: 14px;
+ padding-bottom: 2px;
+ }
+
+ .nav a {
+ flex: 0 0 auto;
+ justify-content: center;
+ min-height: 44px;
+ padding: 0 8px;
+ font-size: 12px;
+ }
+
+ .nav-more {
+ flex: 0 0 auto;
+ }
+
+ .nav-more summary {
+ min-height: 44px;
+ padding: 0 10px;
+ font-size: 12px;
+ }
+
+ .nav-more-menu {
+ position: fixed;
+ top: 138px;
+ right: 12px;
+ bottom: auto;
+ }
+
+ .server-switcher {
+ max-width: 480px;
+ }
+
+ .account-dock {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 12px;
+ }
+
+ .account-identity {
+ flex: 1;
+ border-top: 0;
+ padding-top: 0;
+ }
+
+ .account-dock form,
+ .account-action {
+ width: auto;
+ }
+
+ .main {
+ padding: 18px 16px;
+ }
+
+ .topbar {
+ display: grid;
+ }
+
+ .grid.metrics,
+ .grid.two,
+ .training-grid,
+ .status-cards,
+ .form-grid,
+ .workspace-summary,
+ .server-card-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .training-context {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .training-context > .badge {
+ grid-column: 2;
+ justify-self: start;
+ }
+
+ .server-manager-filters {
+ grid-template-columns: 1fr;
+ }
+
+ .detail-list,
+ .record-meta {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .server-manager-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .training-panel.wide {
+ grid-column: auto;
+ }
+}
+
+@media (max-width: 560px) {
+ .sidebar {
+ padding: 14px 12px;
+ }
+
+ .nav {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ overflow: visible;
+ }
+
+ .nav a,
+ .nav-more summary {
+ flex-direction: column;
+ gap: 3px;
+ width: 100%;
+ padding: 5px 2px;
+ font-size: 11px;
+ line-height: 1.1;
+ }
+
+ .nav > .mobile-overflow {
+ display: none;
+ }
+
+ .nav-more-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ bottom: auto;
+ width: min(240px, calc(100vw - 24px));
+ }
+
+ .nav-more-menu a,
+ .nav-more-menu a.mobile-nav-only {
+ display: flex !important;
+ flex-direction: row;
+ justify-content: flex-start;
+ gap: 10px;
+ padding: 0 12px;
+ font-size: 13px;
+ }
+
+ .account-identity small {
+ display: none;
+ }
+
+ .topbar .button-row,
+ .topbar .button-row > *,
+ .topbar .button-row .button {
+ width: 100%;
+ }
+
+ .server-card-head {
+ grid-template-columns: 44px minmax(0, 1fr);
+ }
+
+ .workspace-summary dl {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+
+ .server-card-head .badge {
+ grid-column: 1 / -1;
+ justify-self: start;
+ }
+
+ .dialog-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
+
+ .field input,
+ .field select,
+ .field textarea,
+ .server-search input,
+ .server-filter select {
+ font-size: 16px;
+ }
+
+ .filtered-empty {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .workspace-state,
+ .workspace-restoring,
+ .friendly-empty,
+ .setup-command-card {
+ display: grid;
+ }
+
+ .training-library article {
+ grid-template-columns: 1fr;
+ }
+
+ .training-library .button {
+ width: 100%;
+ }
+
+ .workspace-state > .button {
+ width: 100%;
+ }
+
+ .detail-list,
+ .record-meta,
+ .compact-records dl {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 900px) and (max-height: 600px) {
+ .sidebar {
+ position: static;
+ }
+
+ .confirm-dialog-body {
+ gap: 10px;
+ padding: 16px;
+ }
+
+ .confirm-dialog .danger-mark {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 1ms !important;
+ scroll-behavior: auto !important;
+ transition-duration: 1ms !important;
+ }
+}
+
+@keyframes page-settle {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.992);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.site {
+ min-height: 100vh;
+ background: #061423;
+ color: white;
+}
+
+.site .eyebrow {
+ color: #9eeaff;
+}
+
+.hero {
+ position: relative;
+ min-height: 88vh;
+ overflow: hidden;
+ background: #061423;
+ color: white;
+}
+
+.hero-bg {
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(90deg, rgba(6, 20, 35, 0.92), rgba(6, 20, 35, 0.28)),
+ url("/piphacklup-site-hero.png") center / cover no-repeat;
}
.hero-bg::after {
@@ -874,6 +2029,7 @@ html[data-dashboard-theme="dark"] .shell {
margin: 0 auto;
padding: 54px 0 60px;
color: white;
+ scroll-margin-top: 24px;
}
.ops-intro {
@@ -1019,9 +2175,42 @@ html[data-dashboard-theme="dark"] .shell {
max-width: 720px;
}
+.site-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ border-top: 1px solid rgba(176, 230, 255, 0.18);
+ width: min(1120px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 22px 0 28px;
+ color: #8fb6c8;
+ font-size: 13px;
+}
+
+.site-footer nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.site-footer a {
+ display: inline-flex;
+ align-items: center;
+ min-height: 44px;
+ color: #d9f7ff;
+ font-weight: 700;
+}
+
+.site-footer a:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
@media (max-width: 900px) {
.site-nav,
- .public-band {
+ .public-band,
+ .site-footer {
align-items: flex-start;
flex-direction: column;
}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 2059ae4..014b338 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
"Discord moderation bot",
"Discord onboarding bot",
"hackathon organizer dashboard",
- "hackathon AI assistant",
+ "hackathon Q&A assistant",
"hackathon FAQ bot",
],
authors: [{ name: "Rupayon Haldar", url: "https://github.com/rupayon123" }],
@@ -77,16 +77,14 @@ export default function RootLayout({
diff --git a/apps/web/app/moderation/page.tsx b/apps/web/app/moderation/page.tsx
index 23e8ee4..b89c950 100644
--- a/apps/web/app/moderation/page.tsx
+++ b/apps/web/app/moderation/page.tsx
@@ -1,62 +1,135 @@
-import { defaultAutoModTemplates } from "@piphacklup/core";
+import { ShieldCheck } from "lucide-react";
+import { defaultAutoModTemplates, type ModerationCase } from "@piphacklup/core";
+import { listModerationCasesFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { demoCases } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
+
+interface ModerationPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function ModerationPage({
+ searchParams,
+}: ModerationPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let cases: ModerationCase[] = [];
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ cases = await listModerationCasesFromDb(workspace.guild.id);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load moderation cases.");
+ }
+ }
+
+ const openCases = cases.filter(
+ (moderationCase) => moderationCase.status === "open",
+ );
-export default function ModerationPage() {
return (
-
+
-
-
- Open cases
-
-
-
- Case
- Action
- Reason
- Status
-
-
-
- {demoCases.map((moderationCase) => (
-
-
- {moderationCase.id}
-
-
- {moderationCase.action}
-
- {moderationCase.reason}
- {moderationCase.status}
-
- ))}
-
-
-
-
-
- AutoMod templates
-
- {defaultAutoModTemplates().map((rule) => (
-
-
A
-
-
{rule.name}
-
{rule.goal}
-
-
{rule.trigger}
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : (
+
+
+
+
+
Open cases
+
{openCases.length} need staff review
- ))}
-
-
-
+
+ {openCases.length ? "Review needed" : "Clear"}
+
+
+ {openCases.length ? (
+
+ {openCases.map((moderationCase) => (
+
+
+
{moderationCase.id}
+
{moderationCase.action}
+
{moderationCase.reason}
+
+
+
+
Discord member
+ {moderationCase.targetUserId}
+
+
+
Opened
+ {formatDate(moderationCase.createdAt)}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No open cases. Reports created with{" "}
+ /mod report
+ will appear here for staff.
+
+
+ )}
+
+
+
+
+
+
Recommended Discord protections
+
+ Review these templates before applying them in Discord.
+
+
+
+
+ {defaultAutoModTemplates().map((rule) => (
+
+
A
+
+
{rule.name}
+
{rule.goal}
+
+
Template
+
+ ))}
+
+
+
+ )}
);
}
+
+function formatDate(value: string): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ dateStyle: "medium",
+ timeZone: "America/Toronto",
+ }).format(new Date(value));
+}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 6a327a1..669093a 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -4,7 +4,7 @@ import {
ClipboardCheck,
Clock3,
Github,
- LayoutDashboard,
+ LogIn,
MessageCircleQuestion,
Radio,
Shield,
@@ -14,8 +14,7 @@ import {
} from "lucide-react";
export default function HomePage() {
- const installUrl =
- "https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1117094267958";
+ const signInUrl = "/api/auth/discord/start";
const structuredData = {
"@context": "https://schema.org",
@@ -62,15 +61,15 @@ export default function HomePage() {
icon: Users,
label: "Team table",
title: "Solo builders find the right group faster.",
- copy: "Profiles, recruiting teams, join requests, and matching keep team formation from turning into a chaotic introductions channel.",
+ copy: "Profiles, recruiting teams, team listings, and matching keep team formation from turning into a chaotic introductions channel.",
},
];
const operatorSteps = [
- "Create the Discord app under your account.",
- "Invite PipHackLup with the required bot permissions.",
- "Run setup, choose roles and channels, and enable onboarding.",
- "Train event details before doors open.",
+ "Sign in with the Discord account you already use.",
+ "Choose a server you own or have permission to manage.",
+ "Add PipHackLup to that server with the guided install.",
+ "Run setup, add event details, and try a participant check-in.",
];
return (
@@ -84,20 +83,15 @@ export default function HomePage() {
- P
+
+ P
+
PipHackLup
@@ -111,40 +105,43 @@ export default function HomePage() {
repeat traffic and calls staff when a human needs the wheel.
-
100-500
- person events
+ 4
+ help lines
-
4
- queue types
+ 1
+ guided setup
-
0
- secret tokens in repo
+ Human
+ fallback built in
-
+
Event-day dispatch board
-
Built for the moments organizers dread
+
Built for the busiest parts of event day
- PipHackLup is not a shiny dashboard costume. It is a Discord
- operator that helps staff absorb the first wave of confusion,
- route the important stuff, and keep receipts.
+ PipHackLup gives staff a calm place to handle the first wave of
+ questions, route the important requests, and see what still needs
+ a person.
@@ -221,8 +218,8 @@ export default function HomePage() {
+
+
>
);
diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx
index 1911626..5037702 100644
--- a/apps/web/app/privacy/page.tsx
+++ b/apps/web/app/privacy/page.tsx
@@ -3,7 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
export default function PrivacyPage() {
return (
-
+
Data We Process
- PipHackLup may process Discord server IDs, user IDs, display names, roles, team profiles, queue tickets, moderation
- case details, audit events, and organizer settings needed to run hackathon workflows.
+ PipHackLup may process Discord server IDs, user IDs, display names,
+ roles, team profiles, queue tickets, moderation case details, audit
+ events, and organizer settings needed to run hackathon workflows.
How Data Is Used
- Data is used to provide onboarding, team formation, help queues, moderation workflows, dashboards, exports, and
- bot diagnostics. PipHackLup does not sell personal data.
+ Data is used to provide onboarding, team formation, help queues,
+ moderation workflows, dashboards, exports, and bot diagnostics.
+ PipHackLup does not sell personal data.
Message Content
- PipHackLup is designed to work without Discord's Message Content intent for the first public release. Reports and
- moderation cases may include details submitted by users or staff, such as message links or written reasons.
+ PipHackLup is designed to work without Discord's Message Content
+ intent for the first public release. Reports and moderation cases may
+ include details submitted by users or staff, such as message links or
+ written reasons. A question that needs human follow-up may be shared
+ with authorized event staff in a staff-only channel. Choosing a
+ private reply keeps the response out of the participant channel; it
+ does not hide an escalated question from the staff handling it.
Retention and Removal
- Event data is retained until an organizer exports, deletes, or requests removal. To request data removal or report
- a privacy concern, open an issue at
- {" "}
- github.com/rupayon123/PipHackLup .
+ Removing PipHackLup from a Discord server does not delete that
+ server's saved event data; the data is retained so an organizer
+ can reinstall the bot later. To request deletion or report a privacy
+ concern, use the repository's{" "}
+
+ private reporting page
+
+ . Do not post Discord user or server IDs, moderation-case details, or
+ other personal data in a public GitHub issue.
diff --git a/apps/web/app/queues/page.tsx b/apps/web/app/queues/page.tsx
index bc19f29..41e4e56 100644
--- a/apps/web/app/queues/page.tsx
+++ b/apps/web/app/queues/page.tsx
@@ -1,49 +1,117 @@
+import { Clock3, TicketCheck } from "lucide-react";
+import type { QueueTicket } from "@piphacklup/core";
+import { listQueueTicketsFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { demoTickets } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
+
+interface QueuesPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function QueuesPage({ searchParams }: QueuesPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let tickets: QueueTicket[] = [];
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ tickets = await listQueueTicketsFromDb(workspace.guild.id);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load queue tickets.");
+ }
+ }
-export default function QueuesPage() {
return (
-
+
-
-
-
-
- Ticket
- Kind
- Topic
- Status
- Priority
- Created
-
-
-
- {demoTickets.map((ticket) => (
-
-
- {ticket.id}
-
-
- {ticket.kind}
-
-
- {ticket.topic}
- {ticket.description}
-
- {ticket.status}
- {ticket.priority}
- {new Date(ticket.createdAt).toLocaleTimeString()}
-
- ))}
-
-
-
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : tickets.length ? (
+
+ {tickets.map((ticket) => (
+
+
+
+ {ticket.id}
+
{ticket.topic}
+
+
+ {ticket.status}
+
+
+ {ticket.description}
+
+
+
Queue
+ {ticket.kind}
+
+
+
Priority
+ {ticket.priority}
+
+
+
Opened
+ {formatTimestamp(ticket.createdAt)}
+
+
+
Assigned to
+ {ticket.assignedTo ?? "Waiting for staff"}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
No help requests yet
+
+ When someone uses /queue open in{" "}
+ {workspace.guild.name}, their request will appear here.
+
+
+
+ Live queue updates are saved per
+ server
+
+
+ )}
);
}
+
+function ticketBadge(status: string): "green" | "amber" | "blue" {
+ if (status === "closed" || status === "canceled") return "green";
+ if (status === "escalated") return "amber";
+ return "blue";
+}
+
+function formatTimestamp(value: string): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "America/Toronto",
+ }).format(new Date(value));
+}
diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts
index ae18373..b9ecf56 100644
--- a/apps/web/app/robots.ts
+++ b/apps/web/app/robots.ts
@@ -4,9 +4,9 @@ export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
- allow: "/"
+ allow: "/",
},
sitemap: "https://piphacklup.vercel.app/sitemap.xml",
- host: "https://piphacklup.vercel.app"
+ host: "https://piphacklup.vercel.app",
};
}
diff --git a/apps/web/app/setup/page.tsx b/apps/web/app/setup/page.tsx
index 6836dce..04389e5 100644
--- a/apps/web/app/setup/page.tsx
+++ b/apps/web/app/setup/page.tsx
@@ -1,53 +1,188 @@
-import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
+import { CheckCircle2, Circle, ServerCog } from "lucide-react";
+import { getGuildConfigFromDb, type GuildIdentity } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { setupSteps } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
+
+interface SetupPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function SetupPage({ searchParams }: SetupPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let config = null;
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ config = await getGuildConfigFromDb(workspace.guild.id);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load server setup.");
+ }
+ }
+
+ const roleCount = config ? Object.keys(config.roles).length : 0;
+ const channelCount = config ? Object.keys(config.channels).length : 0;
+ const resourceCount = config?.resources
+ ? Object.values(config.resources).filter(Boolean).length
+ : 0;
+ const setupStarted = Boolean(roleCount || channelCount || resourceCount);
+ const steps = [
+ {
+ label: "Server configuration saved",
+ detail:
+ setupStarted && config
+ ? `${config.eventName} is connected to PipHackLup.`
+ : "Run /setup in Discord to create this server's event workspace.",
+ done: setupStarted,
+ },
+ {
+ label: "Event roles ready",
+ detail: roleCount
+ ? `${roleCount} role${roleCount === 1 ? "" : "s"} recorded for onboarding and staff access.`
+ : "No PipHackLup event roles have been recorded yet.",
+ done: roleCount > 0,
+ },
+ {
+ label: "Event channels ready",
+ detail: channelCount
+ ? `${channelCount} channel${channelCount === 1 ? "" : "s"} recorded for help, teams, and logs.`
+ : "No PipHackLup event channels have been recorded yet.",
+ done: channelCount > 0,
+ },
+ {
+ label: "Onboarding mode chosen",
+ detail:
+ setupStarted && config
+ ? `${config.onboardingMode === "gated" ? "Gated" : "Guided"} onboarding is active.`
+ : "Choose guided or gated onboarding when you run /setup.",
+ done: setupStarted,
+ },
+ ];
-export default function SetupPage() {
return (
-
+
-
- Developer Portal
-
+ workspace.guild ? (
+
+ Back to servers
+
+ ) : undefined
}
/>
-
- Launch checklist
-
- {setupSteps.map((step) => {
- const done = step.status === "done";
- const Icon = done ? CheckCircle2 : Circle;
- return (
-
-
-
-
-
-
{step.label}
-
{step.detail}
-
-
{done ? "Done" : "Todo"}
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : (
+ <>
+
+
+
+
Run this in {workspace.guild.name}
+
/setup
+
+ Only someone with Manage Server can run it. PipHackLup will
+ reuse anything it already created, so setup is safe to run again
+ after an interrupted attempt.
+
+
+
+
+
+
+
+
Setup checklist
+
+ These checks come from this server's saved configuration—not
+ sample data.
+
- );
- })}
-
-
-
-
- Invite permissions
-
- Use scopes bot and applications.commands . Grant View Channels, Send Messages, Embed Links,
- Attach Files, Read Message History, Manage Roles, Manage Nicknames, Manage Channels, Manage Threads, Moderate Members,
- Manage Guild, and optional Kick/Ban.
-
-
+
+ {setupStarted ? "Started" : "Not started"}
+
+
+
+ {steps.map((step) => {
+ const Icon = step.done ? CheckCircle2 : Circle;
+ return (
+
+
+
+
+
+
{step.label}
+
{step.detail}
+
+
+ {step.done ? "Ready" : "Needed"}
+
+
+ );
+ })}
+
+
+
+ {config && setupStarted ? (
+
+ ) : null}
+ >
+ )}
);
}
+
+function SetupDetails({
+ guild,
+ config,
+}: Readonly<{
+ guild: GuildIdentity;
+ config: NonNullable
>>;
+}>) {
+ return (
+
+ Saved event settings
+
+
+
Discord server
+ {guild.name}
+
+
+
Event name
+ {config.eventName}
+
+
+
Onboarding
+ {config.onboardingMode}
+
+
+
Team size
+
+ {config.teamSizeMin}–{config.teamSizeMax} people
+
+
+
+
+ );
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
index 94c1976..96702e4 100644
--- a/apps/web/app/sitemap.ts
+++ b/apps/web/app/sitemap.ts
@@ -6,12 +6,47 @@ export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return [
{ url: baseUrl, lastModified: now, changeFrequency: "weekly", priority: 1 },
- { url: `${baseUrl}/dashboard`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
- { url: `${baseUrl}/setup`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/queues`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/teams`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/moderation`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
- { url: `${baseUrl}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
- { url: `${baseUrl}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }
+ {
+ url: `${baseUrl}/dashboard`,
+ lastModified: now,
+ changeFrequency: "weekly",
+ priority: 0.8,
+ },
+ {
+ url: `${baseUrl}/setup`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/queues`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/teams`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/moderation`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 0.7,
+ },
+ {
+ url: `${baseUrl}/terms`,
+ lastModified: now,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
+ {
+ url: `${baseUrl}/privacy`,
+ lastModified: now,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
];
}
diff --git a/apps/web/app/teams/page.tsx b/apps/web/app/teams/page.tsx
index 1fa386c..03c4a47 100644
--- a/apps/web/app/teams/page.tsx
+++ b/apps/web/app/teams/page.tsx
@@ -1,63 +1,153 @@
-import { suggestTeamMatches } from "@piphacklup/core";
+import { Sparkles, Users } from "lucide-react";
+import {
+ suggestTeamMatches,
+ type MatchResult,
+ type MemberProfile,
+ type TeamProfile,
+} from "@piphacklup/core";
+import { listMemberProfilesFromDb, listTeamsFromDb } from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/PageHeader";
-import { demoMembers, demoTeams } from "@/lib/demo-data";
+import { WorkspaceState } from "@/components/WorkspaceState";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
-export default function TeamsPage() {
- const matches = suggestTeamMatches(demoMembers, demoTeams);
+interface TeamsPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function TeamsPage({ searchParams }: TeamsPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let profiles: MemberProfile[] = [];
+ let teams: TeamProfile[] = [];
+ let matches: MatchResult[] = [];
+ let dataUnavailable = false;
+
+ if (workspace.guild) {
+ try {
+ [profiles, teams] = await Promise.all([
+ listMemberProfilesFromDb(workspace.guild.id),
+ listTeamsFromDb(workspace.guild.id),
+ ]);
+ matches = suggestTeamMatches(profiles, teams);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load team formation data.");
+ }
+ }
return (
-
+
-
-
- Recruiting teams
-
-
-
- Team
- Members
- Needs
-
-
-
- {demoTeams.map((team) => (
-
-
- {team.name}
- {team.projectIdea}
-
-
- {team.memberIds.length}/{team.maxSize}
-
- {team.desiredSkills.join(", ")}
-
- ))}
-
-
-
+ {workspace.requestedGuildUnavailable ? (
+
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
+ ) : teams.length || profiles.length ? (
+
+
+
+
+
Recruiting teams
+
{teams.length} saved in this server
+
+
+ {teams.length ? (
+
+ {teams.map((team) => (
+
+
+
{team.name}
+
{team.projectIdea ?? "No project idea shared yet."}
+
+
+
+
Members
+
+ {team.memberIds.length}/{team.maxSize}
+
+
+
+
Looking for
+
+ {team.desiredSkills.join(", ") || "Open to ideas"}
+
+
+
+
+ ))}
+
+ ) : (
+ No teams are recruiting yet.
+ )}
+
-
- Match suggestions
-
- {matches.map((match) => (
-
-
{match.score}
-
-
{match.teamId}
-
Add users {match.addedMemberIds.join(", ")}
-
-
Suggested
+
+
+
+
Match suggestions
+
+ Suggestions only—people decide where they join.
+
+
+
+
+ {matches.length ? (
+
+ {matches.map((match) => {
+ const team = teams.find((item) => item.id === match.teamId);
+ return (
+
+
{match.score}
+
+
{team?.name ?? match.teamId}
+
+ {match.addedMemberIds.length} possible teammate
+ {match.addedMemberIds.length === 1 ? "" : "s"}
+
+
+
Review
+
+ );
+ })}
- ))}
+ ) : (
+
+ No match is ready yet. Participants can use{" "}
+ /team profile to join the pool, then an
+ organizer can run /team match .
+
+ )}
+
+
+ ) : (
+
+
+
+
No team activity yet
+
+ Participants can create a profile or recruiting team in
+ {` ${workspace.guild.name}`} with the /team {" "}
+ command.
+
-
+ )}
);
}
diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx
index 1a9a835..c071bce 100644
--- a/apps/web/app/terms/page.tsx
+++ b/apps/web/app/terms/page.tsx
@@ -3,7 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
export default function TermsPage() {
return (
-
+
Use of PipHackLup
- PipHackLup is provided to help Discord communities run hackathons with onboarding, role setup, queues, team formation,
- moderation cases, and organizer dashboards. You are responsible for using the bot in a way that follows Discord's
- Terms of Service, Discord's Developer Policy, and the rules of your event.
+ PipHackLup is provided to help Discord communities run hackathons with
+ onboarding, role setup, queues, team formation, moderation cases, and
+ organizer dashboards. You are responsible for using the bot in a way
+ that follows Discord's Terms of Service, Discord's Developer Policy,
+ and the rules of your event.
Organizer Responsibilities
- Server owners and organizers control how PipHackLup is installed, configured, and used in their Discord servers.
- Organizers are responsible for notifying participants about event rules, moderation expectations, and any exports
- or records they choose to keep.
+ Server owners and organizers control how PipHackLup is installed,
+ configured, and used in their Discord servers. Organizers are
+ responsible for notifying participants about event rules, moderation
+ expectations, and any exports or records they choose to keep.
Availability
- PipHackLup is an early-stage open-source project and is provided as-is. We try to keep the service reliable, but
- do not guarantee uninterrupted operation, especially on free hosting tiers or during active development.
+ PipHackLup is an open-source service provided as-is. We work to keep
+ it reliable, but do not guarantee uninterrupted operation during
+ maintenance, provider outages, or active development.
Contact
- For issues, feature requests, or removal requests, use the public GitHub repository at
- {" "}
- github.com/rupayon123/PipHackLup .
+ For bugs and feature requests that contain no private data, use the
+ public GitHub repository at{" "}
+
+ github.com/rupayon123/PipHackLup
+
+ . For data deletion or privacy concerns, use the repository's{" "}
+
+ private reporting page
+
+ . Never put Discord IDs, moderation-case details, or personal data in
+ a public issue.
diff --git a/apps/web/app/training/TrainingConsole.tsx b/apps/web/app/training/TrainingConsole.tsx
index 4f3b1c1..bf23524 100644
--- a/apps/web/app/training/TrainingConsole.tsx
+++ b/apps/web/app/training/TrainingConsole.tsx
@@ -1,70 +1,52 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Save, Sparkles, Trash2, Upload } from "lucide-react";
import {
answerHackathonQuestion,
- createKnowledgeEntry,
- defaultKnowledgeSettings,
- parseKnowledgeImportText,
type HackathonKnowledgeEntry,
type KnowledgeAssistantSettings,
type KnowledgeEscalationTarget,
} from "@piphacklup/core";
-import type { DiscordSession, ManagedDiscordGuild } from "@/lib/discord-auth";
+import type { ManagedDiscordGuild } from "@/lib/discord-auth";
interface TrainingConsoleProps {
- session: DiscordSession | null;
- databaseReady: boolean;
- installUrl: string;
+ guild: ManagedDiscordGuild;
+ initialEntries: HackathonKnowledgeEntry[];
+ initialSettings: KnowledgeAssistantSettings;
+ botInstallation: boolean | null;
}
-const previewGuild: ManagedDiscordGuild = {
- id: "preview-guild",
- name: "Preview Hackathon Server",
- isOwner: true,
- permissions: "32",
- canManage: true,
-};
+interface DiscordOption {
+ id: string;
+ name: string;
+}
+
+interface DiscordGuildOptions {
+ roles: DiscordOption[];
+ channels: DiscordOption[];
+}
-const previewEntries: HackathonKnowledgeEntry[] = [
- createKnowledgeEntry({
- guildId: previewGuild.id,
- title: "Check-in location",
- answer:
- "Check-in is at the main registration desk. Staff can update this from the website trainer or `/train add`.",
- tags: ["check-in", "registration", "badge"],
- createdBy: "preview",
- }),
- createKnowledgeEntry({
- guildId: previewGuild.id,
- title: "Submission deadline",
- answer:
- "Project submissions close at 10:00 AM on demo day. Ask staff to change this for the real event.",
- tags: ["deadline", "submit", "demo"],
- escalationTarget: "staff",
- createdBy: "preview",
- }),
-];
+type BusyAction = "add" | "import" | "settings" | `delete:${string}`;
+type Notice = { kind: "status" | "error"; message: string };
+type DiscordOptionsStatus = "not-needed" | "loading" | "ready" | "error";
export function TrainingConsole({
- session,
- databaseReady,
- installUrl,
-}: TrainingConsoleProps) {
- const guilds = session?.guilds.length ? session.guilds : [previewGuild];
- const [selectedGuildId, setSelectedGuildId] = useState(
- guilds[0]?.id ?? previewGuild.id,
- );
- const selectedGuild =
- guilds.find((guild) => guild.id === selectedGuildId) ??
- guilds[0] ??
- previewGuild;
- const liveMode = Boolean(session && databaseReady);
+ guild,
+ initialEntries,
+ initialSettings,
+ botInstallation,
+}: Readonly
) {
const [entries, setEntries] =
- useState(previewEntries);
- const [settings, setSettings] = useState({
- ...defaultKnowledgeSettings,
- });
+ useState(initialEntries);
+ const [settings, setSettings] =
+ useState(initialSettings);
+ const [discordOptions, setDiscordOptions] =
+ useState(null);
+ const [discordOptionsStatus, setDiscordOptionsStatus] =
+ useState(
+ botInstallation === true ? "loading" : "not-needed",
+ );
const [title, setTitle] = useState("");
const [answer, setAnswer] = useState("");
const [tags, setTags] = useState("");
@@ -72,188 +54,218 @@ export function TrainingConsole({
useState("none");
const [importText, setImportText] = useState("");
const [question, setQuestion] = useState("Where do I check in?");
- const [status, setStatus] = useState(
- liveMode
- ? "Connected to Discord and database."
- : "Preview mode: connect Discord and DATABASE_URL for live training.",
- );
-
- useEffect(() => {
- setSelectedGuildId(guilds[0]?.id ?? previewGuild.id);
- }, [session?.user.id]);
+ const [busyAction, setBusyAction] = useState(null);
+ const [removeConfirmationId, setRemoveConfirmationId] = useState<
+ string | null
+ >(null);
+ const deleteConfirmRef = useRef(null);
+ const returnDeleteFocusIdRef = useRef(null);
+ const [notice, setNotice] = useState({
+ kind: "status",
+ message: `Ready to edit answers for ${guild.name}.`,
+ });
useEffect(() => {
- if (!liveMode) {
- setEntries(
- previewEntries.map((entry) => ({
- ...entry,
- guildId: selectedGuild.id,
- })),
- );
- setSettings({ ...defaultKnowledgeSettings });
+ if (botInstallation !== true) {
+ setDiscordOptions(null);
+ setDiscordOptionsStatus("not-needed");
return;
}
- let canceled = false;
- async function loadTraining() {
- setStatus("Loading server training...");
- const query = `guildId=${encodeURIComponent(selectedGuild.id)}`;
- const [entriesResponse, settingsResponse] = await Promise.all([
- fetch(`/api/training/entries?${query}`),
- fetch(`/api/training/settings?${query}`),
- ]);
- if (canceled) return;
- if (!entriesResponse.ok || !settingsResponse.ok) {
- setStatus("Could not load live training for this server.");
- return;
+ const controller = new AbortController();
+ setDiscordOptions(null);
+ setDiscordOptionsStatus("loading");
+ async function loadDiscordOptions() {
+ try {
+ const response = await fetch(
+ `/api/discord/guilds/${encodeURIComponent(guild.id)}/options`,
+ { signal: controller.signal },
+ );
+ if (!response.ok) throw new Error("options_unavailable");
+ const body = (await response.json()) as DiscordGuildOptions;
+ if (!controller.signal.aborted) {
+ setDiscordOptions(body);
+ setDiscordOptionsStatus("ready");
+ }
+ } catch {
+ if (controller.signal.aborted) return;
+ console.error("PipHackLup could not load Discord roles and channels.");
+ setDiscordOptions(null);
+ setDiscordOptionsStatus("error");
+ setNotice({
+ kind: "error",
+ message:
+ "Discord roles and channels could not be loaded. Your saved answers are still available.",
+ });
}
- const entriesJson = (await entriesResponse.json()) as {
- entries: HackathonKnowledgeEntry[];
- };
- const settingsJson = (await settingsResponse.json()) as {
- settings: KnowledgeAssistantSettings;
- };
- setEntries(entriesJson.entries);
- setSettings(settingsJson.settings);
- setStatus(`Live training loaded for ${selectedGuild.name}.`);
+ }
+ void loadDiscordOptions();
+ return () => controller.abort();
+ }, [botInstallation, guild.id]);
+
+ useEffect(() => {
+ if (removeConfirmationId !== null) {
+ if (busyAction !== null) return;
+ const frame = window.requestAnimationFrame(() => {
+ deleteConfirmRef.current?.focus();
+ });
+ return () => window.cancelAnimationFrame(frame);
}
- void loadTraining();
- return () => {
- canceled = true;
- };
- }, [liveMode, selectedGuild.id, selectedGuild.name]);
+ if (!returnDeleteFocusIdRef.current) return;
+ const entryId = returnDeleteFocusIdRef.current;
+ returnDeleteFocusIdRef.current = null;
+ const frame = window.requestAnimationFrame(() => {
+ document
+ .querySelector(
+ `[data-remove-entry-id="${CSS.escape(entryId)}"]`,
+ )
+ ?.focus();
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }, [busyAction, removeConfirmationId]);
const previewAnswer = useMemo(
() => answerHackathonQuestion(question, entries, settings),
[entries, question, settings],
);
+ const busy = busyAction !== null;
async function addEntry() {
if (!title.trim() || !answer.trim()) {
- setStatus("Add a title and answer first.");
+ setNotice({
+ kind: "error",
+ message:
+ "Add both a participant question and the answer they should receive.",
+ });
return;
}
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const response = await fetch(
- `/api/training/entries?guildId=${guildQuery}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ title, answer, tags, escalationTarget }),
- },
- );
- if (!response.ok) {
- setStatus("Live training save failed.");
- return;
- }
- const json = (await response.json()) as {
+ setBusyAction("add");
+ setNotice({ kind: "status", message: "Saving this answer…" });
+ try {
+ const response = await trainingFetch("/api/training/entries", guild.id, {
+ method: "POST",
+ body: JSON.stringify({ title, answer, tags, escalationTarget }),
+ });
+ const body = (await response.json()) as {
entry: HackathonKnowledgeEntry;
+ warning?: string | null;
};
- setEntries((current) => [json.entry, ...current]);
- setStatus(`Saved live training entry: ${json.entry.title}.`);
- } else {
- const entry = createKnowledgeEntry({
- guildId: selectedGuild.id,
- title,
- answer,
- tags: tags.split(","),
- escalationTarget,
- createdBy: session?.user.id ?? "preview",
+ setEntries((current) => [body.entry, ...current]);
+ setTitle("");
+ setAnswer("");
+ setTags("");
+ setEscalationTarget("none");
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Saved “${body.entry.title}” for ${guild.name}, but its activity-log entry could not be recorded. The answer itself is safe.`
+ : `Saved “${body.entry.title}” for ${guild.name}.`,
});
- setEntries((current) => [entry, ...current]);
- setStatus(`Preview entry added: ${entry.title}.`);
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
-
- setTitle("");
- setAnswer("");
- setTags("");
- setEscalationTarget("none");
}
async function importEntries() {
if (!importText.trim()) {
- setStatus("Paste training lines first.");
+ setNotice({
+ kind: "error",
+ message: "Paste at least one event detail before importing.",
+ });
return;
}
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const response = await fetch(
- `/api/training/entries?guildId=${guildQuery}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ importText, escalationTarget }),
- },
- );
- if (!response.ok) {
- setStatus("Live import failed.");
- return;
- }
- const json = (await response.json()) as {
+ setBusyAction("import");
+ setNotice({ kind: "status", message: "Checking and importing details…" });
+ try {
+ const response = await trainingFetch("/api/training/entries", guild.id, {
+ method: "POST",
+ body: JSON.stringify({ importText, escalationTarget }),
+ });
+ const body = (await response.json()) as {
entries: HackathonKnowledgeEntry[];
+ warning?: string | null;
};
- setEntries((current) => [...json.entries, ...current]);
- setStatus(`Imported ${json.entries.length} live training entries.`);
- } else {
- const imported = parseKnowledgeImportText(
- importText,
- escalationTarget,
- ).map((entry) =>
- createKnowledgeEntry({
- guildId: selectedGuild.id,
- title: entry.title,
- answer: entry.answer,
- tags: entry.tags,
- escalationTarget: entry.escalationTarget,
- createdBy: session?.user.id ?? "preview",
- }),
- );
- setEntries((current) => [...imported, ...current]);
- setStatus(`Preview imported ${imported.length} entries.`);
+ setEntries((current) => [...body.entries, ...current]);
+ setImportText("");
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Imported ${body.entries.length} answer${body.entries.length === 1 ? "" : "s"}, but the activity-log entry could not be recorded. The answers themselves are safe.`
+ : `Imported ${body.entries.length} answer${body.entries.length === 1 ? "" : "s"}.`,
+ });
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
- setImportText("");
}
async function saveSettings() {
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const response = await fetch(
- `/api/training/settings?guildId=${guildQuery}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(settings),
- },
- );
- if (!response.ok) {
- setStatus("Live settings save failed.");
- return;
- }
- setStatus("Live escalation settings saved.");
- return;
+ setBusyAction("settings");
+ setNotice({ kind: "status", message: "Saving answer settings…" });
+ try {
+ const response = await trainingFetch("/api/training/settings", guild.id, {
+ method: "POST",
+ body: JSON.stringify({
+ ...settings,
+ staffRoleId: settings.staffRoleId ?? null,
+ mentorRoleId: settings.mentorRoleId ?? null,
+ helpChannelId: settings.helpChannelId ?? null,
+ }),
+ });
+ const body = (await response.json()) as { warning?: string | null };
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Settings were saved for ${guild.name}, but the activity-log entry could not be recorded.`
+ : `Answer and follow-up settings saved for ${guild.name}.`,
+ });
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
- setStatus("Preview settings updated.");
}
- async function removeEntry(entryId: string) {
- if (liveMode) {
- const guildQuery = encodeURIComponent(selectedGuild.id);
- const entryQuery = encodeURIComponent(entryId);
- const response = await fetch(
- `/api/training/entries?guildId=${guildQuery}&entryId=${entryQuery}`,
+ async function removeEntry(entry: HackathonKnowledgeEntry) {
+ setBusyAction(`delete:${entry.id}`);
+ setNotice({ kind: "status", message: `Removing “${entry.title}”…` });
+ try {
+ const response = await trainingFetch(
+ "/api/training/entries",
+ guild.id,
{ method: "DELETE" },
+ { entryId: entry.id },
);
- if (!response.ok) {
- setStatus("Could not delete live training entry.");
- return;
- }
+ const body = (await response.json()) as { warning?: string | null };
+ setEntries((current) => current.filter((item) => item.id !== entry.id));
+ setRemoveConfirmationId(null);
+ returnDeleteFocusIdRef.current = null;
+ setNotice({
+ kind: body.warning ? "error" : "status",
+ message: body.warning
+ ? `Removed “${entry.title}” from ${guild.name}, but the activity-log entry could not be recorded.`
+ : `Removed “${entry.title}” from ${guild.name}.`,
+ });
+ } catch (error) {
+ setNotice({ kind: "error", message: trainingErrorMessage(error) });
+ } finally {
+ setBusyAction(null);
}
- setEntries((current) => current.filter((entry) => entry.id !== entryId));
- setStatus("Training entry removed.");
+ }
+
+ function askToRemoveEntry(entryId: string): void {
+ setRemoveConfirmationId(entryId);
+ }
+
+ function cancelEntryRemoval(): void {
+ returnDeleteFocusIdRef.current = removeConfirmationId;
+ setRemoveConfirmationId(null);
}
function updateOptionalSetting(
@@ -262,110 +274,63 @@ export function TrainingConsole({
) {
setSettings((current) => {
const next = { ...current };
- if (value) {
- next[key] = value;
- } else {
- delete next[key];
- }
+ if (value) next[key] = value;
+ else delete next[key];
return next;
});
}
return (
-
+
+
+ {notice.message}
+
+
- Linked Discord Account
- {session ? (
-
- {session.user.avatarUrl ? (
-
- ) : (
-
- {session.user.username.slice(0, 1).toUpperCase()}
-
- )}
-
-
- {session.user.globalName ?? session.user.username}
-
-
Discord ID {session.user.id}
-
-
- Sign out
-
-
- ) : (
-
-
-
1
-
-
Connect Discord
-
- The dashboard will show servers where your account has Manage
- Server.
-
-
-
- Connect
-
-
+
+
+
Add an answer
+
+ Use the words participants are likely to use.
+
- )}
-
-
- Server deployment
- setSelectedGuildId(event.target.value)}
- >
- {guilds.map((guild) => (
-
- {guild.name}
-
- ))}
-
-
-
-
- {liveMode ? "Live" : "Preview"}
-
- {status}
+
-
- Add PipHackLup to this Discord server
-
-
-
-
- Bulk Import
+ Import several details
+
+ Put one detail on each line: question | answer | comma-separated
+ keywords | optional mentor or staff follow-up.
+
- One line per detail
+ Event details
-
- Import details
+
+
+ {busyAction === "import" ? "Importing…" : "Import details"}
-
- Ask Preview
+
+ Try a participant question
- Participant question
+ Question
setQuestion(event.target.value)}
@@ -479,8 +486,8 @@ export function TrainingConsole({
{previewAnswer.shouldEscalate
- ? "Answer + human follow-up"
- : "Answer from training"}
+ ? "Answer with human follow-up"
+ : "Answer from saved details"}
{previewAnswer.answer}
{previewAnswer.escalationReason}
@@ -489,49 +496,173 @@ export function TrainingConsole({
- Training Library
-
-
-
- Topic
- Keywords
- Follow-up
-
-
-
-
+
+
+
Saved answers
+
+ {entries.length} in {guild.name}
+
+
+
+ {entries.length ? (
+
{entries.map((entry) => (
-
-
- {entry.title}
- {entry.answer}
-
- {entry.tags.join(", ") || "none"}
-
-
+
+
{entry.title}
+
{entry.answer}
+
+ {entry.tags.map((tag) => (
+ {tag}
+ ))}
+
+ {entry.escalationTarget === "none"
+ ? "No automatic follow-up"
+ : `${entry.escalationTarget} follow-up`}
+
+
+
+ {removeConfirmationId === entry.id ? (
+ {
+ if (event.key !== "Escape" || busy) return;
+ event.preventDefault();
+ cancelEntryRemoval();
+ }}
>
- {entry.escalationTarget}
-
-
-
+
+ Remove “{entry.title}” ? Participants will
+ no longer receive this saved answer.
+
+
+ removeEntry(entry)}
+ ref={deleteConfirmRef}
+ >
+
+ {busyAction === `delete:${entry.id}`
+ ? "Removing…"
+ : "Confirm remove"}
+
+
+ Keep answer
+
+
+
+ ) : (
removeEntry(entry.id)}
+ disabled={busy}
+ onClick={() => askToRemoveEntry(entry.id)}
+ aria-label={`Remove ${entry.title}`}
+ data-remove-entry-id={entry.id}
>
+
Remove
-
-
+ )}
+
))}
-
-
+
+ ) : (
+
+ No answers have been saved for this server yet. Add the event basics
+ above, then try the participant question preview.
+
+ )}
);
}
+
+function DiscordSelect({
+ label,
+ value,
+ options,
+ disabled,
+ onChange,
+}: Readonly<{
+ label: string;
+ value: string;
+ options: DiscordOption[];
+ disabled: boolean;
+ onChange: (value: string) => void;
+}>) {
+ const currentOptionExists = options.some((option) => option.id === value);
+ return (
+
+ {label}
+ onChange(event.target.value)}
+ disabled={disabled}
+ >
+ Not selected
+ {value && !currentOptionExists ? (
+ Previously selected
+ ) : null}
+ {options.map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+ );
+}
+
+async function trainingFetch(
+ path: string,
+ guildId: string,
+ init: RequestInit,
+ extraQuery: Record
= {},
+): Promise {
+ const query = new URLSearchParams({ guildId, ...extraQuery });
+ const response = await fetch(`${path}?${query.toString()}`, {
+ ...init,
+ headers: {
+ "content-type": "application/json",
+ ...init.headers,
+ },
+ });
+ if (response.ok) return response;
+ const body = (await response.json().catch(() => null)) as {
+ error?: string;
+ } | null;
+ throw new Error(body?.error ?? `request_failed_${response.status}`);
+}
+
+function trainingErrorMessage(error: unknown): string {
+ const code = error instanceof Error ? error.message : "unknown";
+ switch (code) {
+ case "training_content_rejected":
+ return "That text looks like an instruction-override attempt, so it was not saved. Rewrite it as plain event information or ask staff to review it.";
+ case "training_import_empty":
+ return "No usable event details were found. Add one detail per line, then import again.";
+ case "training_import_too_many_entries":
+ return "This import has more than 50 event details. Split it into smaller groups so every answer can be reviewed and saved.";
+ case "missing_manage_server":
+ return "Your Discord account no longer has permission to manage this server.";
+ case "untrusted_request_origin":
+ return "This save could not be verified. Refresh the page and try again.";
+ case "rate_limited":
+ return "Too many changes were sent at once. Wait a moment, then try again.";
+ case "database_not_configured":
+ case "database_unavailable":
+ return "Your saved answers are temporarily unavailable. Nothing was changed; try again shortly.";
+ default:
+ return "That change could not be saved. Nothing was lost; try again.";
+ }
+}
diff --git a/apps/web/app/training/page.tsx b/apps/web/app/training/page.tsx
index c79992b..1ec83cd 100644
--- a/apps/web/app/training/page.tsx
+++ b/apps/web/app/training/page.tsx
@@ -1,104 +1,124 @@
-import { Bot, Database, LogIn, ServerCog } from "lucide-react";
-import { isDatabaseConfigured } from "@piphacklup/db";
+import { Bot, BookOpenCheck, ExternalLink } from "lucide-react";
+import {
+ defaultKnowledgeSettings,
+ type HackathonKnowledgeEntry,
+ type KnowledgeAssistantSettings,
+} from "@piphacklup/core";
+import {
+ getKnowledgeSettingsFromDb,
+ listKnowledgeEntriesFromDb,
+} from "@piphacklup/db";
import { AppShell } from "@/components/AppShell";
-import { MetricCard } from "@/components/MetricCard";
import { PageHeader } from "@/components/PageHeader";
+import { WorkspaceState } from "@/components/WorkspaceState";
import {
- isDiscordAuthConfigured,
- readDiscordSession,
-} from "@/lib/discord-auth";
+ getDiscordInstallUrl,
+ isDiscordBotApiConfigured,
+ listDiscordBotGuildIds,
+} from "@/lib/discord-installation";
+import { loadGuildWorkspace } from "@/lib/guild-workspace";
import { TrainingConsole } from "./TrainingConsole";
-const installUrl =
- "https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1117094267958";
+interface TrainingPageProps {
+ searchParams: Promise<{ guildId?: string }>;
+}
+
+export default async function TrainingPage({
+ searchParams,
+}: TrainingPageProps) {
+ const { guildId } = await searchParams;
+ const workspace = await loadGuildWorkspace(guildId);
+ let entries: HackathonKnowledgeEntry[] = [];
+ let settings: KnowledgeAssistantSettings = { ...defaultKnowledgeSettings };
+ let dataUnavailable = false;
+ let installation: boolean | null = null;
+
+ if (workspace.guild) {
+ try {
+ [entries, settings] = await Promise.all([
+ listKnowledgeEntriesFromDb(workspace.guild.id),
+ getKnowledgeSettingsFromDb(workspace.guild.id),
+ ]);
+ } catch {
+ dataUnavailable = true;
+ console.error("PipHackLup could not load Q&A training.");
+ }
-export default async function TrainingPage() {
- const session = await readDiscordSession();
- const authReady = isDiscordAuthConfigured();
- const databaseReady = isDatabaseConfigured();
+ if (isDiscordBotApiConfigured()) {
+ try {
+ installation = (await listDiscordBotGuildIds()).has(workspace.guild.id);
+ } catch {
+ console.error("PipHackLup could not check the bot installation.");
+ }
+ }
+ }
return (
-
+
- {session ? (
-
-
- Sign out
-
- ) : (
-
-
- Connect Discord
-
- )}
-
+ workspace.guild && installation === false ? (
+
- Add bot
+ Add PipHackLup
+
- >
+ ) : undefined
}
/>
-
-
+ ) : !workspace.guild ? (
+
+ ) : dataUnavailable ? (
+
-
-
-
-
-
-
-
-
-
- Server training is scoped to Discord guilds the signed-in organizer
- can manage.
-
-
-
-
-
- With Postgres attached, website training and slash-command training
- use the same source.
-
-
-
-
-
+ ) : (
+ <>
+
+
+
+
+ {entries.length} saved answer{entries.length === 1 ? "" : "s"}
+
+
+ Changes apply only to {workspace.guild.name}. PipHackLup filters
+ instruction-override attempts before saving them.
+
+
+
+ {installation === true
+ ? "Bot installed"
+ : installation === false
+ ? "Bot not installed"
+ : "Install status unavailable"}
+
+
+
+ >
+ )}
);
}
diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx
index 97949e8..d0b9d40 100644
--- a/apps/web/components/AppShell.tsx
+++ b/apps/web/components/AppShell.tsx
@@ -1,96 +1,67 @@
-"use client";
-
import {
- BarChart3,
- ClipboardList,
- Download,
- FileText,
- MessageCircleQuestion,
- Shield,
- Users,
- Wrench,
-} from "lucide-react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useEffect, useState } from "react";
-import { ThemeToggle } from "@/components/ThemeToggle";
-
-type DashboardTheme = "light" | "dark";
-
-const themeStorageKey = "piphacklup-dashboard-theme";
+ isDiscordAuthConfigured,
+ readDiscordSession,
+ type DiscordSession,
+} from "@/lib/discord-auth";
+import { Suspense } from "react";
+import { AppShellClient } from "./AppShellClient";
-const navItems = [
- { href: "/dashboard", label: "Overview", icon: BarChart3 },
- { href: "/setup", label: "Setup", icon: Wrench },
- { href: "/training", label: "Q&A Training", icon: MessageCircleQuestion },
- { href: "/queues", label: "Queues", icon: ClipboardList },
- { href: "/teams", label: "Teams", icon: Users },
- { href: "/moderation", label: "Moderation", icon: Shield },
- { href: "/privacy", label: "Privacy", icon: FileText },
-];
+interface AppShellProps {
+ children: React.ReactNode;
+ session?: DiscordSession | null;
+ authRequired?: boolean;
+ sessionUnavailable?: boolean;
+}
-export function AppShell({
+export async function AppShell({
children,
-}: Readonly<{ children: React.ReactNode }>) {
- const [theme, setTheme] = useState("light");
- const pathname = usePathname();
-
- useEffect(() => {
- const saved = window.localStorage.getItem(themeStorageKey);
- const initial =
- saved === "dark" || saved === "light"
- ? saved
- : window.matchMedia("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
- setTheme(initial);
- document.documentElement.dataset.dashboardTheme = initial;
- }, []);
+ session: providedSession,
+ authRequired = true,
+ sessionUnavailable: providedSessionUnavailable = false,
+}: Readonly) {
+ let session = providedSession ?? null;
+ let sessionUnavailable = providedSessionUnavailable;
- function toggleTheme() {
- const next = theme === "dark" ? "light" : "dark";
- setTheme(next);
- window.localStorage.setItem(themeStorageKey, next);
- document.documentElement.dataset.dashboardTheme = next;
+ if (providedSession === undefined && isDiscordAuthConfigured()) {
+ try {
+ session = await readDiscordSession();
+ } catch {
+ sessionUnavailable = true;
+ console.error("PipHackLup could not read the organizer session.");
+ }
}
return (
-
+
}>
+
+ {children}
+
+
+ );
+}
+
+function AppShellFallback() {
+ return (
+
-
-
- {children}
+
+
+
Organizer control room
+
Opening your server workspace…
diff --git a/apps/web/components/AppShellClient.tsx b/apps/web/components/AppShellClient.tsx
new file mode 100644
index 0000000..3d929b8
--- /dev/null
+++ b/apps/web/components/AppShellClient.tsx
@@ -0,0 +1,523 @@
+"use client";
+
+import {
+ BarChart3,
+ Bot,
+ ClipboardList,
+ Download,
+ FileText,
+ LogIn,
+ LogOut,
+ MessageCircleQuestion,
+ MoreHorizontal,
+ Server,
+ Shield,
+ Users,
+ Wrench,
+} from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+import type { DiscordSession } from "@/lib/discord-auth";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+type DashboardTheme = "light" | "dark";
+
+interface AppShellClientProps {
+ children: React.ReactNode;
+ session: DiscordSession | null;
+ authReady: boolean;
+ authRequired: boolean;
+ sessionUnavailable: boolean;
+}
+
+const themeStorageKey = "piphacklup-dashboard-theme";
+const guildWorkspacePaths = new Set([
+ "/dashboard",
+ "/dev-fixtures/control-room",
+ "/moderation",
+ "/queues",
+ "/setup",
+ "/teams",
+ "/training",
+]);
+
+const primaryNavItems = [
+ { href: "/dashboard", label: "Overview", icon: BarChart3 },
+ { href: "/setup", label: "Setup", icon: Wrench },
+ { href: "/training", label: "Q&A Training", icon: MessageCircleQuestion },
+ {
+ href: "/queues",
+ label: "Queues",
+ icon: ClipboardList,
+ mobileOverflow: true,
+ },
+ { href: "/teams", label: "Teams", icon: Users, mobileOverflow: true },
+];
+
+const mobileOverflowNavItems = primaryNavItems.filter(
+ (item) => item.mobileOverflow,
+);
+
+const secondaryNavItems = [
+ { href: "/moderation", label: "Moderation", icon: Shield },
+ { href: "/privacy", label: "Privacy", icon: FileText },
+];
+
+export function AppShellClient({
+ children,
+ session,
+ authReady,
+ authRequired,
+ sessionUnavailable,
+}: Readonly) {
+ const [theme, setTheme] = useState("light");
+ const pathname = usePathname();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const requestedGuildId = searchParams.get("guildId");
+ const shouldRestoreGuild = Boolean(
+ session?.guilds.length &&
+ !requestedGuildId &&
+ guildWorkspacePaths.has(pathname),
+ );
+ const [restoringGuild, setRestoringGuild] = useState(shouldRestoreGuild);
+ const requestedGuild = session?.guilds.find(
+ (guild) => guild.id === requestedGuildId,
+ );
+ const requestedGuildUnavailable = Boolean(
+ session && requestedGuildId && !requestedGuild,
+ );
+ const selectedGuildId = requestedGuildUnavailable
+ ? ""
+ : restoringGuild
+ ? ""
+ : (requestedGuild?.id ?? session?.guilds[0]?.id ?? "");
+ const selectedGuild = session?.guilds.find(
+ (guild) => guild.id === selectedGuildId,
+ );
+ const authMessage = getAuthMessage(searchParams.get("auth"));
+
+ useEffect(() => {
+ const initial = readPreparedTheme();
+ setTheme(initial);
+ document.documentElement.dataset.dashboardTheme = initial;
+ }, []);
+
+ useEffect(() => {
+ if (!session?.guilds.length || !guildWorkspacePaths.has(pathname)) {
+ setRestoringGuild(false);
+ return;
+ }
+
+ const storageKey = lastGuildStorageKey(session.user.id);
+ if (requestedGuildId) {
+ if (requestedGuild) {
+ storeLastGuild(storageKey, requestedGuild.id);
+ }
+ setRestoringGuild(false);
+ return;
+ }
+
+ const fallbackGuildId = session.guilds[0]!.id;
+ const savedGuildId = readLastGuild(storageKey);
+ const savedGuild = session.guilds.find(
+ (guild) => guild.id === savedGuildId,
+ );
+ if (savedGuild && savedGuild.id !== fallbackGuildId) {
+ setRestoringGuild(true);
+ router.replace(withGuild(pathname, savedGuild.id), { scroll: false });
+ return;
+ }
+
+ storeLastGuild(storageKey, fallbackGuildId);
+ setRestoringGuild(false);
+ }, [pathname, requestedGuild, requestedGuildId, router, session]);
+
+ useEffect(() => {
+ let canceled = false;
+ let retryTimer: ReturnType | undefined;
+ let attempt = 0;
+
+ async function retryPendingRevocations() {
+ let completed = false;
+ try {
+ const response = await fetch("/api/auth/session/revoke-pending", {
+ method: "POST",
+ });
+ completed = response.status === 204;
+ } catch {
+ completed = false;
+ }
+
+ if (canceled) return;
+ if (completed) {
+ if (searchParams.get("auth") === "logout_incomplete") {
+ const next = new URLSearchParams(searchParams.toString());
+ next.delete("auth");
+ router.replace(`${pathname}${next.size ? `?${next}` : ""}`, {
+ scroll: false,
+ });
+ }
+ return;
+ }
+
+ attempt += 1;
+ retryTimer = setTimeout(
+ () => void retryPendingRevocations(),
+ Math.min(60_000, 2_000 * 2 ** Math.min(attempt, 5)),
+ );
+ }
+
+ void retryPendingRevocations();
+ return () => {
+ canceled = true;
+ if (retryTimer) clearTimeout(retryTimer);
+ };
+ }, [pathname, router, searchParams]);
+
+ const navigation = useMemo(
+ () =>
+ primaryNavItems.map((item) => ({
+ ...item,
+ href: withGuild(item.href, selectedGuildId),
+ })),
+ [selectedGuildId],
+ );
+ const secondaryNavigation = useMemo(
+ () =>
+ secondaryNavItems.map((item) => ({
+ ...item,
+ href: withGuild(item.href, selectedGuildId),
+ })),
+ [selectedGuildId],
+ );
+
+ function toggleTheme() {
+ const next = theme === "dark" ? "light" : "dark";
+ setTheme(next);
+ try {
+ window.localStorage.setItem(themeStorageKey, next);
+ } catch {
+ // The theme still changes for this page when browser storage is blocked.
+ }
+ document.documentElement.dataset.dashboardTheme = next;
+ }
+
+ function selectGuild(guildId: string) {
+ if (!guildId) return;
+ router.push(withGuild(pathname, guildId));
+ }
+
+ const showAuthGate = authRequired && !session;
+
+ return (
+
+
+ Skip to main content
+
+
+
+
+
+ P
+
+ PipHackLup
+
+
+
+
+ {session ? (
+
+
+
+ Server workspace
+
+ selectGuild(event.target.value)}
+ >
+ {restoringGuild ? (
+
+ Opening last server…
+
+ ) : requestedGuildUnavailable ? (
+
+ Choose a server
+
+ ) : null}
+ {session.guilds.length ? (
+ session.guilds.map((guild) => (
+
+ {guild.name}
+
+ ))
+ ) : (
+ No manageable servers
+ )}
+
+
+ ) : null}
+
+ {session && !restoringGuild ? (
+
+ {navigation.map((item) => {
+ const Icon = item.icon;
+ const active =
+ pathname === item.href.split("?")[0] ||
+ pathname.startsWith(`${item.href.split("?")[0]}/`);
+
+ return (
+
+
+ {item.label}
+
+ );
+ })}
+ {
+ if (event.key !== "Escape") return;
+ event.currentTarget.open = false;
+ event.currentTarget.querySelector("summary")?.focus();
+ }}
+ >
+
+
+ More
+
+
+ {mobileOverflowNavItems.map((item) => {
+ const Icon = item.icon;
+ const href = withGuild(item.href, selectedGuildId);
+ const active =
+ pathname === item.href ||
+ pathname.startsWith(`${item.href}/`);
+ return (
+
+
+
{item.label}
+
+ );
+ })}
+ {secondaryNavigation.map((item) => {
+ const Icon = item.icon;
+ const active =
+ pathname === item.href.split("?")[0] ||
+ pathname.startsWith(`${item.href.split("?")[0]}/`);
+ return (
+
+
+
{item.label}
+
+ );
+ })}
+
+
+ Export
+
+
+
+
+ ) : null}
+
+
+ {session ? (
+ <>
+
+ {session.user.avatarUrl ? (
+
+ ) : (
+
+ {session.user.username.slice(0, 1).toUpperCase()}
+
+ )}
+
+
+ {session.user.globalName ?? session.user.username}
+
+
+ {selectedGuild ? selectedGuild.name : "Discord connected"}
+
+
+
+
+ >
+ ) : (
+
+
+
+ Organizer account
+ Discord is your sign-in
+
+
+ )}
+
+
+
+
+ {restoringGuild ? (
+
+
+
+
Opening your last server workspace…
+
PipHackLup is checking that you can still manage it.
+
+
+ ) : showAuthGate ? (
+
+ ) : (
+ children
+ )}
+
+
+
+ );
+}
+
+function AuthGate({
+ authMessage,
+ authReady,
+ sessionUnavailable,
+}: {
+ authMessage: string | undefined;
+ authReady: boolean;
+ sessionUnavailable: boolean;
+}) {
+ const unavailable = sessionUnavailable || !authReady;
+ return (
+
+
+
+
+ Organizer control room
+ Sign in with Discord to manage your servers
+
+ PipHackLup uses your Discord identity to show only servers you own or
+ where you currently have Manage Server permission.
+
+ {authMessage ? (
+
+ {authMessage}
+
+ ) : null}
+ {unavailable ? (
+
+ Discord account login is temporarily unavailable.
+
+ Nothing is wrong with your Discord account. Please try again a
+ little later.
+
+
+ ) : (
+
+
+ Continue with Discord
+
+ )}
+
+ PipHackLup never asks for your Discord password and does not request
+ access to read your messages.
+
+
+ );
+}
+
+function withGuild(href: string, guildId: string): string {
+ if (!guildId) return href;
+ const separator = href.includes("?") ? "&" : "?";
+ return `${href}${separator}guildId=${encodeURIComponent(guildId)}`;
+}
+
+function lastGuildStorageKey(discordUserId: string): string {
+ return `piphacklup:last-guild:${discordUserId}`;
+}
+
+function readPreparedTheme(): DashboardTheme {
+ const prepared = document.documentElement.dataset.dashboardTheme;
+ if (prepared === "dark" || prepared === "light") return prepared;
+
+ try {
+ const saved = window.localStorage.getItem(themeStorageKey);
+ if (saved === "dark" || saved === "light") return saved;
+ } catch {
+ // Fall through to the operating-system preference.
+ }
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+function readLastGuild(storageKey: string): string | null {
+ try {
+ return window.localStorage.getItem(storageKey);
+ } catch {
+ return null;
+ }
+}
+
+function storeLastGuild(storageKey: string, guildId: string): void {
+ try {
+ window.localStorage.setItem(storageKey, guildId);
+ } catch {
+ // The workspace remains usable when browser storage is unavailable.
+ }
+}
+
+function getAuthMessage(status: string | null): string | undefined {
+ switch (status) {
+ case "denied":
+ return "Discord sign-in was canceled. Nothing was changed; try again when you are ready.";
+ case "expired":
+ return "Your sign-in request expired. Start again to reconnect securely.";
+ case "failed":
+ return "Discord sign-in could not be completed. Try again, then contact support if it keeps failing.";
+ case "missing":
+ return "Discord sign-in is not configured on this deployment yet.";
+ case "logout_incomplete":
+ return "You are signed out on this browser. PipHackLup could not immediately end the previous server session. It will retry while this page is open and when this browser returns; that session expires automatically within 12 hours.";
+ default:
+ return undefined;
+ }
+}
diff --git a/apps/web/components/MetricCard.tsx b/apps/web/components/MetricCard.tsx
deleted file mode 100644
index 63d166a..0000000
--- a/apps/web/components/MetricCard.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-export function MetricCard({
- label,
- value,
- detail
-}: Readonly<{
- label: string;
- value: string;
- detail: string;
-}>) {
- return (
-
- {value}
- {label}
- {detail}
-
- );
-}
diff --git a/apps/web/components/PageHeader.tsx b/apps/web/components/PageHeader.tsx
index 8b7c3f9..087d54e 100644
--- a/apps/web/components/PageHeader.tsx
+++ b/apps/web/components/PageHeader.tsx
@@ -2,7 +2,7 @@ export function PageHeader({
eyebrow,
title,
subtitle,
- actions
+ actions,
}: Readonly<{
eyebrow: string;
title: string;
diff --git a/apps/web/components/ServerManager.tsx b/apps/web/components/ServerManager.tsx
new file mode 100644
index 0000000..7dbf44f
--- /dev/null
+++ b/apps/web/components/ServerManager.tsx
@@ -0,0 +1,463 @@
+"use client";
+
+import {
+ Bot,
+ CheckCircle2,
+ ExternalLink,
+ RefreshCw,
+ Search,
+ ServerOff,
+ Settings,
+ ShieldCheck,
+ Trash2,
+ X,
+} from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useEffect, useRef, useState } from "react";
+
+export interface ManagedServerView {
+ id: string;
+ name: string;
+ iconUrl?: string;
+ isOwner: boolean;
+ installed: boolean | null;
+ installUrl: string;
+}
+
+interface ServerManagerProps {
+ servers: ManagedServerView[];
+ installationStatusError?: string;
+}
+
+type ServerFilter = "all" | "installed" | "needs_setup" | "unknown";
+
+export function ServerManager({
+ servers,
+ installationStatusError,
+}: Readonly) {
+ const router = useRouter();
+ const dialogRef = useRef(null);
+ const dialogErrorRef = useRef(null);
+ const [installedState, setInstalledState] = useState<
+ Record
+ >(Object.fromEntries(servers.map((server) => [server.id, server.installed])));
+ const [removeTarget, setRemoveTarget] = useState(
+ null,
+ );
+ const [confirmation, setConfirmation] = useState("");
+ const [dialogError, setDialogError] = useState(null);
+ const [query, setQuery] = useState("");
+ const [filter, setFilter] = useState("all");
+ const [busyGuildId, setBusyGuildId] = useState(null);
+ const [pendingInstallGuildId, setPendingInstallGuildId] = useState<
+ string | null
+ >(null);
+ const [status, setStatus] = useState(
+ installationStatusError ?? "Server installation status is up to date.",
+ );
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ const visibleServers = servers.filter((server) => {
+ const installed = Object.hasOwn(installedState, server.id)
+ ? installedState[server.id]!
+ : server.installed;
+ const matchesQuery =
+ !normalizedQuery ||
+ server.name.toLocaleLowerCase().includes(normalizedQuery);
+ const matchesFilter =
+ filter === "all" ||
+ (filter === "installed" && installed === true) ||
+ (filter === "needs_setup" && installed === false) ||
+ (filter === "unknown" && installed === null);
+ return matchesQuery && matchesFilter;
+ });
+
+ useEffect(() => {
+ if (!pendingInstallGuildId) return;
+ const refreshWhenOrganizerReturns = () => {
+ setStatus("Checking whether Discord finished adding PipHackLup...");
+ window.location.reload();
+ };
+ window.addEventListener("focus", refreshWhenOrganizerReturns, {
+ once: true,
+ });
+ return () =>
+ window.removeEventListener("focus", refreshWhenOrganizerReturns);
+ }, [pendingInstallGuildId]);
+
+ function openRemoval(server: ManagedServerView) {
+ setRemoveTarget(server);
+ setConfirmation("");
+ setDialogError(null);
+ dialogRef.current?.showModal();
+ }
+
+ function closeRemoval() {
+ if (busyGuildId) return;
+ dialogRef.current?.close();
+ setRemoveTarget(null);
+ setConfirmation("");
+ setDialogError(null);
+ }
+
+ async function removeBot() {
+ if (!removeTarget || confirmation !== removeTarget.name) return;
+ setBusyGuildId(removeTarget.id);
+ setDialogError(null);
+ setStatus(`Removing PipHackLup from ${removeTarget.name}...`);
+
+ try {
+ const response = await fetch(
+ `/api/discord/guilds/${encodeURIComponent(removeTarget.id)}/bot`,
+ {
+ method: "DELETE",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ confirmGuildName: confirmation }),
+ },
+ );
+ const body = (await response.json().catch(() => null)) as {
+ error?: string;
+ warning?: string;
+ } | null;
+ if (!response.ok) {
+ throw new Error(removalErrorMessage(body?.error));
+ }
+
+ setInstalledState((current) => ({
+ ...current,
+ [removeTarget.id]: false,
+ }));
+ setStatus(
+ body?.warning === "record_update_failed"
+ ? `Discord removed PipHackLup from ${removeTarget.name}, and its saved event data was retained. PipHackLup could not update its activity record, so refresh the status before relying on the dashboard record.`
+ : `PipHackLup was removed from ${removeTarget.name}. Its saved event data was retained for a future reinstall.`,
+ );
+ dialogRef.current?.close();
+ setRemoveTarget(null);
+ setConfirmation("");
+ router.refresh();
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "PipHackLup could not be removed. Try again.";
+ setStatus(message);
+ setDialogError(message);
+ window.requestAnimationFrame(() => dialogErrorRef.current?.focus());
+ } finally {
+ setBusyGuildId(null);
+ }
+ }
+
+ if (!servers.length) {
+ return (
+
+
+ No manageable Discord servers found
+
+ Discord only returns servers you own or where your account currently
+ has Manage Server. Create a test server or ask its owner for that
+ permission, then reconnect Discord.
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
Server workspaces
+
+ Add, open, or remove PipHackLup without losing track of which server
+ you are changing.
+
+
+
{
+ setStatus("Refreshing installation status...");
+ window.location.reload();
+ }}
+ >
+
+ Refresh status
+
+
+
+
+ {status}
+
+
+
+
+ Search servers by name
+
+ setQuery(event.target.value)}
+ />
+
+
+ Status
+ setFilter(event.target.value as ServerFilter)}
+ >
+ All servers
+ Installed
+ Needs setup
+ Status unavailable
+
+
+
+
+
+ {visibleServers.map((server) => {
+ const installed = Object.hasOwn(installedState, server.id)
+ ? installedState[server.id]!
+ : server.installed;
+ return (
+
+
+ {server.iconUrl ? (
+
+ ) : (
+
+ {server.name.slice(0, 1).toUpperCase()}
+
+ )}
+
+
{server.name}
+
{server.isOwner ? "Server owner" : "Manage Server"}
+
+
+
+
+
+
+ Your organizer access was checked with Discord
+
+
+
+
+ );
+ })}
+
+
+ {!visibleServers.length ? (
+
+ No servers match those filters.
+ {
+ setQuery("");
+ setFilter("all");
+ }}
+ >
+ Clear filters
+
+
+ ) : null}
+
+ {
+ if (busyGuildId) event.preventDefault();
+ else closeRemoval();
+ }}
+ onClose={() => {
+ if (!busyGuildId) {
+ setRemoveTarget(null);
+ setConfirmation("");
+ setDialogError(null);
+ }
+ }}
+ >
+ {removeTarget ? (
+
+
+
+
+
+
+
+
+ Remove PipHackLup from {removeTarget.name}?
+
+
+ The bot will immediately lose access to this Discord server and
+ its commands will stop working. Saved hackathon data will be kept
+ so an organizer can reinstall later.
+
+ {dialogError ? (
+
+ {dialogError}
+
+ ) : null}
+
+
+ Type {removeTarget.name} to confirm
+
+ setConfirmation(event.target.value)}
+ />
+
+
+
+ Cancel
+
+
+
+ {busyGuildId ? "Removing..." : "Remove bot"}
+
+
+
+ ) : null}
+
+ >
+ );
+}
+
+function InstallationBadge({ installed }: { installed: boolean | null }) {
+ if (installed === true) {
+ return (
+
+
+ Installed
+
+ );
+ }
+ if (installed === false) {
+ return Not installed ;
+ }
+ return Unavailable ;
+}
+
+function removalErrorMessage(code: string | undefined): string {
+ switch (code) {
+ case "missing_manage_server":
+ return "Your Discord account no longer has Manage Server permission.";
+ case "guild_name_confirmation_required":
+ return "The server name confirmation did not match.";
+ case "untrusted_request_origin":
+ return "The removal request could not be verified. Refresh and try again.";
+ case "rate_limited":
+ return "Too many removal attempts. Wait a moment and try again.";
+ case "discord_bot_api_failed":
+ return "Discord did not complete the removal. Try again in a moment.";
+ default:
+ return "PipHackLup could not be removed. Refresh the page and try again.";
+ }
+}
diff --git a/apps/web/components/WorkspaceState.tsx b/apps/web/components/WorkspaceState.tsx
new file mode 100644
index 0000000..9a146b3
--- /dev/null
+++ b/apps/web/components/WorkspaceState.tsx
@@ -0,0 +1,53 @@
+import { AlertTriangle, ServerOff } from "lucide-react";
+
+export function WorkspaceState({
+ kind,
+ serverName,
+}: Readonly<{
+ kind: "no-server" | "server-unavailable" | "data-unavailable";
+ serverName?: string;
+}>) {
+ const unavailable = kind === "data-unavailable";
+ const serverUnavailable = kind === "server-unavailable";
+ const Icon = unavailable || serverUnavailable ? AlertTriangle : ServerOff;
+ return (
+
+
+
+
+ {unavailable
+ ? `We could not load ${serverName ?? "this server"}`
+ : serverUnavailable
+ ? "That server is no longer available"
+ : "No manageable server is available"}
+
+
+ {unavailable
+ ? "Your saved data was not changed. Refresh the page, and try again in a moment."
+ : serverUnavailable
+ ? "Discord no longer lists it as a server you can manage. Nothing from another server was loaded; choose a different workspace or reconnect Discord."
+ : "Create a Discord server or ask its owner for Manage Server permission, then reconnect your account."}
+
+
+
+ {unavailable
+ ? "Try again"
+ : serverUnavailable
+ ? "Choose another server"
+ : "Reconnect Discord"}
+
+
+ );
+}
diff --git a/apps/web/lib/audit-log.ts b/apps/web/lib/audit-log.ts
new file mode 100644
index 0000000..40764e3
--- /dev/null
+++ b/apps/web/lib/audit-log.ts
@@ -0,0 +1,25 @@
+import { createAuditEventInDb } from "@piphacklup/db";
+import type { AuditEvent } from "@piphacklup/core";
+
+export const activityLogUnavailableWarning = "activity_log_unavailable";
+
+type AuditInput = Omit & {
+ createdAt?: string;
+};
+
+type AuditWriter = (input: AuditInput) => Promise;
+
+export async function recordAuditAfterCommit(
+ input: AuditInput,
+ writeAudit: AuditWriter = createAuditEventInDb,
+): Promise {
+ try {
+ await writeAudit(input);
+ return null;
+ } catch {
+ console.error(
+ "PipHackLup saved a dashboard change but could not record its audit event.",
+ );
+ return activityLogUnavailableWarning;
+ }
+}
diff --git a/apps/web/lib/dashboard-security.ts b/apps/web/lib/dashboard-security.ts
index 9740af1..757694a 100644
--- a/apps/web/lib/dashboard-security.ts
+++ b/apps/web/lib/dashboard-security.ts
@@ -1,15 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { isDatabaseConfigured } from "@piphacklup/db";
import {
+ DiscordAuthUnavailableError,
findManagedGuild,
readDiscordSession,
type DiscordSession,
type ManagedDiscordGuild,
} from "@/lib/discord-auth";
import {
- buildRateLimitKey,
+ buildPreAuthRateLimitKey,
enforceRateLimit,
- getClientIp,
type RateLimitPolicy,
} from "@/lib/rate-limit";
@@ -27,21 +27,32 @@ export async function requireOrganizerGuildAccess(
action: string;
rateLimit: RateLimitPolicy;
requireDatabase?: boolean;
+ guildId?: string;
},
): Promise {
- const guildId = request.nextUrl.searchParams.get("guildId");
- const session = await readDiscordSession();
- const rateLimitResponse = enforceRateLimit(request, {
- key: buildRateLimitKey([
- "web",
- options.action,
- session?.user.id ?? `ip-${getClientIp(request)}`,
- guildId ?? "no-guild",
- ]),
+ const guildId =
+ options.guildId ?? request.nextUrl.searchParams.get("guildId") ?? undefined;
+ const rateLimitResponse = await enforceRateLimit(request, {
+ // This bucket runs before authentication, so its cardinality must depend
+ // only on trusted request context. Never let an attacker create one
+ // persistent database row per arbitrary guildId.
+ key: buildPreAuthRateLimitKey(request, options.action),
policy: options.rateLimit,
});
if (rateLimitResponse) return rateLimitResponse;
+ let session: DiscordSession | null;
+ try {
+ session = await readDiscordSession();
+ } catch (error) {
+ if (error instanceof DiscordAuthUnavailableError) {
+ return NextResponse.json(
+ { error: "discord_session_unavailable" },
+ { status: 503 },
+ );
+ }
+ throw error;
+ }
if (options.requireDatabase !== false && !isDatabaseConfigured()) {
return NextResponse.json(
{ error: "database_not_configured" },
diff --git a/apps/web/lib/demo-data.ts b/apps/web/lib/demo-data.ts
deleted file mode 100644
index b11041e..0000000
--- a/apps/web/lib/demo-data.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import {
- createModerationCase,
- createQueueTicket,
- createTeam,
- type MemberProfile,
- type QueueTicket,
- type TeamProfile
-} from "@piphacklup/core";
-
-const now = "2026-06-06T15:00:00.000Z";
-const guildId = "demo-guild";
-
-export const demoMembers: MemberProfile[] = [
- {
- userId: "1001",
- displayName: "Ava",
- skills: ["frontend", "design"],
- interests: ["climate", "education"],
- timezone: "ET",
- beginnerFriendly: true,
- lookingForTeam: true,
- updatedAt: now
- },
- {
- userId: "1002",
- displayName: "Noah",
- skills: ["backend", "python"],
- interests: ["ai", "health"],
- timezone: "PT",
- beginnerFriendly: true,
- lookingForTeam: true,
- updatedAt: now
- },
- {
- userId: "1003",
- displayName: "Mina",
- skills: ["pitch", "product"],
- interests: ["accessibility"],
- timezone: "ET",
- beginnerFriendly: false,
- lookingForTeam: false,
- updatedAt: now
- }
-];
-
-export const demoTeams: TeamProfile[] = [
- createTeam({
- guildId,
- owner: demoMembers[2]!,
- name: "Iceberg Labs",
- desiredSkills: ["frontend", "ai"],
- projectIdea: "AI helper for first-time hackers",
- now
- })
-];
-
-export const demoTickets: QueueTicket[] = [
- createQueueTicket({
- guildId,
- kind: "mentor",
- requesterId: "1001",
- topic: "Scope check",
- description: "Need help cutting the project down for demo time.",
- priority: 2,
- now
- }),
- createQueueTicket({
- guildId,
- kind: "tech",
- requesterId: "1002",
- topic: "Vercel deploy",
- description: "Build passes locally but fails in CI.",
- priority: 3,
- now: "2026-06-06T15:03:00.000Z"
- }),
- createQueueTicket({
- guildId,
- kind: "judging",
- requesterId: "1003",
- topic: "Demo room",
- description: "Ready for practice judging.",
- priority: 1,
- now: "2026-06-06T15:07:00.000Z"
- })
-];
-
-export const demoCases = [
- createModerationCase({
- guildId,
- targetUserId: "2001",
- action: "report",
- reason: "Suspicious invite link in general chat",
- reporterId: "1002",
- evidenceMessageUrl: "https://discord.com/channels/demo/1/2",
- now
- })
-];
-
-export const setupSteps = [
- { label: "Create Discord application", status: "done", detail: "Bot app owned by your Discord account." },
- { label: "Enable Guild Members intent", status: "todo", detail: "Required for joins, roles, and onboarding." },
- { label: "Invite bot to test server", status: "todo", detail: "Use bot + applications.commands scopes." },
- { label: "Run /setup", status: "todo", detail: "Creates event defaults and previews AutoMod rules." },
- { label: "Deploy dashboard", status: "todo", detail: "Vercel Hobby can host the organizer surface." }
-] as const;
diff --git a/apps/web/lib/discord-auth.ts b/apps/web/lib/discord-auth.ts
index 4c7a8c5..427b183 100644
--- a/apps/web/lib/discord-auth.ts
+++ b/apps/web/lib/discord-auth.ts
@@ -1,11 +1,55 @@
-import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
+import {
+ createCipheriv,
+ createDecipheriv,
+ createHash,
+ hkdfSync,
+ randomBytes,
+ timingSafeEqual,
+} from "node:crypto";
+import {
+ acquireDiscordAccountTokenRefreshLease,
+ completeDiscordAccountTokenRefresh,
+ createDiscordAuthSession,
+ getActiveDiscordAuthSession,
+ getDiscordAccount,
+ releaseDiscordAccountTokenRefreshLease,
+ revokeDiscordAuthSession,
+ revokeDiscordAuthSessionsForAccountIfTokenVersion,
+ touchDiscordAuthSession,
+ updateDiscordAccountIdentity,
+ upsertDiscordAccount,
+ type DiscordAccountRecord,
+ type DiscordAuthSessionWithAccount,
+} from "@piphacklup/db";
import { cookies } from "next/headers";
const SESSION_COOKIE = "piphacklup_discord_session";
+const PENDING_REVOCATIONS_COOKIE = "piphacklup_pending_session_revocations";
const STATE_COOKIE = "piphacklup_oauth_state";
const DISCORD_API = "https://discord.com/api/v10";
+const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
const ADMINISTRATOR = 1n << 3n;
const MANAGE_GUILD = 1n << 5n;
+const SESSION_DURATION_MS = 12 * 60 * 60 * 1_000;
+const MAX_PENDING_SESSION_REVOCATIONS = 48;
+const TOKEN_REFRESH_SKEW_MS = 60_000;
+const TOKEN_REFRESH_LEASE_MS = 60_000;
+const TOKEN_REFRESH_WAIT_TIMEOUT_MS = 12_000;
+const TOKEN_REFRESH_POLL_MS = 100;
+const OAUTH_STATE_TTL_SECONDS = 10 * 60;
+const ENCRYPTION_VERSION = "v1";
+const ENCRYPTION_ALGORITHM = "aes-256-gcm";
+const ENCRYPTION_IV_BYTES = 12;
+const ENCRYPTION_TAG_BYTES = 16;
+const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
+const OAUTH_STATE_PATTERN = /^[A-Za-z0-9_-]{32}$/;
+const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
+const SESSION_HASH_PATTERN = /^[a-f0-9]{64}$/;
+const SNOWFLAKE_PATTERN = /^\d{17,20}$/;
+const PERMISSIONS_PATTERN = /^\d{1,32}$/;
+const REFRESH_LEASE_PATTERN = /^[A-Za-z0-9_-]{24}$/;
+
+type DiscordTokenKind = "access" | "refresh";
export interface ManagedDiscordGuild {
id: string;
@@ -27,49 +71,166 @@ export interface DiscordSession {
issuedAt: number;
}
-interface DiscordUserResponse {
+export interface CreatedDiscordSession {
+ expiresAt: Date;
+ sessionToken: string;
+}
+
+export interface DiscordSessionCookieStore {
+ get(name: string): { value: string } | undefined;
+ set(
+ name: string,
+ value: string,
+ options: {
+ httpOnly: boolean;
+ sameSite: "lax" | "strict";
+ secure: boolean;
+ expires: Date;
+ path: string;
+ },
+ ): void;
+ delete(name: string): void;
+}
+
+export interface DiscordSessionCookieDependencies {
+ getCookieStore: () => Promise;
+ revokeSession: (tokenHash: string) => Promise;
+ hasSessionStore: () => boolean;
+ appUrl: () => string;
+ now: () => number;
+}
+
+export interface DiscordLogoutResult {
+ revocationPending: boolean;
+}
+
+interface ParsedDiscordUser {
id: string;
username: string;
- global_name?: string | null;
- avatar?: string | null;
+ globalName: string | null;
+ avatarHash: string | null;
}
-interface DiscordGuildResponse {
- id: string;
- name: string;
- icon?: string | null;
- owner?: boolean;
- permissions: string;
+export interface ParsedDiscordToken {
+ accessToken: string;
+ expiresAt: Date;
+ refreshToken: string;
}
-export function isDiscordAuthConfigured(): boolean {
+export interface DiscordTokenRefreshDependencies {
+ acquireLease: typeof acquireDiscordAccountTokenRefreshLease;
+ completeRefresh: typeof completeDiscordAccountTokenRefresh;
+ createLeaseId: () => string;
+ exchangeRefreshToken: (refreshToken: string) => Promise;
+ getAccount: typeof getDiscordAccount;
+ now: () => number;
+ releaseLease: typeof releaseDiscordAccountTokenRefreshLease;
+ wait: (milliseconds: number) => Promise;
+}
+
+class DiscordAuthorizationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "DiscordAuthorizationError";
+ }
+}
+
+export class DiscordAuthUnavailableError extends Error {
+ constructor(message = "Discord authentication is temporarily unavailable.") {
+ super(message);
+ this.name = "DiscordAuthUnavailableError";
+ }
+}
+
+class DiscordApiError extends Error {
+ constructor(
+ readonly status: number,
+ operation: string,
+ ) {
+ super(`Discord API request failed during ${operation}.`);
+ this.name = "DiscordApiError";
+ }
+}
+
+export function hasDiscordAuthConfiguration(
+ env: Readonly>,
+): boolean {
return Boolean(
- process.env.DISCORD_CLIENT_ID &&
- process.env.DISCORD_CLIENT_SECRET &&
- process.env.NEXTAUTH_SECRET,
+ env.DISCORD_CLIENT_ID &&
+ env.DISCORD_CLIENT_SECRET &&
+ env.NEXTAUTH_SECRET &&
+ Buffer.byteLength(env.NEXTAUTH_SECRET, "utf8") >= 32 &&
+ hasDiscordSessionStoreConfiguration(env),
);
}
-export function getDiscordAuthorizeUrl(state: string): string {
- const clientId = process.env.DISCORD_CLIENT_ID;
- if (!clientId) throw new Error("DISCORD_CLIENT_ID is required.");
+export function hasDiscordSessionStoreConfiguration(
+ env: Readonly>,
+): boolean {
+ const databaseUrl = env.DATABASE_URL;
+ return Boolean(
+ databaseUrl &&
+ !databaseUrl.includes("user:password@host") &&
+ !databaseUrl.includes("example.com"),
+ );
+}
+export function isDiscordAuthConfigured(): boolean {
+ return hasDiscordAuthConfiguration(process.env);
+}
+
+export function buildDiscordAuthorizeUrl(input: {
+ clientId: string;
+ redirectUri: string;
+ state: string;
+}): string {
const params = new URLSearchParams({
- client_id: clientId,
- redirect_uri: getDiscordRedirectUri(),
+ client_id: input.clientId,
+ redirect_uri: input.redirectUri,
response_type: "code",
scope: "identify guilds",
- state,
+ state: input.state,
});
return `https://discord.com/oauth2/authorize?${params.toString()}`;
}
+export function buildDiscordTokenRequest(
+ input:
+ | { code: string; grantType: "authorization_code"; redirectUri: string }
+ | { grantType: "refresh_token"; refreshToken: string },
+ client: { clientId: string; clientSecret: string },
+): URLSearchParams {
+ const body = new URLSearchParams({
+ client_id: client.clientId,
+ client_secret: client.clientSecret,
+ grant_type: input.grantType,
+ });
+ if (input.grantType === "authorization_code") {
+ body.set("code", input.code);
+ body.set("redirect_uri", input.redirectUri);
+ } else {
+ body.set("refresh_token", input.refreshToken);
+ }
+ return body;
+}
+
+export function getDiscordAuthorizeUrl(state: string): string {
+ const clientId = process.env.DISCORD_CLIENT_ID;
+ if (!clientId) throw new Error("DISCORD_CLIENT_ID is required.");
+ return buildDiscordAuthorizeUrl({
+ clientId,
+ redirectUri: getDiscordRedirectUri(),
+ state,
+ });
+}
+
export function getDiscordRedirectUri(): string {
return `${getAppUrl()}/api/auth/discord/callback`;
}
export function getAppUrl(): string {
- if (process.env.NEXTAUTH_URL) return process.env.NEXTAUTH_URL;
+ const configured = process.env.NEXTAUTH_URL?.replace(/\/+$/, "");
+ if (configured) return configured;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "http://localhost:3000";
}
@@ -78,13 +239,29 @@ export function createOauthState(): string {
return randomBytes(24).toString("base64url");
}
+export function oauthStatesMatch(
+ stored: string | null | undefined,
+ received: string | null | undefined,
+): boolean {
+ if (
+ !stored ||
+ !received ||
+ !OAUTH_STATE_PATTERN.test(stored) ||
+ !OAUTH_STATE_PATTERN.test(received)
+ )
+ return false;
+ const left = Buffer.from(stored, "utf8");
+ const right = Buffer.from(received, "utf8");
+ return timingSafeEqual(left, right);
+}
+
export async function setOauthStateCookie(state: string): Promise {
const cookieStore = await cookies();
cookieStore.set(STATE_COOKIE, state, {
httpOnly: true,
sameSite: "lax",
secure: getAppUrl().startsWith("https://"),
- maxAge: 60 * 10,
+ maxAge: OAUTH_STATE_TTL_SECONDS,
path: "/",
});
}
@@ -95,97 +272,536 @@ export async function consumeOauthStateCookie(
const cookieStore = await cookies();
const stored = cookieStore.get(STATE_COOKIE)?.value;
cookieStore.delete(STATE_COOKIE);
- return Boolean(stored && state && stored === state);
+ return oauthStatesMatch(stored, state);
}
-export async function createDiscordSessionFromCode(
- code: string,
-): Promise {
- const clientId = process.env.DISCORD_CLIENT_ID;
- const clientSecret = process.env.DISCORD_CLIENT_SECRET;
- if (!clientId || !clientSecret) {
- throw new Error("Discord OAuth is not configured.");
+export function hashSessionToken(sessionToken: string): string {
+ return createHash("sha256").update(sessionToken, "utf8").digest("hex");
+}
+
+export function createSessionToken(): string {
+ return randomBytes(32).toString("base64url");
+}
+
+export function shouldRefreshDiscordToken(
+ expiresAt: Date,
+ nowMs: number = Date.now(),
+): boolean {
+ return (
+ !isValidDate(expiresAt) ||
+ expiresAt.getTime() <= nowMs + TOKEN_REFRESH_SKEW_MS
+ );
+}
+
+export function encryptDiscordToken(
+ plaintext: string,
+ secret: string,
+ context: string,
+): string {
+ if (!plaintext) throw new Error("Cannot encrypt an empty Discord token.");
+ const iv = randomBytes(ENCRYPTION_IV_BYTES);
+ const cipher = createCipheriv(
+ ENCRYPTION_ALGORITHM,
+ deriveEncryptionKey(secret),
+ iv,
+ { authTagLength: ENCRYPTION_TAG_BYTES },
+ );
+ cipher.setAAD(Buffer.from(context, "utf8"));
+ const ciphertext = Buffer.concat([
+ cipher.update(plaintext, "utf8"),
+ cipher.final(),
+ ]);
+ const tag = cipher.getAuthTag();
+ return [
+ ENCRYPTION_VERSION,
+ iv.toString("base64url"),
+ ciphertext.toString("base64url"),
+ tag.toString("base64url"),
+ ].join(".");
+}
+
+export function decryptDiscordToken(
+ encrypted: string,
+ secret: string,
+ context: string,
+): string {
+ const [version, ivEncoded, ciphertextEncoded, tagEncoded, extra] =
+ encrypted.split(".");
+ if (
+ version !== ENCRYPTION_VERSION ||
+ !ivEncoded ||
+ !ciphertextEncoded ||
+ !tagEncoded ||
+ !BASE64URL_PATTERN.test(ivEncoded) ||
+ !BASE64URL_PATTERN.test(ciphertextEncoded) ||
+ !BASE64URL_PATTERN.test(tagEncoded) ||
+ extra
+ ) {
+ throw new Error("Encrypted Discord token has an invalid format.");
}
- const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, {
- method: "POST",
- headers: { "content-type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- client_id: clientId,
- client_secret: clientSecret,
- grant_type: "authorization_code",
- code,
- redirect_uri: getDiscordRedirectUri(),
- }),
- });
+ const iv = Buffer.from(ivEncoded, "base64url");
+ const ciphertext = Buffer.from(ciphertextEncoded, "base64url");
+ const tag = Buffer.from(tagEncoded, "base64url");
+ if (
+ iv.length !== ENCRYPTION_IV_BYTES ||
+ tag.length !== ENCRYPTION_TAG_BYTES ||
+ ciphertext.length === 0
+ ) {
+ throw new Error("Encrypted Discord token has invalid components.");
+ }
- if (!tokenResponse.ok) {
- throw new Error("Discord rejected the OAuth code.");
+ try {
+ const decipher = createDecipheriv(
+ ENCRYPTION_ALGORITHM,
+ deriveEncryptionKey(secret),
+ iv,
+ { authTagLength: ENCRYPTION_TAG_BYTES },
+ );
+ decipher.setAAD(Buffer.from(context, "utf8"));
+ decipher.setAuthTag(tag);
+ return Buffer.concat([
+ decipher.update(ciphertext),
+ decipher.final(),
+ ]).toString("utf8");
+ } catch {
+ throw new Error("Encrypted Discord token authentication failed.");
}
+}
- const token = (await tokenResponse.json()) as { access_token: string };
- const [user, guilds] = await Promise.all([
- fetchDiscord("/users/@me", token.access_token),
- fetchDiscord(
- "/users/@me/guilds",
- token.access_token,
- ),
- ]);
+export function canManageGuild(permissions: string, isOwner: boolean): boolean {
+ if (isOwner) return true;
+ if (!PERMISSIONS_PATTERN.test(permissions)) return false;
+ try {
+ const granted = BigInt(permissions);
+ return (
+ (granted & ADMINISTRATOR) === ADMINISTRATOR ||
+ (granted & MANAGE_GUILD) === MANAGE_GUILD
+ );
+ } catch {
+ return false;
+ }
+}
- return {
- user: {
- id: user.id,
- username: user.username,
- ...(user.global_name ? { globalName: user.global_name } : {}),
- ...(user.avatar
+export function parseManagedDiscordGuilds(
+ value: unknown,
+): ManagedDiscordGuild[] | null {
+ if (!Array.isArray(value) || value.length > 200) return null;
+
+ const guilds: ManagedDiscordGuild[] = [];
+ for (const item of value) {
+ if (!isRecord(item)) return null;
+ const id = parseSnowflake(item.id);
+ const name = parseBoundedString(item.name, 1, 100);
+ const permissions = parseDecimalString(item.permissions);
+ const owner = item.owner === undefined ? false : item.owner;
+ const icon = parseOptionalNullableString(item.icon, 256);
+ if (
+ !id ||
+ !name ||
+ !permissions ||
+ typeof owner !== "boolean" ||
+ icon === undefined
+ ) {
+ return null;
+ }
+
+ const manageable = canManageGuild(permissions, owner);
+ if (!manageable) continue;
+ guilds.push({
+ id,
+ name,
+ ...(icon
? {
- avatarUrl: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`,
+ iconUrl: `https://cdn.discordapp.com/icons/${id}/${encodeURIComponent(icon)}.png?size=128`,
}
: {}),
- },
- guilds: guilds
- .map((guild) => ({
- id: guild.id,
- name: guild.name,
- ...(guild.icon
- ? {
- iconUrl: `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`,
- }
- : {}),
- isOwner: guild.owner === true,
- permissions: guild.permissions,
- canManage: canManageGuild(guild.permissions, guild.owner === true),
- }))
- .filter((guild) => guild.canManage)
- .slice(0, 25),
- issuedAt: Date.now(),
+ isOwner: owner,
+ permissions,
+ canManage: true,
+ });
+ }
+ return guilds;
+}
+
+export function parseStoredDiscordAuthSession(
+ value: unknown,
+ now: Date = new Date(),
+ expectedTokenHash?: string,
+): DiscordAuthSessionWithAccount | null {
+ if (!isRecord(value) || !isRecord(value.account) || !isRecord(value.session))
+ return null;
+ const account = parseStoredDiscordAccount(value.account);
+ const session = value.session;
+ if (
+ !account ||
+ !("revokedAt" in session) ||
+ !("lastSeenAt" in session) ||
+ typeof session.tokenHash !== "string" ||
+ !SESSION_HASH_PATTERN.test(session.tokenHash) ||
+ (expectedTokenHash !== undefined &&
+ session.tokenHash !== expectedTokenHash) ||
+ session.discordUserId !== account.discordUserId ||
+ !isValidDate(session.expiresAt) ||
+ session.expiresAt <= now ||
+ session.revokedAt !== null ||
+ (session.lastSeenAt !== null && !isValidDate(session.lastSeenAt)) ||
+ !isValidDate(session.createdAt) ||
+ !isValidDate(session.updatedAt)
+ ) {
+ return null;
+ }
+ return {
+ account,
+ session: session as unknown as DiscordAuthSessionWithAccount["session"],
};
}
+export function isPostOriginAllowed(
+ origin: string | null,
+ appUrl: string,
+): boolean {
+ if (!origin) return false;
+ try {
+ return new URL(origin).origin === new URL(appUrl).origin;
+ } catch {
+ return false;
+ }
+}
+
+export async function createDiscordSessionFromCode(
+ code: string,
+): Promise {
+ if (!isDiscordAuthConfigured()) {
+ throw new Error("Discord OAuth is not configured.");
+ }
+ if (!code || code.length > 1_024) {
+ throw new Error("Discord OAuth code is invalid.");
+ }
+
+ const token = await exchangeDiscordToken({
+ code,
+ grantType: "authorization_code",
+ });
+ const { user } = await fetchDiscordIdentity(token.accessToken);
+ const secret = getSessionSecret();
+ const avatarUrl = buildDiscordAvatarUrl(user);
+ await runSessionStoreOperation(() =>
+ upsertDiscordAccount({
+ discordUserId: user.id,
+ username: user.username,
+ globalName: user.globalName,
+ avatarUrl,
+ accessTokenEncrypted: encryptDiscordToken(
+ token.accessToken,
+ secret,
+ tokenEncryptionContext(user.id, "access"),
+ ),
+ refreshTokenEncrypted: encryptDiscordToken(
+ token.refreshToken,
+ secret,
+ tokenEncryptionContext(user.id, "refresh"),
+ ),
+ tokenExpiresAt: token.expiresAt,
+ }),
+ );
+
+ const sessionToken = createSessionToken();
+ const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
+ await runSessionStoreOperation(() =>
+ createDiscordAuthSession({
+ tokenHash: hashSessionToken(sessionToken),
+ discordUserId: user.id,
+ expiresAt,
+ }),
+ );
+ return { expiresAt, sessionToken };
+}
+
export async function setDiscordSessionCookie(
- session: DiscordSession,
+ created: CreatedDiscordSession,
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
): Promise {
- const cookieStore = await cookies();
- cookieStore.set(SESSION_COOKIE, signSession(session), {
+ const cookieStore = await dependencies.getCookieStore();
+ const displacedSessionToken = cookieStore.get(SESSION_COOKIE)?.value;
+ if (
+ displacedSessionToken &&
+ SESSION_TOKEN_PATTERN.test(displacedSessionToken) &&
+ displacedSessionToken !== created.sessionToken
+ ) {
+ if (!dependencies.hasSessionStore()) {
+ queuePendingSessionRevocation(
+ cookieStore,
+ hashSessionToken(created.sessionToken),
+ dependencies,
+ );
+ throw new DiscordAuthUnavailableError(
+ "Discord session storage is not configured.",
+ );
+ }
+ try {
+ await runSessionStoreOperation(() =>
+ dependencies.revokeSession(hashSessionToken(displacedSessionToken)),
+ );
+ } catch (error) {
+ queuePendingSessionRevocation(
+ cookieStore,
+ hashSessionToken(created.sessionToken),
+ dependencies,
+ );
+ throw error;
+ }
+ }
+ cookieStore.set(SESSION_COOKIE, created.sessionToken, {
httpOnly: true,
sameSite: "lax",
- secure: getAppUrl().startsWith("https://"),
- maxAge: 60 * 60 * 24 * 14,
+ secure: dependencies.appUrl().startsWith("https://"),
+ expires: created.expiresAt,
path: "/",
});
}
-export async function clearDiscordSessionCookie(): Promise {
- const cookieStore = await cookies();
+export async function clearDiscordSessionCookie(
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
+): Promise {
+ const cookieStore = await dependencies.getCookieStore();
+ const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
cookieStore.delete(SESSION_COOKIE);
+ if (!sessionToken || !SESSION_TOKEN_PATTERN.test(sessionToken)) {
+ return {
+ revocationPending:
+ parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ ).length > 0,
+ };
+ }
+ const tokenHash = hashSessionToken(sessionToken);
+ if (!dependencies.hasSessionStore()) {
+ queuePendingSessionRevocation(cookieStore, tokenHash, dependencies);
+ console.error(
+ "PipHackLup cleared the browser session but could not revoke it in storage.",
+ );
+ return { revocationPending: true };
+ }
+ try {
+ await runSessionStoreOperation(() => dependencies.revokeSession(tokenHash));
+ const remaining = removePendingSessionRevocation(
+ cookieStore,
+ tokenHash,
+ dependencies,
+ );
+ return { revocationPending: remaining > 0 };
+ } catch {
+ queuePendingSessionRevocation(cookieStore, tokenHash, dependencies);
+ console.error(
+ "PipHackLup cleared the browser session but could not revoke it in storage.",
+ );
+ return { revocationPending: true };
+ }
+}
+
+export async function retryPendingDiscordSessionRevocations(
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
+): Promise {
+ const cookieStore = await dependencies.getCookieStore();
+ const pending = parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ );
+ if (!pending.length) return 0;
+ if (!dependencies.hasSessionStore()) return pending.length;
+
+ const remaining: string[] = [];
+ for (const tokenHash of pending) {
+ try {
+ await runSessionStoreOperation(() =>
+ dependencies.revokeSession(tokenHash),
+ );
+ } catch {
+ remaining.push(tokenHash);
+ }
+ }
+ writePendingSessionRevocations(cookieStore, remaining, dependencies);
+ if (remaining.length) {
+ console.error(
+ "PipHackLup could not finish one or more pending session revocations.",
+ );
+ }
+ return remaining.length;
+}
+
+export async function hasPendingDiscordSessionRevocations(
+ dependencies: DiscordSessionCookieDependencies = sessionCookieDependencies(),
+): Promise {
+ const cookieStore = await dependencies.getCookieStore();
+ return (
+ parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ ).length > 0
+ );
+}
+
+function sessionCookieDependencies(): DiscordSessionCookieDependencies {
+ return {
+ getCookieStore: async () =>
+ (await cookies()) as unknown as DiscordSessionCookieStore,
+ revokeSession: revokeDiscordAuthSession,
+ hasSessionStore: () => hasDiscordSessionStoreConfiguration(process.env),
+ appUrl: getAppUrl,
+ now: Date.now,
+ };
+}
+
+function queuePendingSessionRevocation(
+ cookieStore: DiscordSessionCookieStore,
+ tokenHash: string,
+ dependencies: DiscordSessionCookieDependencies,
+): void {
+ const pending = parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ );
+ const combined = [...new Set([...pending, tokenHash])];
+ if (combined.length > MAX_PENDING_SESSION_REVOCATIONS) {
+ console.error(
+ "PipHackLup reached the browser limit for pending session revocations.",
+ );
+ }
+ writePendingSessionRevocations(
+ cookieStore,
+ combined.slice(-MAX_PENDING_SESSION_REVOCATIONS),
+ dependencies,
+ );
+}
+
+function removePendingSessionRevocation(
+ cookieStore: DiscordSessionCookieStore,
+ tokenHash: string,
+ dependencies: DiscordSessionCookieDependencies,
+): number {
+ const pending = parsePendingSessionRevocations(
+ cookieStore.get(PENDING_REVOCATIONS_COOKIE)?.value,
+ ).filter((candidate) => candidate !== tokenHash);
+ writePendingSessionRevocations(cookieStore, pending, dependencies);
+ return pending.length;
+}
+
+function writePendingSessionRevocations(
+ cookieStore: DiscordSessionCookieStore,
+ tokenHashes: string[],
+ dependencies: DiscordSessionCookieDependencies,
+): void {
+ if (!tokenHashes.length) {
+ cookieStore.delete(PENDING_REVOCATIONS_COOKIE);
+ return;
+ }
+ cookieStore.set(PENDING_REVOCATIONS_COOKIE, tokenHashes.join("."), {
+ httpOnly: true,
+ sameSite: "strict",
+ secure: dependencies.appUrl().startsWith("https://"),
+ expires: new Date(dependencies.now() + SESSION_DURATION_MS),
+ path: "/",
+ });
+}
+
+function parsePendingSessionRevocations(value: string | undefined): string[] {
+ if (!value || value.length > MAX_PENDING_SESSION_REVOCATIONS * (64 + 1))
+ return [];
+ const hashes = value.split(".");
+ if (!hashes.length || hashes.length > MAX_PENDING_SESSION_REVOCATIONS)
+ return [];
+ return hashes.every((hash) => SESSION_HASH_PATTERN.test(hash)) ? hashes : [];
}
export async function readDiscordSession(): Promise {
- if (!process.env.NEXTAUTH_SECRET) return null;
+ if (!isDiscordAuthConfigured()) return null;
const cookieStore = await cookies();
- const signed = cookieStore.get(SESSION_COOKIE)?.value;
- if (!signed) return null;
- return verifySession(signed);
+ const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
+ if (!sessionToken || !SESSION_TOKEN_PATTERN.test(sessionToken)) return null;
+ const tokenHash = hashSessionToken(sessionToken);
+ const now = new Date();
+
+ const stored = await runSessionStoreOperation(() =>
+ getActiveDiscordAuthSession(tokenHash, now),
+ );
+ if (!stored) return null;
+ const loaded = parseStoredDiscordAuthSession(stored, now, tokenHash);
+ if (!loaded) {
+ throw new DiscordAuthUnavailableError(
+ "Stored Discord authentication data is invalid.",
+ );
+ }
+
+ let authorizationTokenVersion = loaded.account.tokenVersion;
+ try {
+ let authorized = await getAuthorizedAccessToken(loaded.account, false);
+ authorizationTokenVersion = authorized.account.tokenVersion;
+ let identity;
+ try {
+ identity = await fetchDiscordIdentity(authorized.accessToken);
+ } catch (error) {
+ if (
+ !(error instanceof DiscordApiError) ||
+ (error.status !== 401 && error.status !== 403)
+ )
+ throw error;
+ authorized = await getAuthorizedAccessToken(authorized.account, true);
+ authorizationTokenVersion = authorized.account.tokenVersion;
+ identity = await fetchDiscordIdentity(authorized.accessToken);
+ }
+
+ if (identity.user.id !== loaded.account.discordUserId) {
+ throw new DiscordAuthorizationError("Discord account identity changed.");
+ }
+
+ const avatarUrl = buildDiscordAvatarUrl(identity.user);
+ const [, sessionStillActive] = await Promise.all([
+ runSessionStoreOperation(() =>
+ updateDiscordAccountIdentity({
+ discordUserId: identity.user.id,
+ username: identity.user.username,
+ globalName: identity.user.globalName,
+ avatarUrl,
+ }),
+ ),
+ runSessionStoreOperation(() => touchDiscordAuthSession(tokenHash)),
+ ]);
+ if (!sessionStillActive) return null;
+
+ return {
+ user: {
+ id: identity.user.id,
+ username: identity.user.username,
+ ...(identity.user.globalName
+ ? { globalName: identity.user.globalName }
+ : {}),
+ ...(avatarUrl ? { avatarUrl } : {}),
+ },
+ guilds: identity.guilds,
+ issuedAt: loaded.session.createdAt.getTime(),
+ };
+ } catch (error) {
+ if (
+ error instanceof DiscordAuthorizationError ||
+ (error instanceof DiscordApiError &&
+ (error.status === 401 || error.status === 403))
+ ) {
+ const revoked = await runSessionStoreOperation(() =>
+ revokeDiscordAuthSessionsForAccountIfTokenVersion(
+ loaded.account.discordUserId,
+ authorizationTokenVersion,
+ ),
+ );
+ if (revoked) return null;
+
+ const stillActive = await runSessionStoreOperation(() =>
+ getActiveDiscordAuthSession(tokenHash),
+ );
+ if (!stillActive) return null;
+ throw new DiscordAuthUnavailableError(
+ "Discord credentials changed while authorization was checked.",
+ );
+ }
+ throw error;
+ }
}
export function findManagedGuild(
@@ -195,55 +811,509 @@ export function findManagedGuild(
return session.guilds.find((guild) => guild.id === guildId) ?? null;
}
-async function fetchDiscord(path: string, accessToken: string): Promise {
- const response = await fetch(`${DISCORD_API}${path}`, {
- headers: { authorization: `Bearer ${accessToken}` },
- });
- if (!response.ok) throw new Error(`Discord API failed for ${path}.`);
- return (await response.json()) as T;
+async function getAuthorizedAccessToken(
+ account: DiscordAccountRecord,
+ forceRefresh: boolean,
+): Promise<{ account: DiscordAccountRecord; accessToken: string }> {
+ const secret = getSessionSecret();
+ if (!forceRefresh && !shouldRefreshDiscordToken(account.tokenExpiresAt)) {
+ return { account, accessToken: decryptStoredAccessToken(account, secret) };
+ }
+
+ return refreshDiscordAccessTokenWithLease(account, secret);
}
-function canManageGuild(permissions: string, isOwner: boolean): boolean {
- if (isOwner) return true;
- const granted = BigInt(permissions);
- return (
- (granted & ADMINISTRATOR) === ADMINISTRATOR ||
- (granted & MANAGE_GUILD) === MANAGE_GUILD
+export async function refreshDiscordAccessTokenWithLease(
+ initialAccount: DiscordAccountRecord,
+ secret: string,
+ dependencies: DiscordTokenRefreshDependencies = createDiscordTokenRefreshDependencies(),
+): Promise<{ account: DiscordAccountRecord; accessToken: string }> {
+ let account = requireStoredDiscordAccount(initialAccount);
+ const deadline = dependencies.now() + TOKEN_REFRESH_WAIT_TIMEOUT_MS;
+
+ for (let attempt = 0; attempt < 125; attempt += 1) {
+ const nowMs = dependencies.now();
+ if (nowMs > deadline) break;
+
+ const leaseId = dependencies.createLeaseId();
+ if (!REFRESH_LEASE_PATTERN.test(leaseId)) {
+ throw new DiscordAuthUnavailableError(
+ "Discord token refresh coordination is unavailable.",
+ );
+ }
+
+ const leasedValue = await runSessionStoreOperation(() =>
+ dependencies.acquireLease({
+ discordUserId: account.discordUserId,
+ expectedTokenVersion: account.tokenVersion,
+ leaseId,
+ leaseExpiresAt: new Date(nowMs + TOKEN_REFRESH_LEASE_MS),
+ now: new Date(nowMs),
+ }),
+ );
+ if (leasedValue) {
+ const leased = requireStoredDiscordAccount(leasedValue);
+ if (
+ leased.tokenVersion !== account.tokenVersion ||
+ leased.tokenRefreshLeaseId !== leaseId
+ ) {
+ throw new DiscordAuthUnavailableError(
+ "Discord token refresh coordination is invalid.",
+ );
+ }
+
+ try {
+ const refreshToken = decryptStoredRefreshToken(leased, secret);
+ const token = await dependencies.exchangeRefreshToken(refreshToken);
+ const completedValue = await runSessionStoreOperation(() =>
+ dependencies.completeRefresh({
+ discordUserId: leased.discordUserId,
+ expectedTokenVersion: leased.tokenVersion,
+ leaseId,
+ accessTokenEncrypted: encryptDiscordToken(
+ token.accessToken,
+ secret,
+ tokenEncryptionContext(leased.discordUserId, "access"),
+ ),
+ refreshTokenEncrypted: encryptDiscordToken(
+ token.refreshToken,
+ secret,
+ tokenEncryptionContext(leased.discordUserId, "refresh"),
+ ),
+ tokenExpiresAt: token.expiresAt,
+ }),
+ );
+ if (completedValue) {
+ return {
+ account: requireStoredDiscordAccount(completedValue),
+ accessToken: token.accessToken,
+ };
+ }
+
+ const current = await loadDiscordAccountForRefresh(
+ leased.discordUserId,
+ dependencies,
+ );
+ const recovered = recoverNewerDiscordAccessToken(
+ leased,
+ current,
+ secret,
+ dependencies.now(),
+ );
+ if (recovered) return recovered;
+ throw new DiscordAuthUnavailableError(
+ "Discord token refresh could not be committed.",
+ );
+ } catch (error) {
+ await runSessionStoreOperation(() =>
+ dependencies.releaseLease({
+ discordUserId: leased.discordUserId,
+ expectedTokenVersion: leased.tokenVersion,
+ leaseId,
+ }),
+ );
+
+ if (error instanceof DiscordAuthorizationError) {
+ const current = await loadDiscordAccountForRefresh(
+ leased.discordUserId,
+ dependencies,
+ );
+ const recovered = recoverNewerDiscordAccessToken(
+ leased,
+ current,
+ secret,
+ dependencies.now(),
+ );
+ if (recovered) return recovered;
+ }
+ throw error;
+ }
+ }
+
+ const current = await loadDiscordAccountForRefresh(
+ account.discordUserId,
+ dependencies,
+ );
+ if (!current) {
+ throw new DiscordAuthorizationError("Discord account no longer exists.");
+ }
+ if (current.tokenVersion < account.tokenVersion) {
+ throw new DiscordAuthUnavailableError(
+ "Stored Discord token version is invalid.",
+ );
+ }
+ const recovered = recoverNewerDiscordAccessToken(
+ account,
+ current,
+ secret,
+ nowMs,
+ );
+ if (recovered) return recovered;
+
+ account = current;
+ await dependencies.wait(TOKEN_REFRESH_POLL_MS);
+ }
+
+ throw new DiscordAuthUnavailableError(
+ "Discord token refresh is temporarily busy.",
);
}
-function signSession(session: DiscordSession): string {
- const payload = Buffer.from(JSON.stringify(session)).toString("base64url");
- const signature = createHmac("sha256", getSessionSecret())
- .update(payload)
- .digest("base64url");
- return `${payload}.${signature}`;
+function createDiscordTokenRefreshDependencies(): DiscordTokenRefreshDependencies {
+ return {
+ acquireLease: acquireDiscordAccountTokenRefreshLease,
+ completeRefresh: completeDiscordAccountTokenRefresh,
+ createLeaseId: () => randomBytes(18).toString("base64url"),
+ exchangeRefreshToken: (refreshToken) =>
+ exchangeDiscordToken({ grantType: "refresh_token", refreshToken }),
+ getAccount: getDiscordAccount,
+ now: () => Date.now(),
+ releaseLease: releaseDiscordAccountTokenRefreshLease,
+ wait: (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
+ };
}
-function verifySession(signed: string): DiscordSession | null {
- const [payload, signature] = signed.split(".");
- if (!payload || !signature) return null;
+async function loadDiscordAccountForRefresh(
+ discordUserId: string,
+ dependencies: DiscordTokenRefreshDependencies,
+): Promise {
+ const value = await runSessionStoreOperation(() =>
+ dependencies.getAccount(discordUserId),
+ );
+ return value ? requireStoredDiscordAccount(value) : null;
+}
- const expected = createHmac("sha256", getSessionSecret())
- .update(payload)
- .digest("base64url");
- const left = Buffer.from(signature);
- const right = Buffer.from(expected);
- if (left.length !== right.length || !timingSafeEqual(left, right))
+function recoverNewerDiscordAccessToken(
+ previous: DiscordAccountRecord,
+ current: DiscordAccountRecord | null,
+ secret: string,
+ nowMs: number,
+): { account: DiscordAccountRecord; accessToken: string } | null {
+ if (
+ !current ||
+ current.tokenVersion <= previous.tokenVersion ||
+ current.tokenExpiresAt.getTime() <= nowMs
+ ) {
return null;
+ }
+ return {
+ account: current,
+ accessToken: decryptStoredAccessToken(current, secret),
+ };
+}
+function decryptStoredAccessToken(
+ account: DiscordAccountRecord,
+ secret: string,
+): string {
try {
- return JSON.parse(
- Buffer.from(payload, "base64url").toString("utf8"),
- ) as DiscordSession;
+ return decryptDiscordToken(
+ account.accessTokenEncrypted,
+ secret,
+ tokenEncryptionContext(account.discordUserId, "access"),
+ );
} catch {
+ throw new DiscordAuthorizationError("Stored Discord token is unavailable.");
+ }
+}
+
+function decryptStoredRefreshToken(
+ account: DiscordAccountRecord,
+ secret: string,
+): string {
+ try {
+ return decryptDiscordToken(
+ account.refreshTokenEncrypted,
+ secret,
+ tokenEncryptionContext(account.discordUserId, "refresh"),
+ );
+ } catch {
+ throw new DiscordAuthorizationError("Stored Discord token is unavailable.");
+ }
+}
+
+async function exchangeDiscordToken(
+ input:
+ | { code: string; grantType: "authorization_code" }
+ | { grantType: "refresh_token"; refreshToken: string },
+): Promise {
+ const clientId = process.env.DISCORD_CLIENT_ID;
+ const clientSecret = process.env.DISCORD_CLIENT_SECRET;
+ if (!clientId || !clientSecret) {
+ throw new Error("Discord OAuth is not configured.");
+ }
+ const body = buildDiscordTokenRequest(
+ input.grantType === "authorization_code"
+ ? {
+ code: input.code,
+ grantType: input.grantType,
+ redirectUri: getDiscordRedirectUri(),
+ }
+ : input,
+ { clientId, clientSecret },
+ );
+
+ const response = await fetch(DISCORD_TOKEN_URL, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body,
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) {
+ if (response.status === 400 || response.status === 401) {
+ throw new DiscordAuthorizationError("Discord rejected the OAuth token.");
+ }
+ throw new DiscordApiError(response.status, "OAuth token exchange");
+ }
+ const parsed = parseDiscordTokenResponse(await response.json());
+ if (!parsed) {
+ throw new DiscordAuthorizationError("Discord returned an invalid token.");
+ }
+ return parsed;
+}
+
+async function fetchDiscordIdentity(accessToken: string): Promise<{
+ guilds: ManagedDiscordGuild[];
+ user: ParsedDiscordUser;
+}> {
+ const [userValue, guildValue] = await Promise.all([
+ fetchDiscordJson("/users/@me", accessToken, "user lookup"),
+ fetchDiscordJson(
+ "/users/@me/guilds?limit=200",
+ accessToken,
+ "guild lookup",
+ ),
+ ]);
+ const user = parseDiscordUserResponse(userValue);
+ const guilds = parseManagedDiscordGuilds(guildValue);
+ if (!user || !guilds) {
+ throw new DiscordAuthorizationError(
+ "Discord identity response is invalid.",
+ );
+ }
+ return { guilds, user };
+}
+
+async function fetchDiscordJson(
+ path: string,
+ accessToken: string,
+ operation: string,
+): Promise {
+ const response = await fetch(`${DISCORD_API}${path}`, {
+ headers: { authorization: `Bearer ${accessToken}` },
+ cache: "no-store",
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) throw new DiscordApiError(response.status, operation);
+ return response.json() as Promise;
+}
+
+function parseDiscordUserResponse(value: unknown): ParsedDiscordUser | null {
+ if (!isRecord(value)) return null;
+ const id = parseSnowflake(value.id);
+ const username = parseBoundedString(value.username, 1, 32);
+ const globalName = parseOptionalNullableString(value.global_name, 100);
+ const avatarHash = parseOptionalNullableString(value.avatar, 256);
+ if (!id || !username || globalName === undefined || avatarHash === undefined)
+ return null;
+ return { avatarHash, globalName, id, username };
+}
+
+export function parseDiscordTokenResponse(
+ value: unknown,
+ nowMs: number = Date.now(),
+): ParsedDiscordToken | null {
+ if (!isRecord(value)) return null;
+ const accessToken = parseBoundedString(value.access_token, 1, 4_096);
+ const refreshToken = parseBoundedString(value.refresh_token, 1, 4_096);
+ const tokenType = parseBoundedString(value.token_type, 1, 32);
+ const expiresIn = value.expires_in;
+ if (
+ !accessToken ||
+ !refreshToken ||
+ tokenType?.toLowerCase() !== "bearer" ||
+ typeof expiresIn !== "number" ||
+ !Number.isFinite(expiresIn) ||
+ expiresIn <= 0 ||
+ expiresIn > 366 * 24 * 60 * 60
+ ) {
return null;
}
+ return {
+ accessToken,
+ refreshToken,
+ expiresAt: new Date(nowMs + expiresIn * 1_000),
+ };
+}
+
+function buildDiscordAvatarUrl(user: ParsedDiscordUser): string | null {
+ return user.avatarHash
+ ? `https://cdn.discordapp.com/avatars/${user.id}/${encodeURIComponent(user.avatarHash)}.png?size=128`
+ : null;
+}
+
+function tokenEncryptionContext(
+ discordUserId: string,
+ kind: DiscordTokenKind,
+): string {
+ return `piphacklup:discord-oauth:${discordUserId}:${kind}:v1`;
+}
+
+function deriveEncryptionKey(secret: string): Buffer {
+ if (Buffer.byteLength(secret, "utf8") < 32) {
+ throw new Error("NEXTAUTH_SECRET must contain at least 32 bytes.");
+ }
+ return Buffer.from(
+ hkdfSync(
+ "sha256",
+ Buffer.from(secret, "utf8"),
+ Buffer.from("piphacklup-auth", "utf8"),
+ Buffer.from("discord-token-encryption-v1", "utf8"),
+ 32,
+ ),
+ );
}
function getSessionSecret(): string {
const secret = process.env.NEXTAUTH_SECRET;
- if (!secret)
- throw new Error("NEXTAUTH_SECRET is required for dashboard auth.");
+ if (!secret || Buffer.byteLength(secret, "utf8") < 32) {
+ throw new Error("NEXTAUTH_SECRET must contain at least 32 bytes.");
+ }
return secret;
}
+
+function requireStoredDiscordAccount(value: unknown): DiscordAccountRecord {
+ const account = parseStoredDiscordAccount(value);
+ if (!account) {
+ throw new DiscordAuthUnavailableError(
+ "Stored Discord account data is invalid.",
+ );
+ }
+ return account;
+}
+
+function parseStoredDiscordAccount(
+ value: unknown,
+): DiscordAccountRecord | null {
+ if (
+ !isRecord(value) ||
+ !("globalName" in value) ||
+ !("avatarUrl" in value) ||
+ !("tokenRefreshLeaseId" in value) ||
+ !("tokenRefreshLeaseExpiresAt" in value) ||
+ !parseSnowflake(value.discordUserId) ||
+ !parseBoundedString(value.username, 1, 32) ||
+ parseOptionalNullableString(value.globalName, 100) === undefined ||
+ parseOptionalNullableString(value.avatarUrl, 2_048) === undefined ||
+ !isEncryptedToken(value.accessTokenEncrypted) ||
+ !isEncryptedToken(value.refreshTokenEncrypted) ||
+ !isValidDate(value.tokenExpiresAt) ||
+ typeof value.tokenVersion !== "number" ||
+ !Number.isSafeInteger(value.tokenVersion) ||
+ value.tokenVersion < 1 ||
+ !isValidDate(value.createdAt) ||
+ !isValidDate(value.updatedAt)
+ ) {
+ return null;
+ }
+
+ const leaseId = value.tokenRefreshLeaseId;
+ const leaseExpiresAt = value.tokenRefreshLeaseExpiresAt;
+ if (
+ (leaseId !== null &&
+ (typeof leaseId !== "string" || !REFRESH_LEASE_PATTERN.test(leaseId))) ||
+ (leaseExpiresAt !== null && !isValidDate(leaseExpiresAt)) ||
+ (leaseId === null) !== (leaseExpiresAt === null)
+ ) {
+ return null;
+ }
+
+ return value as unknown as DiscordAccountRecord;
+}
+
+async function runSessionStoreOperation(
+ operation: () => Promise,
+): Promise {
+ try {
+ return await operation();
+ } catch (error) {
+ if (error instanceof DiscordAuthUnavailableError) throw error;
+ throw new DiscordAuthUnavailableError(
+ "Discord session storage is temporarily unavailable.",
+ );
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isValidDate(value: unknown): value is Date {
+ return value instanceof Date && Number.isFinite(value.getTime());
+}
+
+function isEncryptedToken(value: unknown): value is string {
+ if (typeof value !== "string") return false;
+ const [version, ivEncoded, ciphertextEncoded, tagEncoded, extra] =
+ value.split(".");
+ if (
+ version !== ENCRYPTION_VERSION ||
+ !ivEncoded ||
+ !ciphertextEncoded ||
+ !tagEncoded ||
+ !BASE64URL_PATTERN.test(ivEncoded) ||
+ !BASE64URL_PATTERN.test(ciphertextEncoded) ||
+ !BASE64URL_PATTERN.test(tagEncoded) ||
+ extra
+ )
+ return false;
+ try {
+ return (
+ Buffer.from(ivEncoded, "base64url").length === ENCRYPTION_IV_BYTES &&
+ Buffer.from(ciphertextEncoded, "base64url").length > 0 &&
+ Buffer.from(tagEncoded, "base64url").length === ENCRYPTION_TAG_BYTES
+ );
+ } catch {
+ return false;
+ }
+}
+
+function parseSnowflake(value: unknown): string | null {
+ return typeof value === "string" && SNOWFLAKE_PATTERN.test(value)
+ ? value
+ : null;
+}
+
+function parseDecimalString(value: unknown): string | null {
+ if (typeof value !== "string" || !PERMISSIONS_PATTERN.test(value))
+ return null;
+ try {
+ BigInt(value);
+ return value;
+ } catch {
+ return null;
+ }
+}
+
+function parseBoundedString(
+ value: unknown,
+ minimumLength: number,
+ maximumLength: number,
+): string | null {
+ return typeof value === "string" &&
+ value.length >= minimumLength &&
+ value.length <= maximumLength
+ ? value
+ : null;
+}
+
+function parseOptionalNullableString(
+ value: unknown,
+ maximumLength: number,
+): string | null | undefined {
+ if (value === undefined || value === null) return null;
+ return typeof value === "string" && value.length <= maximumLength
+ ? value
+ : undefined;
+}
diff --git a/apps/web/lib/discord-installation.ts b/apps/web/lib/discord-installation.ts
new file mode 100644
index 0000000..3f194ff
--- /dev/null
+++ b/apps/web/lib/discord-installation.ts
@@ -0,0 +1,272 @@
+const DISCORD_API = "https://discord.com/api/v10";
+const DEFAULT_INSTALL_PERMISSIONS = "1099914365968";
+const DISCORD_SNOWFLAKE = /^\d{17,20}$/;
+
+interface DiscordBotGuildResponse {
+ id: string;
+}
+
+export interface DiscordGuildOption {
+ id: string;
+ name: string;
+}
+
+interface DiscordRoleResponse extends DiscordGuildOption {
+ managed: boolean;
+ position: number;
+}
+
+interface DiscordChannelResponse extends DiscordGuildOption {
+ position: number;
+ type: number;
+}
+
+export interface DiscordInstallUrlOptions {
+ clientId: string;
+ guildId?: string;
+ permissions?: string;
+}
+
+export class DiscordBotApiError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ ) {
+ super(message);
+ this.name = "DiscordBotApiError";
+ }
+}
+
+export function buildDiscordInstallUrl({
+ clientId,
+ guildId,
+ permissions = DEFAULT_INSTALL_PERMISSIONS,
+}: DiscordInstallUrlOptions): string {
+ assertSnowflake(clientId, "Discord client ID");
+ if (guildId) assertSnowflake(guildId, "Discord guild ID");
+ if (!/^\d+$/.test(permissions)) {
+ throw new Error("Discord install permissions must be a numeric bitfield.");
+ }
+
+ const params = new URLSearchParams({
+ client_id: clientId,
+ scope: "bot applications.commands",
+ permissions,
+ integration_type: "0",
+ });
+ if (guildId) {
+ params.set("guild_id", guildId);
+ params.set("disable_guild_select", "true");
+ }
+
+ return `https://discord.com/oauth2/authorize?${params.toString()}`;
+}
+
+export function getDiscordInstallUrl(guildId?: string): string {
+ const clientId = process.env.DISCORD_CLIENT_ID;
+ if (!clientId) throw new Error("DISCORD_CLIENT_ID is required.");
+ return buildDiscordInstallUrl({
+ clientId,
+ ...(guildId ? { guildId } : {}),
+ ...(process.env.DISCORD_INSTALL_PERMISSIONS
+ ? { permissions: process.env.DISCORD_INSTALL_PERMISSIONS }
+ : {}),
+ });
+}
+
+export function isDiscordBotApiConfigured(): boolean {
+ return Boolean(process.env.DISCORD_TOKEN);
+}
+
+export async function listDiscordBotGuildIds(
+ fetchImplementation: typeof fetch = fetch,
+): Promise> {
+ const token = getDiscordBotToken();
+ const guildIds = new Set();
+ let after: string | undefined;
+
+ for (let page = 0; page < 10; page += 1) {
+ const url = new URL(`${DISCORD_API}/users/@me/guilds`);
+ url.searchParams.set("limit", "200");
+ if (after) url.searchParams.set("after", after);
+
+ const response = await fetchImplementation(url, {
+ headers: { authorization: `Bot ${token}` },
+ cache: "no-store",
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) {
+ throw new DiscordBotApiError(
+ "Discord could not return the bot's installed servers.",
+ response.status,
+ );
+ }
+
+ const body = (await response.json()) as unknown;
+ if (!Array.isArray(body) || !body.every(isDiscordBotGuildResponse)) {
+ throw new DiscordBotApiError(
+ "Discord returned an invalid installed-server response.",
+ 502,
+ );
+ }
+
+ for (const guild of body) guildIds.add(guild.id);
+ if (body.length < 200) return guildIds;
+ after = body.at(-1)?.id;
+ if (!after) return guildIds;
+ }
+
+ throw new DiscordBotApiError(
+ "Discord returned too many installed-server pages to reconcile safely.",
+ 502,
+ );
+}
+
+export async function leaveDiscordBotGuild(
+ guildId: string,
+ fetchImplementation: typeof fetch = fetch,
+): Promise<"removed" | "already_removed"> {
+ assertSnowflake(guildId, "Discord guild ID");
+ const response = await fetchImplementation(
+ `${DISCORD_API}/users/@me/guilds/${guildId}`,
+ {
+ method: "DELETE",
+ headers: { authorization: `Bot ${getDiscordBotToken()}` },
+ cache: "no-store",
+ signal: AbortSignal.timeout(10_000),
+ },
+ );
+
+ if (response.status === 204) return "removed";
+ if (response.status === 404) return "already_removed";
+ throw new DiscordBotApiError(
+ "Discord could not remove PipHackLup from this server.",
+ response.status,
+ );
+}
+
+export async function getDiscordGuildConfigurationOptions(
+ guildId: string,
+ fetchImplementation: typeof fetch = fetch,
+): Promise<{ roles: DiscordGuildOption[]; channels: DiscordGuildOption[] }> {
+ assertSnowflake(guildId, "Discord guild ID");
+ const headers = { authorization: `Bot ${getDiscordBotToken()}` };
+ const [rolesResponse, channelsResponse] = await Promise.all([
+ fetchImplementation(`${DISCORD_API}/guilds/${guildId}/roles`, {
+ headers,
+ cache: "no-store",
+ signal: AbortSignal.timeout(10_000),
+ }),
+ fetchImplementation(`${DISCORD_API}/guilds/${guildId}/channels`, {
+ headers,
+ cache: "no-store",
+ signal: AbortSignal.timeout(10_000),
+ }),
+ ]);
+ if (!rolesResponse.ok || !channelsResponse.ok) {
+ throw new DiscordBotApiError(
+ "Discord could not return this server's roles and channels.",
+ !rolesResponse.ok ? rolesResponse.status : channelsResponse.status,
+ );
+ }
+
+ const roles = parseDiscordRoleOptions(await rolesResponse.json(), guildId);
+ const channels = parseDiscordChannelOptions(await channelsResponse.json());
+ if (!roles || !channels) {
+ throw new DiscordBotApiError(
+ "Discord returned invalid role or channel options.",
+ 502,
+ );
+ }
+ return { channels, roles };
+}
+
+export function parseDiscordRoleOptions(
+ value: unknown,
+ guildId: string,
+): DiscordGuildOption[] | null {
+ if (!Array.isArray(value) || value.length > 1_000) return null;
+ const roles: DiscordRoleResponse[] = [];
+ for (const item of value) {
+ if (!isRecord(item)) return null;
+ const { id, managed, name, position } = item;
+ if (
+ typeof id !== "string" ||
+ !DISCORD_SNOWFLAKE.test(id) ||
+ typeof name !== "string" ||
+ !name.trim() ||
+ name.length > 100 ||
+ typeof managed !== "boolean" ||
+ !Number.isInteger(position)
+ ) {
+ return null;
+ }
+ if (id !== guildId && !managed) {
+ roles.push({ id, name, managed, position: position as number });
+ }
+ }
+ return roles
+ .toSorted((left, right) => right.position - left.position)
+ .map(({ id, name }) => ({ id, name }));
+}
+
+export function parseDiscordChannelOptions(
+ value: unknown,
+): DiscordGuildOption[] | null {
+ if (!Array.isArray(value) || value.length > 1_000) return null;
+ const channels: DiscordChannelResponse[] = [];
+ const selectableTypes = new Set([0, 5, 15]);
+ for (const item of value) {
+ if (!isRecord(item)) return null;
+ const { id, name, position, type } = item;
+ if (
+ typeof id !== "string" ||
+ !DISCORD_SNOWFLAKE.test(id) ||
+ typeof name !== "string" ||
+ !name.trim() ||
+ name.length > 100 ||
+ !Number.isInteger(position) ||
+ !Number.isInteger(type)
+ ) {
+ return null;
+ }
+ if (selectableTypes.has(type as number)) {
+ channels.push({
+ id,
+ name,
+ position: position as number,
+ type: type as number,
+ });
+ }
+ }
+ return channels
+ .toSorted((left, right) => left.position - right.position)
+ .map(({ id, name }) => ({ id, name: `#${name}` }));
+}
+
+function getDiscordBotToken(): string {
+ const token = process.env.DISCORD_TOKEN;
+ if (!token) throw new Error("DISCORD_TOKEN is required for bot management.");
+ return token;
+}
+
+function assertSnowflake(value: string, label: string): void {
+ if (!DISCORD_SNOWFLAKE.test(value)) {
+ throw new Error(`${label} must be a valid Discord snowflake.`);
+ }
+}
+
+function isDiscordBotGuildResponse(
+ value: unknown,
+): value is DiscordBotGuildResponse {
+ if (!value || typeof value !== "object") return false;
+ return (
+ "id" in value &&
+ typeof value.id === "string" &&
+ DISCORD_SNOWFLAKE.test(value.id)
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/apps/web/lib/guild-workspace.ts b/apps/web/lib/guild-workspace.ts
new file mode 100644
index 0000000..1aa809f
--- /dev/null
+++ b/apps/web/lib/guild-workspace.ts
@@ -0,0 +1,75 @@
+import {
+ isDiscordAuthConfigured,
+ readDiscordSession,
+ type DiscordSession,
+ type ManagedDiscordGuild,
+} from "./discord-auth";
+
+export interface GuildWorkspace {
+ session: DiscordSession | null;
+ guild: ManagedDiscordGuild | null;
+ sessionUnavailable: boolean;
+ requestedGuildUnavailable: boolean;
+}
+
+export async function loadGuildWorkspace(
+ requestedGuildId?: string,
+): Promise {
+ if (!isDiscordAuthConfigured()) {
+ return {
+ session: null,
+ guild: null,
+ sessionUnavailable: false,
+ requestedGuildUnavailable: false,
+ };
+ }
+
+ let session: DiscordSession | null;
+ try {
+ session = await readDiscordSession();
+ } catch {
+ console.error("PipHackLup could not load the organizer workspace.");
+ return {
+ session: null,
+ guild: null,
+ sessionUnavailable: true,
+ requestedGuildUnavailable: false,
+ };
+ }
+
+ if (!session) {
+ return {
+ session: null,
+ guild: null,
+ sessionUnavailable: false,
+ requestedGuildUnavailable: false,
+ };
+ }
+
+ const selection = selectGuildForWorkspace(session, requestedGuildId);
+ return {
+ session,
+ guild: selection.guild,
+ sessionUnavailable: false,
+ requestedGuildUnavailable: selection.requestedGuildUnavailable,
+ };
+}
+
+export function selectGuildForWorkspace(
+ session: DiscordSession,
+ requestedGuildId?: string,
+): Pick {
+ if (!requestedGuildId) {
+ return {
+ guild: session.guilds[0] ?? null,
+ requestedGuildUnavailable: false,
+ };
+ }
+
+ const requestedGuild =
+ session.guilds.find((guild) => guild.id === requestedGuildId) ?? null;
+ return {
+ guild: requestedGuild,
+ requestedGuildUnavailable: requestedGuild === null,
+ };
+}
diff --git a/apps/web/lib/health.ts b/apps/web/lib/health.ts
new file mode 100644
index 0000000..4214f23
--- /dev/null
+++ b/apps/web/lib/health.ts
@@ -0,0 +1,67 @@
+export interface CachedHealthProbe {
+ check(): Promise;
+}
+
+export function createCachedHealthProbe(options: {
+ check: () => Promise;
+ cacheTtlMs?: number;
+ timeoutMs?: number;
+ now?: () => number;
+}): CachedHealthProbe {
+ const cacheTtlMs = options.cacheTtlMs ?? 5_000;
+ const timeoutMs = options.timeoutMs ?? 2_000;
+ const now = options.now ?? Date.now;
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs <= 0) {
+ throw new RangeError("cacheTtlMs must be a positive duration.");
+ }
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
+ throw new RangeError("timeoutMs must be a positive duration.");
+ }
+
+ let cached: { ready: boolean; checkedAt: number } | undefined;
+ let pending: Promise | undefined;
+
+ return {
+ check() {
+ const checkedAt = now();
+ if (cached && checkedAt - cached.checkedAt < cacheTtlMs) {
+ return Promise.resolve(cached.ready);
+ }
+ if (pending) return pending;
+
+ pending = runWithTimeout(options.check, timeoutMs)
+ .then(
+ () => true,
+ () => false,
+ )
+ .then((ready) => {
+ cached = { ready, checkedAt: now() };
+ return ready;
+ })
+ .finally(() => {
+ pending = undefined;
+ });
+ return pending;
+ },
+ };
+}
+
+async function runWithTimeout(
+ operation: () => Promise,
+ timeoutMs: number,
+): Promise {
+ let timeout: ReturnType | undefined;
+ try {
+ await Promise.race([
+ Promise.resolve().then(operation),
+ new Promise((_resolve, reject) => {
+ timeout = setTimeout(
+ () => reject(new Error("health check timed out")),
+ timeoutMs,
+ );
+ }),
+ ]);
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ }
+}
diff --git a/apps/web/lib/pending-session-revocation.ts b/apps/web/lib/pending-session-revocation.ts
new file mode 100644
index 0000000..fe27afa
--- /dev/null
+++ b/apps/web/lib/pending-session-revocation.ts
@@ -0,0 +1,67 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getAppUrl,
+ hasPendingDiscordSessionRevocations,
+ isPostOriginAllowed,
+ retryPendingDiscordSessionRevocations,
+} from "./discord-auth";
+import {
+ buildRateLimitKey,
+ enforceRateLimit,
+ getClientIp,
+ webRateLimitPolicies,
+} from "./rate-limit";
+
+export interface PendingSessionRevocationDependencies {
+ enforceRateLimit: typeof enforceRateLimit;
+ getAppUrl: typeof getAppUrl;
+ hasPendingRevocations: typeof hasPendingDiscordSessionRevocations;
+ isPostOriginAllowed: typeof isPostOriginAllowed;
+ retryPendingRevocations: typeof retryPendingDiscordSessionRevocations;
+}
+
+const defaultDependencies: PendingSessionRevocationDependencies = {
+ enforceRateLimit,
+ getAppUrl,
+ hasPendingRevocations: hasPendingDiscordSessionRevocations,
+ isPostOriginAllowed,
+ retryPendingRevocations: retryPendingDiscordSessionRevocations,
+};
+
+export async function handlePendingSessionRevocationRequest(
+ request: NextRequest,
+ dependencies: PendingSessionRevocationDependencies = defaultDependencies,
+) {
+ if (!(await dependencies.hasPendingRevocations())) {
+ return new NextResponse(null, { status: 204 });
+ }
+
+ if (
+ !dependencies.isPostOriginAllowed(
+ request.headers.get("origin"),
+ dependencies.getAppUrl(),
+ )
+ ) {
+ return NextResponse.json({ error: "invalid_origin" }, { status: 403 });
+ }
+
+ const rateLimitResponse = await dependencies.enforceRateLimit(request, {
+ key: buildRateLimitKey([
+ "web",
+ "auth-revoke-pending",
+ getClientIp(request),
+ ]),
+ policy: webRateLimitPolicies.auth,
+ allowLocalFallback: true,
+ });
+ if (rateLimitResponse) return rateLimitResponse;
+
+ const remaining = await dependencies.retryPendingRevocations();
+ if (remaining) {
+ return NextResponse.json(
+ { error: "session_revocation_pending" },
+ { status: 503 },
+ );
+ }
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts
index 277d7d0..f162090 100644
--- a/apps/web/lib/rate-limit.ts
+++ b/apps/web/lib/rate-limit.ts
@@ -1,4 +1,10 @@
+import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
+import {
+ consumeRateLimitInDb,
+ isDatabaseConfigured,
+ type RateLimitDecision,
+} from "@piphacklup/db";
export interface RateLimitPolicy {
limit: number;
@@ -20,32 +26,41 @@ interface Bucket {
const buckets = new Map();
const maxBuckets = 5_000;
-export function enforceRateLimit(
+export async function enforceRateLimit(
request: NextRequest,
options: {
key: string;
policy: RateLimitPolicy;
+ allowLocalFallback?: boolean;
},
-): NextResponse | null {
+): Promise {
void request;
- const now = Date.now();
- pruneBuckets(now);
-
- const bucket = buckets.get(options.key);
- if (!bucket || bucket.resetAt <= now) {
- buckets.set(options.key, {
- count: 1,
- resetAt: now + options.policy.windowMs,
- });
- return null;
+ let decision: RateLimitDecision;
+ if (isDatabaseConfigured()) {
+ try {
+ decision = await consumeRateLimitInDb({
+ keyHash: hashRateLimitKey(options.key),
+ limit: options.policy.limit,
+ windowMs: options.policy.windowMs,
+ });
+ } catch {
+ console.error("PipHackLup could not check the shared web rate limit.");
+ if (!options.allowLocalFallback) {
+ return NextResponse.json(
+ { error: "rate_limit_unavailable" },
+ { status: 503, headers: { "Retry-After": "5" } },
+ );
+ }
+ decision = consumeLocalRateLimit(options.key, options.policy);
+ }
+ } else {
+ decision = consumeLocalRateLimit(options.key, options.policy);
}
- bucket.count += 1;
- if (bucket.count <= options.policy.limit) return null;
-
+ if (decision.allowed) return null;
const retryAfterSeconds = Math.max(
1,
- Math.ceil((bucket.resetAt - now) / 1000),
+ Math.ceil((decision.resetAt.getTime() - Date.now()) / 1000),
);
return NextResponse.json(
{
@@ -56,9 +71,11 @@ export function enforceRateLimit(
status: 429,
headers: {
"Retry-After": String(retryAfterSeconds),
- "X-RateLimit-Limit": String(options.policy.limit),
- "X-RateLimit-Remaining": "0",
- "X-RateLimit-Reset": String(Math.ceil(bucket.resetAt / 1000)),
+ "X-RateLimit-Limit": String(decision.limit),
+ "X-RateLimit-Remaining": String(decision.remaining),
+ "X-RateLimit-Reset": String(
+ Math.ceil(decision.resetAt.getTime() / 1000),
+ ),
},
},
);
@@ -76,6 +93,48 @@ export function buildRateLimitKey(parts: Array): string {
.join(":");
}
+export function buildPreAuthRateLimitKey(
+ request: NextRequest,
+ action: string,
+): string {
+ return buildRateLimitKey(["web", action, `ip-${getClientIp(request)}`]);
+}
+
+export function hashRateLimitKey(key: string): string {
+ return createHash("sha256")
+ .update(`piphacklup:web-rate-limit:v1:${key}`)
+ .digest("hex");
+}
+
+function consumeLocalRateLimit(
+ key: string,
+ policy: RateLimitPolicy,
+ now = Date.now(),
+): RateLimitDecision {
+ pruneBuckets(now);
+ const bucket = buckets.get(key);
+ if (!bucket || bucket.resetAt <= now) {
+ const resetAt = now + policy.windowMs;
+ buckets.set(key, { count: 1, resetAt });
+ return {
+ allowed: true,
+ count: 1,
+ limit: policy.limit,
+ remaining: Math.max(0, policy.limit - 1),
+ resetAt: new Date(resetAt),
+ };
+ }
+
+ bucket.count += 1;
+ return {
+ allowed: bucket.count <= policy.limit,
+ count: bucket.count,
+ limit: policy.limit,
+ remaining: Math.max(0, policy.limit - bucket.count),
+ resetAt: new Date(bucket.resetAt),
+ };
+}
+
function pruneBuckets(now: number): void {
if (buckets.size < maxBuckets) return;
diff --git a/apps/web/lib/request-security.ts b/apps/web/lib/request-security.ts
new file mode 100644
index 0000000..ddfa7e8
--- /dev/null
+++ b/apps/web/lib/request-security.ts
@@ -0,0 +1,20 @@
+import type { NextRequest } from "next/server";
+import { getAppUrl } from "./discord-auth";
+
+export function hasTrustedMutationOrigin(request: NextRequest): boolean {
+ const requestOrigin = request.headers.get("origin");
+ if (!requestOrigin) return false;
+
+ let normalizedOrigin: string;
+ try {
+ normalizedOrigin = new URL(requestOrigin).origin;
+ } catch {
+ return false;
+ }
+
+ const allowedOrigins = new Set([
+ request.nextUrl.origin,
+ new URL(getAppUrl()).origin,
+ ]);
+ return allowedOrigins.has(normalizedOrigin);
+}
diff --git a/apps/web/lib/training-import.ts b/apps/web/lib/training-import.ts
new file mode 100644
index 0000000..e4cc562
--- /dev/null
+++ b/apps/web/lib/training-import.ts
@@ -0,0 +1,26 @@
+import {
+ parseKnowledgeImportText,
+ type KnowledgeEscalationTarget,
+ type ParsedKnowledgeImport,
+} from "@piphacklup/core";
+
+export const maximumTrainingImportEntries = 50;
+
+export type TrainingImportParseResult =
+ | { ok: true; entries: ParsedKnowledgeImport[] }
+ | {
+ ok: false;
+ error: "training_import_empty" | "training_import_too_many_entries";
+ };
+
+export function parseTrainingImport(
+ text: string,
+ fallbackEscalationTarget: KnowledgeEscalationTarget,
+): TrainingImportParseResult {
+ const entries = parseKnowledgeImportText(text, fallbackEscalationTarget);
+ if (!entries.length) return { ok: false, error: "training_import_empty" };
+ if (entries.length > maximumTrainingImportEntries) {
+ return { ok: false, error: "training_import_too_many_entries" };
+ }
+ return { ok: true, entries };
+}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 9edff1c..ce4e94a 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,7 @@
///
///
import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.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/web/next.config.ts b/apps/web/next.config.ts
index 9c1db6c..bf21d09 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -5,10 +5,25 @@ import { fileURLToPath } from "node:url";
const workspaceRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const nextConfig: NextConfig = {
+ allowedDevOrigins: ["127.0.0.1"],
transpilePackages: ["@piphacklup/core", "@piphacklup/db", "@piphacklup/ui"],
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "cdn.discordapp.com",
+ pathname: "/avatars/**",
+ },
+ {
+ protocol: "https",
+ hostname: "cdn.discordapp.com",
+ pathname: "/icons/**",
+ },
+ ],
+ },
turbopack: {
- root: workspaceRoot
- }
+ root: workspaceRoot,
+ },
};
export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index 3d60cd0..28c8b23 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,17 +9,19 @@
"dev": "next dev",
"e2e": "playwright test",
"lint": "pnpm typecheck",
+ "prebuild": "pnpm --filter @piphacklup/core build && pnpm --filter @piphacklup/db build && pnpm --filter @piphacklup/ui build",
"predev": "pnpm --filter @piphacklup/core build && pnpm --filter @piphacklup/db build && pnpm --filter @piphacklup/ui build",
+ "pretest": "pnpm --filter @piphacklup/core build && pnpm --filter @piphacklup/db build && pnpm --filter @piphacklup/ui build",
"start": "next start",
- "test": "vitest run --passWithNoTests",
- "typecheck": "pnpm --filter @piphacklup/core build && pnpm --filter @piphacklup/db build && pnpm --filter @piphacklup/ui build && tsc -p tsconfig.json --noEmit"
+ "test": "vitest run",
+ "typecheck": "pnpm --filter @piphacklup/core build && pnpm --filter @piphacklup/db build && pnpm --filter @piphacklup/ui build && next typegen && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@piphacklup/core": "workspace:*",
"@piphacklup/db": "workspace:*",
"@piphacklup/ui": "workspace:*",
"lucide-react": "^0.562.0",
- "next": "^16.2.7",
+ "next": "^16.2.11",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index 86a0377..c9008ed 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -4,17 +4,18 @@ export default defineConfig({
testDir: "./tests",
timeout: 30_000,
use: {
- baseURL: "http://127.0.0.1:3000",
- trace: "on-first-retry"
+ baseURL: "http://localhost:3000",
+ trace: "on-first-retry",
},
webServer: {
- command: "pnpm dev",
- url: "http://127.0.0.1:3000/dashboard",
- reuseExistingServer: true,
- timeout: 120_000
+ command:
+ "CI=true npx --yes pnpm@10.25.0 predev && CI=true PIPHACKLUP_UI_TEST_MODE=1 npx --yes pnpm@10.25.0 exec next dev",
+ url: "http://localhost:3000/dashboard",
+ reuseExistingServer: false,
+ timeout: 120_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
- { name: "mobile", use: { ...devices["Pixel 7"] } }
- ]
+ { name: "mobile", use: { ...devices["Pixel 7"] } },
+ ],
});
diff --git a/apps/web/tests/dashboard.spec.ts b/apps/web/tests/dashboard.spec.ts
index 72fd125..467db01 100644
--- a/apps/web/tests/dashboard.spec.ts
+++ b/apps/web/tests/dashboard.spec.ts
@@ -1,47 +1,340 @@
import { expect, test } from "@playwright/test";
-test("dashboard exposes the core hackathon ops views", async ({ page }) => {
- await page.goto("/dashboard");
+test("public page has one clear Discord path and specific event-day copy", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "PipHackLup" })).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Hackathon ops at a glance" }),
+ page.getByRole("link", { name: "Continue with Discord" }).first(),
).toBeVisible();
- await expect(page.getByRole("link", { name: /Q&A Training/ })).toBeVisible();
- await expect(page.getByRole("link", { name: /Queues/ })).toBeVisible();
- await expect(page.getByRole("link", { name: /Teams/ })).toBeVisible();
- await expect(page.getByRole("link", { name: /Moderation/ })).toBeVisible();
+ await expect(
+ page.getByRole("heading", {
+ name: "Built for the busiest parts of event day",
+ }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Create the Discord app under your account."),
+ ).toHaveCount(0);
+});
+
+test("protected routes show an honest sign-in gate without sample records", async ({
+ page,
+}) => {
+ for (const route of [
+ "/dashboard",
+ "/setup",
+ "/queues",
+ "/teams",
+ "/moderation",
+ "/training",
+ ]) {
+ await page.goto(route);
+ await expect(
+ page.getByRole("heading", {
+ name: "Sign in with Discord to manage your servers",
+ }),
+ ).toBeVisible();
+ await expect(page.getByText("Iceberg Labs")).toHaveCount(0);
+ await expect(page.getByText("Preview Hackathon Server")).toHaveCount(0);
+ }
});
-test("training page supports Discord-linked Q&A training", async ({ page }) => {
- await page.goto("/training");
+test("signed-in control-room fixture exposes server lifecycle and filtering", async ({
+ page,
+}) => {
+ await page.goto("/dev-fixtures/control-room");
await expect(
- page.getByRole("heading", { name: "Train PipHackLup from the site" }),
+ page.getByRole("heading", { name: "Your Discord servers" }),
).toBeVisible();
+ const installedServer = page.locator(".server-card").filter({
+ has: page.getByRole("heading", { name: "North Star Hackathon" }),
+ });
+ const uninstalledServer = page
+ .locator(".server-card")
+ .filter({ has: page.getByRole("heading", { name: "Weekend Builders" }) });
+ await expect(installedServer).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Linked Discord Account" }),
+ installedServer.getByText("Installed", { exact: true }),
).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Train Event Details" }),
+ uninstalledServer.getByText("Not installed", { exact: true }),
).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Ask Preview" }),
+ page.getByRole("button", { name: "Status unavailable", exact: true }),
).toBeVisible();
+
+ await page.getByPlaceholder("Find a server").fill("Weekend");
+ await expect(
+ page.getByRole("heading", { name: "Weekend Builders" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "North Star Hackathon" }),
+ ).toHaveCount(0);
+ await page.getByPlaceholder("Find a server").fill("not a real server");
+ await expect(page.getByText("No servers match those filters.")).toBeVisible();
+ await page.getByRole("button", { name: "Clear filters" }).click();
+ await expect(
+ page.getByRole("heading", { name: "North Star Hackathon" }),
+ ).toBeVisible();
+});
+
+test("remove-bot failures stay visible inside the confirmation dialog", async ({
+ page,
+}) => {
+ await page.route("**/api/discord/guilds/*/bot", async (route) => {
+ await route.fulfill({
+ status: 502,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "discord_bot_api_failed" }),
+ });
+ });
+ await page.goto("/dev-fixtures/control-room");
+ await page.getByRole("button", { name: "Remove bot" }).click();
+ const dialog = page.getByRole("dialog");
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole("textbox").fill("North Star Hackathon");
+ await dialog.getByRole("button", { name: "Remove bot", exact: true }).click();
await expect(
- page.getByRole("heading", { name: "Training Library" }),
+ dialog.getByRole("alert").filter({
+ hasText: "Discord did not complete the removal",
+ }),
).toBeVisible();
});
-test("setup page documents required Discord permissions", async ({ page }) => {
- await page.goto("/setup");
+test("successful Discord removal surfaces a non-destructive activity-record warning", async ({
+ page,
+}) => {
+ await page.route("**/api/discord/guilds/*/bot", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ installed: false,
+ result: "left",
+ warning: "record_update_failed",
+ }),
+ });
+ });
+ await page.goto("/dev-fixtures/control-room");
+ await page.getByRole("button", { name: "Remove bot" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("textbox").fill("North Star Hackathon");
+ await dialog.getByRole("button", { name: "Remove bot", exact: true }).click();
+ await expect(dialog).not.toBeVisible();
await expect(
- page.getByRole("heading", {
- name: "Make a hackathon server understandable",
+ page.getByRole("status").filter({
+ hasText: "Discord removed PipHackLup from North Star Hackathon",
}),
+ ).toContainText("could not update its activity record");
+ const installedServer = page.locator(".server-card").filter({
+ has: page.getByRole("heading", { name: "North Star Hackathon" }),
+ });
+ await expect(
+ installedServer.getByText("Not installed", { exact: true }),
+ ).toBeVisible();
+});
+
+test("training fixture uses friendly controls and real empty-safe states", async ({
+ page,
+}) => {
+ await page.goto("/dev-fixtures/training");
+
+ await expect(
+ page.getByRole("heading", { name: "Train North Star Hackathon" }),
).toBeVisible();
await expect(
- page.getByText("applications.commands", { exact: true }),
+ page.getByRole("heading", { name: "Add an answer" }),
).toBeVisible();
- await expect(page.getByText("Guild Members intent")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Human follow-up" }),
+ ).toBeVisible();
+ await expect(page.getByText("Where is participant check-in?")).toBeVisible();
+ await expect(page.getByText("Staff role ID")).toHaveCount(0);
+ await expect(page.getByText("Preview mode")).toHaveCount(0);
+});
+
+test("mobile navigation keeps readable labels and a secondary menu", async ({
+ page,
+}, testInfo) => {
+ test.skip(testInfo.project.name !== "mobile", "mobile project only");
+ await page.goto("/dev-fixtures/control-room");
+
+ await expect(page.getByRole("link", { name: "Overview" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Q&A Training" })).toBeVisible();
+ await page.getByText("More", { exact: true }).click();
+ await expect(page.getByRole("link", { name: "Queues" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Teams" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Moderation" })).toBeVisible();
+});
+
+test("short landscape layouts let the workspace scroll past the organizer header", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 844, height: 390 });
+ await page.goto("/dev-fixtures/control-room");
+
+ await expect(page.locator(".sidebar")).toHaveCSS("position", "static");
+
+ await page.getByRole("button", { name: "Remove bot" }).click();
+ const removeButton = page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Remove bot", exact: true });
+ const bounds = await removeButton.boundingBox();
+
+ expect(bounds).not.toBeNull();
+ expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(374);
+});
+
+test("an unavailable requested server is not shown as a different selected server", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ window.localStorage.setItem(
+ "piphacklup:last-guild:1512918151313231983",
+ "1512918151313231985",
+ );
+ });
+ await page.goto("/dev-fixtures/control-room?guildId=999999999999999999");
+
+ await expect(page.getByLabel("Selected Discord server")).toHaveValue("");
+ await expect(
+ page.getByRole("option", { name: "Choose a server" }),
+ ).toBeAttached();
+});
+
+test("server workspaces restore the last still-manageable server for each Discord user", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ window.localStorage.setItem(
+ "piphacklup:last-guild:1512918151313231983",
+ "1512918151313231985",
+ );
+ });
+ await page.goto("/dev-fixtures/control-room");
+
+ await expect(page).toHaveURL(/guildId=1512918151313231985/);
+ await expect(page.getByLabel("Selected Discord server")).toHaveValue(
+ "1512918151313231985",
+ );
+});
+
+test("Q&A settings distinguish an unknown bot status and keep touch controls usable", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/dev-fixtures/training?installation=unknown");
+
+ await expect(
+ page.getByText(
+ "Installation status is temporarily unavailable, so Discord roles and channels cannot be loaded.",
+ ),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "Add PipHackLup to this server before choosing Discord roles and channels.",
+ ),
+ ).toHaveCount(0);
+
+ const toggleBounds = await page
+ .getByText("Answer in the channel by default")
+ .locator("..")
+ .boundingBox();
+ expect(toggleBounds).not.toBeNull();
+ expect(toggleBounds!.height).toBeGreaterThanOrEqual(44);
+});
+
+test("saved answers require a named keyboard-friendly confirmation and remain retryable after errors", async ({
+ page,
+}) => {
+ await page.route("**/api/training/entries?**", async (route) => {
+ await route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "database_unavailable" }),
+ });
+ });
+ await page.goto("/dev-fixtures/training");
+
+ const remove = page.getByRole("button", {
+ name: "Remove Where is participant check-in?",
+ });
+ await remove.click();
+ const confirmation = page.getByRole("group", {
+ name: "Confirm removal of Where is participant check-in?",
+ });
+ const confirm = confirmation.getByRole("button", {
+ name: "Confirm remove",
+ });
+ await expect(confirmation).toContainText("Where is participant check-in?");
+ await expect(confirm).toBeFocused();
+
+ await page.keyboard.press("Escape");
+ await expect(confirmation).toHaveCount(0);
+ await expect(remove).toBeFocused();
+
+ await remove.click();
+ await expect(confirm).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(page.locator(".training-notice[role='alert']")).toContainText(
+ "saved answers are temporarily unavailable",
+ );
+ await expect(confirmation).toBeVisible();
+ await expect(confirm).toBeFocused();
+});
+
+test("oversized Q&A imports explain the 50-entry limit without implying a partial save", async ({
+ page,
+}) => {
+ await page.route("**/api/training/entries?**", async (route) => {
+ await route.fulfill({
+ status: 400,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "training_import_too_many_entries" }),
+ });
+ });
+ await page.goto("/dev-fixtures/training");
+ await page.getByLabel("Event details").fill("Question | Answer");
+ await page.getByRole("button", { name: "Import details" }).click();
+
+ const notice = page.locator(".training-notice[role='alert']");
+ await expect(notice).toContainText("more than 50 event details");
+ await expect(notice).toContainText("every answer can be reviewed and saved");
+});
+
+test("saved dark mode settles correctly and the theme toggle persists light mode", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ window.localStorage.setItem("piphacklup-dashboard-theme", "dark");
+ });
+ await page.goto("/dev-fixtures/control-room");
+
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-dashboard-theme",
+ "dark",
+ );
+ await expect(page.locator(".shell")).toHaveCSS("color-scheme", "dark");
+ const toggle = page.getByRole("button", {
+ name: "Switch dashboard to light mode",
+ });
+ await expect(toggle).toBeVisible();
+
+ await toggle.click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-dashboard-theme",
+ "light",
+ );
+ await expect(page.locator(".shell")).toHaveCSS("color-scheme", "normal");
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ window.localStorage.getItem("piphacklup-dashboard-theme"),
+ ),
+ )
+ .toBe("light");
});
diff --git a/apps/web/unit/audit-log.test.ts b/apps/web/unit/audit-log.test.ts
new file mode 100644
index 0000000..6dc6670
--- /dev/null
+++ b/apps/web/unit/audit-log.test.ts
@@ -0,0 +1,48 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { AuditEvent } from "@piphacklup/core";
+import {
+ activityLogUnavailableWarning,
+ recordAuditAfterCommit,
+} from "../lib/audit-log";
+
+const input = {
+ guildId: "1512918151313231984",
+ actorId: "1512918151313231983",
+ action: "knowledge.create",
+ targetType: "knowledge" as const,
+ targetId: "know_fixture01",
+ metadata: {},
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("recordAuditAfterCommit", () => {
+ it("returns no warning when the activity log is recorded", async () => {
+ const writer = vi.fn(
+ async (): Promise => ({
+ ...input,
+ id: "audit_fixture01",
+ createdAt: "2026-08-09T16:00:00.000Z",
+ }),
+ );
+
+ await expect(recordAuditAfterCommit(input, writer)).resolves.toBeNull();
+ expect(writer).toHaveBeenCalledWith(input);
+ });
+
+ it("returns an explicit warning without exposing the database error", async () => {
+ const log = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const writer = vi.fn(async (): Promise => {
+ throw new Error("postgres://secret@example.invalid");
+ });
+
+ await expect(recordAuditAfterCommit(input, writer)).resolves.toBe(
+ activityLogUnavailableWarning,
+ );
+ expect(log).toHaveBeenCalledWith(
+ "PipHackLup saved a dashboard change but could not record its audit event.",
+ );
+ });
+});
diff --git a/apps/web/unit/discord-auth.test.ts b/apps/web/unit/discord-auth.test.ts
new file mode 100644
index 0000000..00ce620
--- /dev/null
+++ b/apps/web/unit/discord-auth.test.ts
@@ -0,0 +1,670 @@
+import { describe, expect, test, vi } from "vitest";
+import {
+ buildDiscordAuthorizeUrl,
+ buildDiscordTokenRequest,
+ canManageGuild,
+ clearDiscordSessionCookie,
+ createSessionToken,
+ decryptDiscordToken,
+ encryptDiscordToken,
+ hasDiscordAuthConfiguration,
+ hasDiscordSessionStoreConfiguration,
+ hashSessionToken,
+ isPostOriginAllowed,
+ oauthStatesMatch,
+ parseDiscordTokenResponse,
+ parseManagedDiscordGuilds,
+ parseStoredDiscordAuthSession,
+ refreshDiscordAccessTokenWithLease,
+ retryPendingDiscordSessionRevocations,
+ setDiscordSessionCookie,
+ shouldRefreshDiscordToken,
+ type DiscordSessionCookieDependencies,
+ type DiscordSessionCookieStore,
+} from "../lib/discord-auth";
+import type { DiscordAccountRecord } from "@piphacklup/db";
+
+const secret = "test-only-secret-with-at-least-32-bytes";
+
+function createCookieHarness(
+ initial: Record,
+ revokeFails = false,
+) {
+ const values = new Map(Object.entries(initial));
+ const operations: string[] = [];
+ const revokedHashes: string[] = [];
+ const cookieStore: DiscordSessionCookieStore = {
+ get(name) {
+ const value = values.get(name);
+ return value === undefined ? undefined : { value };
+ },
+ set(name, value) {
+ operations.push(`set:${name}`);
+ values.set(name, value);
+ },
+ delete(name) {
+ operations.push(`delete:${name}`);
+ values.delete(name);
+ },
+ };
+ const dependencies: DiscordSessionCookieDependencies = {
+ getCookieStore: async () => cookieStore,
+ revokeSession: async (tokenHash) => {
+ operations.push(`revoke:${tokenHash}`);
+ revokedHashes.push(tokenHash);
+ if (revokeFails) throw new Error("database unavailable");
+ },
+ hasSessionStore: () => true,
+ appUrl: () => "https://piphacklup.example.test",
+ now: () => Date.parse("2026-08-09T12:00:00.000Z"),
+ };
+ return { dependencies, operations, revokedHashes, values };
+}
+
+describe("Discord OAuth configuration", () => {
+ test("requires OAuth, a strong session secret, and a real database URL", () => {
+ expect(
+ hasDiscordAuthConfiguration({
+ DATABASE_URL: "postgres://local.test/piphacklup",
+ DISCORD_CLIENT_ID: "client-id",
+ DISCORD_CLIENT_SECRET: "client-secret",
+ NEXTAUTH_SECRET: secret,
+ }),
+ ).toBe(true);
+
+ expect(
+ hasDiscordAuthConfiguration({
+ DISCORD_CLIENT_ID: "client-id",
+ DISCORD_CLIENT_SECRET: "client-secret",
+ NEXTAUTH_SECRET: secret,
+ }),
+ ).toBe(false);
+ expect(
+ hasDiscordAuthConfiguration({
+ DATABASE_URL: "postgres://user:password@host:5432/piphacklup",
+ DISCORD_CLIENT_ID: "client-id",
+ DISCORD_CLIENT_SECRET: "client-secret",
+ NEXTAUTH_SECRET: secret,
+ }),
+ ).toBe(false);
+ expect(
+ hasDiscordAuthConfiguration({
+ DATABASE_URL: "postgres://local.test/piphacklup",
+ DISCORD_CLIENT_ID: "client-id",
+ DISCORD_CLIENT_SECRET: "client-secret",
+ NEXTAUTH_SECRET: "too-short",
+ }),
+ ).toBe(false);
+ });
+
+ test("recognizes session storage independently of OAuth credentials", () => {
+ expect(
+ hasDiscordSessionStoreConfiguration({
+ DATABASE_URL: "postgres://local.test/piphacklup",
+ }),
+ ).toBe(true);
+ expect(hasDiscordSessionStoreConfiguration({})).toBe(false);
+ expect(
+ hasDiscordSessionStoreConfiguration({
+ DATABASE_URL: "postgres://user:password@host:5432/piphacklup",
+ }),
+ ).toBe(false);
+ });
+
+ test("builds the exact Discord authorization-code request", () => {
+ const url = new URL(
+ buildDiscordAuthorizeUrl({
+ clientId: "123456789012345678",
+ redirectUri: "https://example.test/api/auth/discord/callback",
+ state: "state-value",
+ }),
+ );
+
+ expect(url.origin).toBe("https://discord.com");
+ expect(url.pathname).toBe("/oauth2/authorize");
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ client_id: "123456789012345678",
+ redirect_uri: "https://example.test/api/auth/discord/callback",
+ response_type: "code",
+ scope: "identify guilds",
+ state: "state-value",
+ });
+ });
+
+ test("builds authorization-code and refresh-token exchanges", () => {
+ const client = { clientId: "client-id", clientSecret: "client-secret" };
+ expect(
+ Object.fromEntries(
+ buildDiscordTokenRequest(
+ {
+ code: "authorization-code",
+ grantType: "authorization_code",
+ redirectUri: "https://example.test/callback",
+ },
+ client,
+ ),
+ ),
+ ).toEqual({
+ client_id: "client-id",
+ client_secret: "client-secret",
+ code: "authorization-code",
+ grant_type: "authorization_code",
+ redirect_uri: "https://example.test/callback",
+ });
+ expect(
+ Object.fromEntries(
+ buildDiscordTokenRequest(
+ { grantType: "refresh_token", refreshToken: "refresh-token" },
+ client,
+ ),
+ ),
+ ).toEqual({
+ client_id: "client-id",
+ client_secret: "client-secret",
+ grant_type: "refresh_token",
+ refresh_token: "refresh-token",
+ });
+ });
+
+ test("matches OAuth state without accepting missing or different values", () => {
+ const state = "a".repeat(32);
+ expect(oauthStatesMatch(state, state)).toBe(true);
+ expect(oauthStatesMatch(state, "b".repeat(32))).toBe(false);
+ expect(oauthStatesMatch("short", "a-longer-state")).toBe(false);
+ expect(oauthStatesMatch(null, state)).toBe(false);
+ });
+});
+
+describe("opaque sessions and encrypted OAuth tokens", () => {
+ test("creates a compact opaque session token and stores only its hash", () => {
+ const token = createSessionToken();
+ const hash = hashSessionToken(token);
+
+ expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
+ expect(hash).not.toContain(token);
+ expect(hashSessionToken(token)).toBe(hash);
+ });
+
+ test("round-trips AES-GCM ciphertext bound to its context", () => {
+ const encrypted = encryptDiscordToken(
+ "discord-access-token",
+ secret,
+ "account-1:access",
+ );
+ const secondEncryption = encryptDiscordToken(
+ "discord-access-token",
+ secret,
+ "account-1:access",
+ );
+
+ expect(encrypted).not.toContain("discord-access-token");
+ expect(secondEncryption).not.toBe(encrypted);
+ expect(decryptDiscordToken(encrypted, secret, "account-1:access")).toBe(
+ "discord-access-token",
+ );
+ expect(() =>
+ decryptDiscordToken(encrypted, secret, "account-2:access"),
+ ).toThrow(/authentication failed/);
+ expect(() =>
+ decryptDiscordToken(
+ encrypted,
+ "another-test-secret-that-is-long-enough",
+ "account-1:access",
+ ),
+ ).toThrow(/authentication failed/);
+ });
+
+ test("rejects tampered AES-GCM ciphertext", () => {
+ const encrypted = encryptDiscordToken(
+ "discord-refresh-token",
+ secret,
+ "account-1:refresh",
+ );
+ const parts = encrypted.split(".");
+ const ciphertext = parts[2]!;
+ parts[2] = `${ciphertext[0] === "A" ? "B" : "A"}${ciphertext.slice(1)}`;
+
+ expect(() =>
+ decryptDiscordToken(parts.join("."), secret, "account-1:refresh"),
+ ).toThrow(/authentication failed/);
+ });
+
+ test("validates Discord token responses and computes explicit expiry", () => {
+ const parsed = parseDiscordTokenResponse(
+ {
+ access_token: "access",
+ refresh_token: "refresh",
+ token_type: "Bearer",
+ expires_in: 3_600,
+ },
+ 1_000,
+ );
+
+ expect(parsed?.expiresAt.getTime()).toBe(3_601_000);
+ expect(
+ parseDiscordTokenResponse({
+ access_token: "access",
+ token_type: "Bearer",
+ expires_in: 3_600,
+ }),
+ ).toBeNull();
+ expect(
+ parseDiscordTokenResponse({
+ access_token: "access",
+ refresh_token: "refresh",
+ token_type: "Bearer",
+ expires_in: -1,
+ }),
+ ).toBeNull();
+ });
+
+ test("refreshes expired and near-expiry access tokens", () => {
+ const now = Date.parse("2026-08-09T12:00:00.000Z");
+ expect(shouldRefreshDiscordToken(new Date(now + 60_001), now)).toBe(false);
+ expect(shouldRefreshDiscordToken(new Date(now + 60_000), now)).toBe(true);
+ expect(shouldRefreshDiscordToken(new Date(now - 1), now)).toBe(true);
+ expect(shouldRefreshDiscordToken(new Date(Number.NaN), now)).toBe(true);
+ });
+
+ test("serializes parallel refreshes and makes the stale reader reuse the winner", async () => {
+ const now = Date.parse("2026-08-09T12:00:00.000Z");
+ const discordUserId = "123456789012345678";
+ let current: DiscordAccountRecord = {
+ discordUserId,
+ username: "organizer",
+ globalName: null,
+ avatarUrl: null,
+ accessTokenEncrypted: encryptDiscordToken(
+ "old-access",
+ secret,
+ `piphacklup:discord-oauth:${discordUserId}:access:v1`,
+ ),
+ refreshTokenEncrypted: encryptDiscordToken(
+ "old-refresh",
+ secret,
+ `piphacklup:discord-oauth:${discordUserId}:refresh:v1`,
+ ),
+ tokenExpiresAt: new Date(now + 1_000),
+ tokenVersion: 1,
+ tokenRefreshLeaseId: null,
+ tokenRefreshLeaseExpiresAt: null,
+ createdAt: new Date(now - 60_000),
+ updatedAt: new Date(now - 60_000),
+ };
+
+ let releaseExchange: () => void = () => undefined;
+ const exchangeGate = new Promise((resolve) => {
+ releaseExchange = resolve;
+ });
+ let markExchangeStarted: () => void = () => undefined;
+ const exchangeStarted = new Promise((resolve) => {
+ markExchangeStarted = resolve;
+ });
+ let markLeaseMiss: () => void = () => undefined;
+ const leaseMissed = new Promise((resolve) => {
+ markLeaseMiss = resolve;
+ });
+ let exchangeCount = 0;
+ let leaseCounter = 0;
+
+ const dependencies = {
+ acquireLease: async (input: {
+ discordUserId: string;
+ expectedTokenVersion: number;
+ leaseId: string;
+ leaseExpiresAt: Date;
+ now?: Date;
+ }) => {
+ const leaseActive =
+ current.tokenRefreshLeaseExpiresAt !== null &&
+ current.tokenRefreshLeaseExpiresAt > (input.now ?? new Date(now));
+ if (
+ input.discordUserId !== current.discordUserId ||
+ input.expectedTokenVersion !== current.tokenVersion ||
+ leaseActive
+ ) {
+ markLeaseMiss();
+ return null;
+ }
+ current = {
+ ...current,
+ tokenRefreshLeaseId: input.leaseId,
+ tokenRefreshLeaseExpiresAt: input.leaseExpiresAt,
+ };
+ return current;
+ },
+ completeRefresh: async (input: {
+ discordUserId: string;
+ expectedTokenVersion: number;
+ leaseId: string;
+ accessTokenEncrypted: string;
+ refreshTokenEncrypted: string;
+ tokenExpiresAt: Date;
+ }) => {
+ if (
+ input.discordUserId !== current.discordUserId ||
+ input.expectedTokenVersion !== current.tokenVersion ||
+ input.leaseId !== current.tokenRefreshLeaseId
+ ) {
+ return null;
+ }
+ current = {
+ ...current,
+ accessTokenEncrypted: input.accessTokenEncrypted,
+ refreshTokenEncrypted: input.refreshTokenEncrypted,
+ tokenExpiresAt: input.tokenExpiresAt,
+ tokenVersion: current.tokenVersion + 1,
+ tokenRefreshLeaseId: null,
+ tokenRefreshLeaseExpiresAt: null,
+ updatedAt: new Date(now),
+ };
+ return current;
+ },
+ createLeaseId: () => String.fromCharCode(97 + leaseCounter++).repeat(24),
+ exchangeRefreshToken: async (refreshToken: string) => {
+ exchangeCount += 1;
+ markExchangeStarted();
+ await exchangeGate;
+ expect(refreshToken).toBe("old-refresh");
+ return {
+ accessToken: "new-access",
+ refreshToken: "new-refresh",
+ expiresAt: new Date(now + 3_600_000),
+ };
+ },
+ getAccount: async () => current,
+ now: () => now,
+ releaseLease: async (input: {
+ discordUserId: string;
+ expectedTokenVersion: number;
+ leaseId: string;
+ }) => {
+ if (
+ input.discordUserId === current.discordUserId &&
+ input.expectedTokenVersion === current.tokenVersion &&
+ input.leaseId === current.tokenRefreshLeaseId
+ ) {
+ current = {
+ ...current,
+ tokenRefreshLeaseId: null,
+ tokenRefreshLeaseExpiresAt: null,
+ };
+ }
+ },
+ wait: async () => exchangeGate,
+ };
+
+ const first = refreshDiscordAccessTokenWithLease(
+ current,
+ secret,
+ dependencies,
+ );
+ await exchangeStarted;
+ const stale = { ...current };
+ const second = refreshDiscordAccessTokenWithLease(
+ stale,
+ secret,
+ dependencies,
+ );
+ await leaseMissed;
+ releaseExchange();
+
+ const [firstResult, secondResult] = await Promise.all([first, second]);
+ expect(exchangeCount).toBe(1);
+ expect(firstResult.accessToken).toBe("new-access");
+ expect(secondResult.accessToken).toBe("new-access");
+ expect(firstResult.account.tokenVersion).toBe(2);
+ expect(secondResult.account.tokenVersion).toBe(2);
+ expect(current.tokenRefreshLeaseId).toBeNull();
+ });
+});
+
+describe("browser session lifecycle", () => {
+ test("revokes the displaced browser session before installing a replacement", async () => {
+ const oldToken = "a".repeat(43);
+ const newToken = "b".repeat(43);
+ const harness = createCookieHarness({
+ piphacklup_discord_session: oldToken,
+ });
+
+ await setDiscordSessionCookie(
+ {
+ sessionToken: newToken,
+ expiresAt: new Date("2026-08-23T12:00:00.000Z"),
+ },
+ harness.dependencies,
+ );
+
+ expect(harness.revokedHashes).toEqual([hashSessionToken(oldToken)]);
+ expect(harness.values.get("piphacklup_discord_session")).toBe(newToken);
+ expect(harness.operations[0]).toBe(`revoke:${hashSessionToken(oldToken)}`);
+ expect(harness.operations[1]).toBe("set:piphacklup_discord_session");
+ });
+
+ test("keeps the old cookie and queues the orphan replacement if rotation fails", async () => {
+ const oldToken = "c".repeat(43);
+ const newToken = "d".repeat(43);
+ const harness = createCookieHarness(
+ { piphacklup_discord_session: oldToken },
+ true,
+ );
+
+ await expect(
+ setDiscordSessionCookie(
+ {
+ sessionToken: newToken,
+ expiresAt: new Date("2026-08-23T12:00:00.000Z"),
+ },
+ harness.dependencies,
+ ),
+ ).rejects.toThrow("Discord session storage is temporarily unavailable");
+
+ expect(harness.values.get("piphacklup_discord_session")).toBe(oldToken);
+ expect(harness.values.get("piphacklup_pending_session_revocations")).toBe(
+ hashSessionToken(newToken),
+ );
+ });
+
+ test("always clears the browser cookie and queues a failed server revocation", async () => {
+ const sessionToken = "e".repeat(43);
+ const harness = createCookieHarness(
+ { piphacklup_discord_session: sessionToken },
+ true,
+ );
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ await expect(
+ clearDiscordSessionCookie(harness.dependencies),
+ ).resolves.toEqual({ revocationPending: true });
+ expect(harness.values.has("piphacklup_discord_session")).toBe(false);
+ expect(harness.values.get("piphacklup_pending_session_revocations")).toBe(
+ hashSessionToken(sessionToken),
+ );
+ });
+
+ test("keeps reporting pending work when an older queued session remains", async () => {
+ const sessionToken = "g".repeat(43);
+ const olderHash = hashSessionToken("h".repeat(43));
+ const harness = createCookieHarness({
+ piphacklup_discord_session: sessionToken,
+ piphacklup_pending_session_revocations: olderHash,
+ });
+
+ await expect(
+ clearDiscordSessionCookie(harness.dependencies),
+ ).resolves.toEqual({ revocationPending: true });
+ expect(harness.values.get("piphacklup_pending_session_revocations")).toBe(
+ olderHash,
+ );
+ });
+
+ test("retries and clears queued server-session revocations", async () => {
+ const pendingHash = hashSessionToken("f".repeat(43));
+ const harness = createCookieHarness({
+ piphacklup_pending_session_revocations: pendingHash,
+ });
+
+ await expect(
+ retryPendingDiscordSessionRevocations(harness.dependencies),
+ ).resolves.toBe(0);
+ expect(harness.revokedHashes).toEqual([pendingHash]);
+ expect(harness.values.has("piphacklup_pending_session_revocations")).toBe(
+ false,
+ );
+ });
+});
+
+describe("fresh guild authorization helpers", () => {
+ test("accepts owner, Administrator, or Manage Server permissions", () => {
+ expect(canManageGuild("0", true)).toBe(true);
+ expect(canManageGuild("8", false)).toBe(true);
+ expect(canManageGuild("32", false)).toBe(true);
+ expect(canManageGuild("0", false)).toBe(false);
+ expect(canManageGuild("not-a-bitset", false)).toBe(false);
+ expect(canManageGuild("9".repeat(33), false)).toBe(false);
+ });
+
+ test("keeps every manageable guild without the former 25-server cap", () => {
+ const response = Array.from({ length: 30 }, (_, index) => ({
+ id: String(100_000_000_000_000_000n + BigInt(index)),
+ name: `Hackathon ${index + 1}`,
+ icon: null,
+ owner: false,
+ permissions: "32",
+ }));
+ response.push({
+ id: "200000000000000000",
+ name: "Not manageable",
+ icon: null,
+ owner: false,
+ permissions: "0",
+ });
+
+ const guilds = parseManagedDiscordGuilds(response);
+ expect(guilds).toHaveLength(30);
+ expect(guilds?.at(-1)?.name).toBe("Hackathon 30");
+ expect(guilds?.every((guild) => guild.canManage)).toBe(true);
+ });
+
+ test("fails closed for malformed or oversized Discord guild responses", () => {
+ expect(
+ parseManagedDiscordGuilds([
+ { id: "1", name: "Guild", owner: false, permissions: "32" },
+ ]),
+ ).toBeNull();
+ expect(
+ parseManagedDiscordGuilds([
+ {
+ id: "100000000000000000",
+ name: "Guild",
+ owner: false,
+ permissions: "invalid",
+ },
+ ]),
+ ).toBeNull();
+ expect(
+ parseManagedDiscordGuilds(
+ Array.from({ length: 201 }, (_, index) => ({
+ id: String(100_000_000_000_000_000n + BigInt(index)),
+ name: "Guild",
+ owner: true,
+ permissions: "0",
+ })),
+ ),
+ ).toBeNull();
+ });
+
+ test("rejects expired, revoked, or cross-account database sessions", () => {
+ const now = new Date("2026-08-09T12:00:00.000Z");
+ const encryptedAccess = encryptDiscordToken(
+ "access",
+ secret,
+ "stored:access",
+ );
+ const encryptedRefresh = encryptDiscordToken(
+ "refresh",
+ secret,
+ "stored:refresh",
+ );
+ const record = {
+ account: {
+ discordUserId: "123456789012345678",
+ username: "organizer",
+ globalName: null,
+ avatarUrl: null,
+ accessTokenEncrypted: encryptedAccess,
+ refreshTokenEncrypted: encryptedRefresh,
+ tokenExpiresAt: new Date("2026-08-10T12:00:00.000Z"),
+ tokenVersion: 1,
+ tokenRefreshLeaseId: null,
+ tokenRefreshLeaseExpiresAt: null,
+ createdAt: new Date("2026-08-01T12:00:00.000Z"),
+ updatedAt: new Date("2026-08-09T11:00:00.000Z"),
+ },
+ session: {
+ tokenHash: hashSessionToken("a".repeat(43)),
+ discordUserId: "123456789012345678",
+ expiresAt: new Date("2026-08-10T12:00:00.000Z"),
+ revokedAt: null,
+ lastSeenAt: null,
+ createdAt: new Date("2026-08-09T11:00:00.000Z"),
+ updatedAt: new Date("2026-08-09T11:00:00.000Z"),
+ },
+ };
+
+ expect(parseStoredDiscordAuthSession(record, now)).not.toBeNull();
+ expect(
+ parseStoredDiscordAuthSession(
+ {
+ ...record,
+ session: { ...record.session, expiresAt: now },
+ },
+ now,
+ ),
+ ).toBeNull();
+ expect(
+ parseStoredDiscordAuthSession(
+ {
+ ...record,
+ session: { ...record.session, revokedAt: now },
+ },
+ now,
+ ),
+ ).toBeNull();
+ expect(
+ parseStoredDiscordAuthSession(
+ {
+ ...record,
+ session: {
+ ...record.session,
+ discordUserId: "223456789012345678",
+ },
+ },
+ now,
+ ),
+ ).toBeNull();
+ expect(
+ parseStoredDiscordAuthSession(
+ record,
+ now,
+ hashSessionToken("b".repeat(43)),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("logout origin validation", () => {
+ test("allows only the configured same origin", () => {
+ expect(
+ isPostOriginAllowed("https://piphacklup.test", "https://piphacklup.test"),
+ ).toBe(true);
+ expect(
+ isPostOriginAllowed("https://attacker.test", "https://piphacklup.test"),
+ ).toBe(false);
+ expect(isPostOriginAllowed(null, "https://piphacklup.test")).toBe(false);
+ expect(isPostOriginAllowed("not-a-url", "https://piphacklup.test")).toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/web/unit/discord-installation.test.ts b/apps/web/unit/discord-installation.test.ts
new file mode 100644
index 0000000..1bfc84a
--- /dev/null
+++ b/apps/web/unit/discord-installation.test.ts
@@ -0,0 +1,151 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ buildDiscordInstallUrl,
+ DiscordBotApiError,
+ getDiscordGuildConfigurationOptions,
+ leaveDiscordBotGuild,
+ listDiscordBotGuildIds,
+ parseDiscordChannelOptions,
+ parseDiscordRoleOptions,
+} from "../lib/discord-installation";
+
+const clientId = "1512918151313231983";
+const guildId = "123456789012345678";
+
+afterEach(() => {
+ delete process.env.DISCORD_TOKEN;
+ vi.restoreAllMocks();
+});
+
+describe("buildDiscordInstallUrl", () => {
+ it("targets and locks a selected managed guild", () => {
+ const url = new URL(buildDiscordInstallUrl({ clientId, guildId }));
+
+ expect(url.origin).toBe("https://discord.com");
+ expect(url.pathname).toBe("/oauth2/authorize");
+ expect(url.searchParams.get("client_id")).toBe(clientId);
+ expect(url.searchParams.get("guild_id")).toBe(guildId);
+ expect(url.searchParams.get("disable_guild_select")).toBe("true");
+ expect(url.searchParams.get("scope")).toBe("bot applications.commands");
+ expect(url.searchParams.get("integration_type")).toBe("0");
+ expect(url.searchParams.get("permissions")).toBe("1099914365968");
+ });
+
+ it("rejects malformed identifiers and permission bitfields", () => {
+ expect(() => buildDiscordInstallUrl({ clientId: "not-an-id" })).toThrow(
+ /snowflake/,
+ );
+ expect(() =>
+ buildDiscordInstallUrl({ clientId, permissions: "administrator" }),
+ ).toThrow(/numeric bitfield/);
+ });
+});
+
+describe("Discord bot installation API", () => {
+ it("paginates the bot guild list without exposing the token", async () => {
+ process.env.DISCORD_TOKEN = "test-token";
+ const firstPage = Array.from({ length: 200 }, (_, index) => ({
+ id: String(100000000000000000n + BigInt(index)),
+ }));
+ const finalGuild = { id: "200000000000000000" };
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(firstPage))
+ .mockResolvedValueOnce(Response.json([finalGuild]));
+
+ const ids = await listDiscordBotGuildIds(fetchMock);
+
+ expect(ids.size).toBe(201);
+ expect(ids.has(finalGuild.id)).toBe(true);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const secondUrl = new URL(String(fetchMock.mock.calls[1]?.[0]));
+ expect(secondUrl.searchParams.get("after")).toBe(firstPage.at(-1)?.id);
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual({
+ authorization: "Bot test-token",
+ });
+ });
+
+ it("rejects malformed Discord responses", async () => {
+ process.env.DISCORD_TOKEN = "test-token";
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(Response.json([{ id: "invalid" }]));
+
+ await expect(listDiscordBotGuildIds(fetchMock)).rejects.toMatchObject({
+ name: "DiscordBotApiError",
+ status: 502,
+ });
+ });
+
+ it("treats a missing guild as already removed", async () => {
+ process.env.DISCORD_TOKEN = "test-token";
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response(null, { status: 404 }));
+
+ await expect(leaveDiscordBotGuild(guildId, fetchMock)).resolves.toBe(
+ "already_removed",
+ );
+ });
+
+ it("surfaces Discord removal failures", async () => {
+ process.env.DISCORD_TOKEN = "test-token";
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response(null, { status: 403 }));
+
+ await expect(
+ leaveDiscordBotGuild(guildId, fetchMock),
+ ).rejects.toBeInstanceOf(DiscordBotApiError);
+ });
+
+ it("returns only selectable Discord roles and text channels", async () => {
+ process.env.DISCORD_TOKEN = "test-token";
+ const roles = [
+ { id: guildId, name: "@everyone", managed: false, position: 0 },
+ {
+ id: "123456789012345679",
+ name: "Organizer",
+ managed: false,
+ position: 3,
+ },
+ {
+ id: "123456789012345680",
+ name: "Bot integration",
+ managed: true,
+ position: 4,
+ },
+ ];
+ const channels = [
+ { id: "123456789012345681", name: "help-desk", position: 2, type: 0 },
+ { id: "123456789012345682", name: "voice", position: 1, type: 2 },
+ ];
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(roles))
+ .mockResolvedValueOnce(Response.json(channels));
+
+ await expect(
+ getDiscordGuildConfigurationOptions(guildId, fetchMock),
+ ).resolves.toEqual({
+ roles: [{ id: "123456789012345679", name: "Organizer" }],
+ channels: [{ id: "123456789012345681", name: "#help-desk" }],
+ });
+ });
+});
+
+describe("Discord configuration option parsing", () => {
+ it("fails closed on malformed role and channel payloads", () => {
+ expect(
+ parseDiscordRoleOptions(
+ [{ id: "invalid", name: "Staff", managed: false, position: 1 }],
+ guildId,
+ ),
+ ).toBeNull();
+ expect(
+ parseDiscordChannelOptions([
+ { id: "123456789012345681", name: "help", position: "first", type: 0 },
+ ]),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/unit/guild-workspace.test.ts b/apps/web/unit/guild-workspace.test.ts
new file mode 100644
index 0000000..a9457f4
--- /dev/null
+++ b/apps/web/unit/guild-workspace.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import type { DiscordSession } from "../lib/discord-auth";
+import { selectGuildForWorkspace } from "../lib/guild-workspace";
+
+const session: DiscordSession = {
+ user: {
+ id: "1512918151313231983",
+ username: "eventorganizer",
+ globalName: "Event Organizer",
+ },
+ guilds: [
+ {
+ id: "1512918151313231984",
+ name: "North Star Hackathon",
+ isOwner: true,
+ permissions: "32",
+ canManage: true,
+ },
+ {
+ id: "1512918151313231985",
+ name: "Weekend Builders",
+ isOwner: false,
+ permissions: "32",
+ canManage: true,
+ },
+ ],
+ issuedAt: 1_786_304_000_000,
+};
+
+describe("selectGuildForWorkspace", () => {
+ it("uses the first manageable guild only when no explicit guild was requested", () => {
+ expect(selectGuildForWorkspace(session)).toEqual({
+ guild: session.guilds[0],
+ requestedGuildUnavailable: false,
+ });
+ });
+
+ it("returns the exact requested manageable guild", () => {
+ expect(selectGuildForWorkspace(session, "1512918151313231985")).toEqual({
+ guild: session.guilds[1],
+ requestedGuildUnavailable: false,
+ });
+ });
+
+ it("fails closed instead of falling back when an explicit guild is unavailable", () => {
+ expect(selectGuildForWorkspace(session, "1512918151313231999")).toEqual({
+ guild: null,
+ requestedGuildUnavailable: true,
+ });
+ });
+});
diff --git a/apps/web/unit/health.test.ts b/apps/web/unit/health.test.ts
new file mode 100644
index 0000000..f543334
--- /dev/null
+++ b/apps/web/unit/health.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createCachedHealthProbe } from "../lib/health";
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("web health probe", () => {
+ it("deduplicates concurrent checks, caches briefly, and recovers", async () => {
+ let now = 0;
+ const check = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error("database unavailable"))
+ .mockResolvedValueOnce(undefined);
+ const probe = createCachedHealthProbe({
+ check,
+ cacheTtlMs: 1_000,
+ timeoutMs: 100,
+ now: () => now,
+ });
+
+ await expect(Promise.all([probe.check(), probe.check()])).resolves.toEqual([
+ true,
+ true,
+ ]);
+ expect(check).toHaveBeenCalledTimes(1);
+ now = 999;
+ await expect(probe.check()).resolves.toBe(true);
+ expect(check).toHaveBeenCalledTimes(1);
+
+ now = 1_000;
+ await expect(probe.check()).resolves.toBe(false);
+ now = 2_000;
+ await expect(probe.check()).resolves.toBe(true);
+ expect(check).toHaveBeenCalledTimes(3);
+ });
+
+ it("bounds a stalled database check", async () => {
+ vi.useFakeTimers();
+ const probe = createCachedHealthProbe({
+ check: () => new Promise(() => undefined),
+ cacheTtlMs: 1_000,
+ timeoutMs: 50,
+ });
+
+ const readiness = probe.check();
+ await vi.advanceTimersByTimeAsync(50);
+ await expect(readiness).resolves.toBe(false);
+ });
+});
diff --git a/apps/web/unit/pending-session-revocation.test.ts b/apps/web/unit/pending-session-revocation.test.ts
new file mode 100644
index 0000000..5679268
--- /dev/null
+++ b/apps/web/unit/pending-session-revocation.test.ts
@@ -0,0 +1,102 @@
+import { NextRequest, NextResponse } from "next/server";
+import { describe, expect, it, vi } from "vitest";
+import {
+ handlePendingSessionRevocationRequest,
+ type PendingSessionRevocationDependencies,
+} from "../lib/pending-session-revocation";
+
+function createDependencies(
+ hasPendingRevocations: boolean,
+): PendingSessionRevocationDependencies {
+ return {
+ enforceRateLimit: vi.fn(async () => null),
+ getAppUrl: () => "https://piphacklup.test",
+ hasPendingRevocations: vi.fn(async () => hasPendingRevocations),
+ isPostOriginAllowed: vi.fn(() => true),
+ retryPendingRevocations: vi.fn(async () => 0),
+ };
+}
+
+describe("pending session revocation route", () => {
+ it("returns before origin and rate-limit work when the browser has no pending cookie", async () => {
+ const dependencies = createDependencies(false);
+ const request = new NextRequest(
+ "https://piphacklup.test/api/auth/session/revoke-pending",
+ { method: "POST" },
+ );
+
+ const response = await handlePendingSessionRevocationRequest(
+ request,
+ dependencies,
+ );
+
+ expect(response.status).toBe(204);
+ expect(dependencies.isPostOriginAllowed).not.toHaveBeenCalled();
+ expect(dependencies.enforceRateLimit).not.toHaveBeenCalled();
+ expect(dependencies.retryPendingRevocations).not.toHaveBeenCalled();
+ });
+
+ it("origin-checks and rate-limits an actual pending retry", async () => {
+ const dependencies = createDependencies(true);
+ const request = new NextRequest(
+ "https://piphacklup.test/api/auth/session/revoke-pending",
+ {
+ method: "POST",
+ headers: { origin: "https://piphacklup.test" },
+ },
+ );
+
+ const response = await handlePendingSessionRevocationRequest(
+ request,
+ dependencies,
+ );
+
+ expect(response.status).toBe(204);
+ expect(dependencies.isPostOriginAllowed).toHaveBeenCalledOnce();
+ expect(dependencies.enforceRateLimit).toHaveBeenCalledOnce();
+ expect(dependencies.retryPendingRevocations).toHaveBeenCalledOnce();
+ });
+
+ it("rejects an untrusted origin before consuming a rate-limit bucket", async () => {
+ const dependencies = createDependencies(true);
+ dependencies.isPostOriginAllowed = vi.fn(() => false);
+ const request = new NextRequest(
+ "https://piphacklup.test/api/auth/session/revoke-pending",
+ {
+ method: "POST",
+ headers: { origin: "https://attacker.invalid" },
+ },
+ );
+
+ const response = await handlePendingSessionRevocationRequest(
+ request,
+ dependencies,
+ );
+
+ expect(response.status).toBe(403);
+ expect(dependencies.enforceRateLimit).not.toHaveBeenCalled();
+ expect(dependencies.retryPendingRevocations).not.toHaveBeenCalled();
+ });
+
+ it("does not attempt revocation when the pending retry is rate-limited", async () => {
+ const dependencies = createDependencies(true);
+ dependencies.enforceRateLimit = vi.fn(async () =>
+ NextResponse.json({ error: "rate_limited" }, { status: 429 }),
+ );
+ const request = new NextRequest(
+ "https://piphacklup.test/api/auth/session/revoke-pending",
+ {
+ method: "POST",
+ headers: { origin: "https://piphacklup.test" },
+ },
+ );
+
+ const response = await handlePendingSessionRevocationRequest(
+ request,
+ dependencies,
+ );
+
+ expect(response.status).toBe(429);
+ expect(dependencies.retryPendingRevocations).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/unit/rate-limit.test.ts b/apps/web/unit/rate-limit.test.ts
new file mode 100644
index 0000000..1453e4e
--- /dev/null
+++ b/apps/web/unit/rate-limit.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { NextRequest } from "next/server";
+import {
+ buildPreAuthRateLimitKey,
+ buildRateLimitKey,
+ enforceRateLimit,
+ getClientIp,
+ hashRateLimitKey,
+} from "../lib/rate-limit";
+
+const originalDatabaseUrl = process.env.DATABASE_URL;
+
+afterEach(() => {
+ if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
+ else process.env.DATABASE_URL = originalDatabaseUrl;
+});
+
+describe("web rate limiting", () => {
+ it("hashes private rate-limit dimensions before shared storage", () => {
+ const key = buildRateLimitKey([
+ "web",
+ "training-write",
+ "203.0.113.42",
+ "1512918151313231984",
+ ]);
+ const hash = hashRateLimitKey(key);
+
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
+ expect(hash).not.toContain("203.0.113.42");
+ expect(hashRateLimitKey(key)).toBe(hash);
+ });
+
+ it("enforces the bounded local fallback when no database is configured", async () => {
+ delete process.env.DATABASE_URL;
+ const request = new NextRequest("http://localhost:3000/api/example");
+ const key = `test-local-${Date.now()}-${Math.random()}`;
+ const policy = { limit: 1, windowMs: 60_000 };
+
+ await expect(
+ enforceRateLimit(request, { key, policy }),
+ ).resolves.toBeNull();
+ const blocked = await enforceRateLimit(request, { key, policy });
+
+ expect(blocked?.status).toBe(429);
+ await expect(blocked?.json()).resolves.toMatchObject({
+ error: "rate_limited",
+ });
+ });
+
+ it("uses the first proxy-provided client address", () => {
+ const request = new NextRequest("http://localhost:3000/api/example", {
+ headers: {
+ "x-forwarded-for": "203.0.113.42, 198.51.100.2",
+ "x-real-ip": "192.0.2.5",
+ },
+ });
+
+ expect(getClientIp(request)).toBe("203.0.113.42");
+ });
+
+ it("does not let unauthenticated guild ids create distinct persistent buckets", () => {
+ const headers = { "x-real-ip": "203.0.113.42" };
+ const first = new NextRequest(
+ "http://localhost:3000/api/training/entries?guildId=1512918151313231984",
+ { headers },
+ );
+ const second = new NextRequest(
+ "http://localhost:3000/api/training/entries?guildId=9999999999999999999",
+ { headers },
+ );
+
+ expect(buildPreAuthRateLimitKey(first, "training-write")).toBe(
+ buildPreAuthRateLimitKey(second, "training-write"),
+ );
+ });
+});
diff --git a/apps/web/unit/request-security.test.ts b/apps/web/unit/request-security.test.ts
new file mode 100644
index 0000000..5473d1d
--- /dev/null
+++ b/apps/web/unit/request-security.test.ts
@@ -0,0 +1,40 @@
+import { NextRequest } from "next/server";
+import { afterEach, describe, expect, it } from "vitest";
+import { hasTrustedMutationOrigin } from "../lib/request-security";
+
+afterEach(() => {
+ delete process.env.NEXTAUTH_URL;
+});
+
+describe("hasTrustedMutationOrigin", () => {
+ it("accepts the request's own origin", () => {
+ const request = new NextRequest("https://piphacklup.test/api/remove", {
+ headers: { origin: "https://piphacklup.test" },
+ });
+
+ expect(hasTrustedMutationOrigin(request)).toBe(true);
+ });
+
+ it("accepts the configured public origin behind a proxy", () => {
+ process.env.NEXTAUTH_URL = "https://piphacklup.vercel.app";
+ const request = new NextRequest("http://internal.test/api/remove", {
+ headers: { origin: "https://piphacklup.vercel.app" },
+ });
+
+ expect(hasTrustedMutationOrigin(request)).toBe(true);
+ });
+
+ it("rejects missing, malformed, and cross-site origins", () => {
+ const missing = new NextRequest("https://piphacklup.test/api/remove");
+ const malformed = new NextRequest("https://piphacklup.test/api/remove", {
+ headers: { origin: "not a URL" },
+ });
+ const crossSite = new NextRequest("https://piphacklup.test/api/remove", {
+ headers: { origin: "https://attacker.example" },
+ });
+
+ expect(hasTrustedMutationOrigin(missing)).toBe(false);
+ expect(hasTrustedMutationOrigin(malformed)).toBe(false);
+ expect(hasTrustedMutationOrigin(crossSite)).toBe(false);
+ });
+});
diff --git a/apps/web/unit/training-import.test.ts b/apps/web/unit/training-import.test.ts
new file mode 100644
index 0000000..9fcc5b7
--- /dev/null
+++ b/apps/web/unit/training-import.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import {
+ maximumTrainingImportEntries,
+ parseTrainingImport,
+} from "../lib/training-import";
+
+describe("training import limits", () => {
+ it("rejects more than 50 parsed entries instead of silently truncating", () => {
+ const importText = Array.from(
+ { length: maximumTrainingImportEntries + 1 },
+ (_, index) => `Question ${index + 1} | Answer ${index + 1}`,
+ ).join("\n");
+
+ expect(parseTrainingImport(importText, "none")).toEqual({
+ ok: false,
+ error: "training_import_too_many_entries",
+ });
+ });
+
+ it("accepts exactly 50 parsed entries without dropping any", () => {
+ const importText = Array.from(
+ { length: maximumTrainingImportEntries },
+ (_, index) => `Question ${index + 1} | Answer ${index + 1}`,
+ ).join("\n");
+
+ const result = parseTrainingImport(importText, "staff");
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.entries).toHaveLength(maximumTrainingImportEntries);
+ expect(result.entries.at(-1)?.answer).toBe("Answer 50");
+ }
+ });
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index d886db6..856404f 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
- include: ["unit/**/*.test.ts", "unit/**/*.test.tsx"]
- }
+ include: ["unit/**/*.test.ts", "unit/**/*.test.tsx"],
+ },
});
diff --git a/docs/deployment.md b/docs/deployment.md
index e5e7781..f11c7fd 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -6,11 +6,11 @@ Public links:
- Website: https://piphacklup.vercel.app
- GitHub repo: https://github.com/rupayon123/PipHackLup
-- Add to Discord: https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1117094267958
+- Add to Discord: https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1099914365968
-## Dashboard: Vercel Hobby
+## Dashboard: Vercel
-Deploy `apps/web` as the Vercel project root.
+Connect the repository to Vercel and keep the repository root as the project root. The checked-in `vercel.json` installs the pinned pnpm version, builds `@piphacklup/web`, and serves `apps/web/.next`.
Environment variables:
@@ -19,8 +19,12 @@ Environment variables:
- `NEXTAUTH_SECRET`
- `DISCORD_CLIENT_ID`
- `DISCORD_CLIENT_SECRET`
+- `DISCORD_TOKEN`
+- optional `DISCORD_INSTALL_PERMISSIONS`
+
+The dashboard uses Discord OAuth to show only servers the connected account owns or can manage. `DISCORD_TOKEN` is server-side only and lets the dashboard verify installations, list setup options, and remove the bot after an exact-name confirmation. Website training and `/train` share the same guild-scoped Q&A entries and escalation settings. Missing OAuth or database configuration disables protected flows; there is no production preview-data fallback.
-The `/training` page uses Discord OAuth to show the connected organizer account and the servers they can manage. With `DATABASE_URL` configured, website training and `/train` slash-command training share the same Postgres-backed Q&A entries and escalation settings. Without `DATABASE_URL`, the trainer stays in preview mode.
+Scope the live Discord credentials, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, and production `DATABASE_URL` to **Production**. Do not give arbitrary branch previews access to the production bot token or database. Use a separate staging Discord app/database for authenticated preview testing; unauthenticated visual previews need neither.
## Database: Neon Free
@@ -38,16 +42,18 @@ Apply migrations:
pnpm --filter @piphacklup/db db:migrate
```
-## Bot: Oracle Cloud Always Free
+Apply every committed migration before promoting the web or bot release. Use the pooled Neon connection string for runtime traffic and keep it only in hosting environment variables.
+
+## Bot: long-running Node host
-Use an Ampere A1 Always Free VM for the bot process. Install Docker or Node 24 with Corepack.
+The gateway connection cannot run inside a request-based Vercel Function. Use a long-running Node 24 host. Oracle Cloud Ampere A1 remains an Always Free option when capacity is available; a paid container host is easier operationally if reliable capacity matters. Install Docker or Node 24 with Corepack.
Basic Node path:
```bash
git clone https://github.com/rupayon123/PipHackLup.git
cd PipHackLup
-corepack enable
+npm install --global pnpm@10.25.0
pnpm install --frozen-lockfile
pnpm build
pnpm --filter @piphacklup/bot start
@@ -67,6 +73,7 @@ Required bot env:
- `DISCORD_CLIENT_ID`
- `DATABASE_URL`
- `PORT=8787`
+- `PIPHACKLUP_PUBLIC_URL=https://piphacklup.vercel.app`
- `PIPHACKLUP_AMBIENT_QA_ENABLED=false`
Keep ambient Q&A disabled unless you have enabled the Discord Message Content intent and want the bot to answer when mentioned in normal chat messages. Slash-command Q&A through `/ask` works without Message Content intent.
@@ -77,6 +84,17 @@ Health check:
curl http://localhost:8787/health
```
+Do not route traffic to the bot until `/health` returns 200. A 503 means database configuration or startup hydration is not ready. Configure the host to restart the process after crashes and deploy only one command-registration job at a time.
+
+## Release order
+
+1. Create the production database and apply all committed migrations.
+2. Configure Vercel production environment variables and deploy the web app.
+3. Add the exact callback `https://piphacklup.vercel.app/api/auth/discord/callback` in the Discord Developer Portal.
+4. Configure the bot host with the same `DATABASE_URL`, then register commands and start the bot.
+5. Verify `/health`, Discord login, managed-server discovery, guild-locked install, setup, Q&A, queues, teams, moderation, export, removal, and reinstall in an isolated test server.
+6. Promote the release only after the automated and isolated-server checks both pass.
+
## Repo creation note
The public repository is live at `rupayon123/PipHackLup`. Keep the repo public, MIT licensed, and linked to `https://piphacklup.vercel.app` for discovery.
diff --git a/docs/discord-setup.md b/docs/discord-setup.md
index 8917db2..8269bf2 100644
--- a/docs/discord-setup.md
+++ b/docs/discord-setup.md
@@ -5,7 +5,7 @@ Public app name: `PipHackLup`
Public install link:
```text
-https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1117094267958
+https://discord.com/oauth2/authorize?client_id=1512918151313231983&scope=bot+applications.commands&permissions=1099914365968
```
## 1. Create the app
@@ -42,39 +42,45 @@ Recommended bot permissions:
- View Channels
- Send Messages
- Embed Links
-- Attach Files
- Read Message History
- Manage Roles
- Manage Nicknames
- Manage Channels
-- Manage Threads
- Moderate Members
-- Manage Guild
-- Kick Members and Ban Members only if you want live kick/ban actions
+
+PipHackLup does not request Kick Members or Ban Members. Its moderation action is a Discord timeout, protected by Moderate Members and staff RBAC.
## 4. Register commands
For a test server, set `DISCORD_TEST_GUILD_ID` so commands register instantly:
```bash
-pnpm --filter @piphacklup/bot deploy:commands
+npx --yes pnpm@10.25.0 --filter @piphacklup/bot deploy:commands
```
+After the isolated release test passes, run the same command once with `DISCORD_TEST_GUILD_ID` unset to publish the commands globally. Do not register global commands from multiple deploy jobs at the same time.
+
Then run the bot:
```bash
-pnpm dev:bot
+npx --yes pnpm@10.25.0 dev:bot
```
-## 5. Test server demo
-
-1. Invite the bot to a new test server.
-2. Run `/setup`.
-3. Run `/train settings` to set the staff role, mentor role, help channel, and confidence threshold.
-4. Run `/train add` or `/train import` with schedule, venue, prizes, judging, team, and rules details.
-5. Open `https://piphacklup.vercel.app/training`, connect Discord, pick the test server, add a web training entry, and preview an answer.
-6. Ask a participant question with `/ask`.
-7. Run `/onboard checklist`.
-8. Create a profile with `/team profile`.
-9. Create help tickets with `/queue open`.
-10. Create a report with `/mod report`.
+## 5. Isolated release test
+
+Use a new server that contains no real participant data and no unrelated bots.
+
+1. Sign in at `https://piphacklup.vercel.app/dashboard`; confirm only servers you own or can manage are listed.
+2. Choose the isolated server and use **Add to this server**. Confirm Discord locks the install to the selected server and the dashboard recognizes it after returning.
+3. Run `/setup`; verify the expected roles, channels, and panels are created once and a second run safely reuses them.
+4. Run `/train settings`, `/train add`, and `/train import`; confirm the website shows the same server-specific entries and settings.
+5. Ask a known question with `/ask`, then ask an uncertain, safety-sensitive, and prompt-injection-style question. Known content should answer; suspicious or low-confidence content should escalate to staff.
+6. Run `/onboard checklist` as a participant. In gated mode, set a nickname, click **Acknowledge rules**, verify the participant role is granted and newcomer role removed, and confirm gated channels become visible.
+7. Create profiles, recruiting teams, and matches with `/team`; verify the dashboard reflects only this server.
+8. Open, claim, escalate, and close each `/queue` type with participant and staff accounts. Confirm unauthorized members cannot perform staff transitions.
+9. Create and review `/mod` cases; confirm staff permission checks, audit events, and safe error messages.
+10. Export CSV and verify spreadsheet-looking content cannot become a formula.
+11. Restart the bot and confirm setup, profiles, teams, tickets, cases, Q&A, and installation state survive.
+12. Remove the bot from the dashboard using the exact server-name confirmation. Confirm another managed server is unchanged, then reinstall and verify retained event data is available.
+
+Record pass/fail evidence for every step. Do not use a production community server for release testing.
diff --git a/docs/product-spec.md b/docs/product-spec.md
index 09e1c1b..f3c7c4e 100644
--- a/docs/product-spec.md
+++ b/docs/product-spec.md
@@ -18,7 +18,7 @@ Built-in queues: mentor, tech, staff follow-up, judging. Tickets support open, c
### Staff-Trained Q&A
-Staff can train PipHackLup with hackathon details from Discord using `/train add` and `/train import`, or from the website trainer after linking a Discord organizer account and selecting a managed server. Participants can ask `/ask` questions in chat, and PipHackLup answers from the staff-approved knowledge base. Low-confidence, mentor-needed, safety, conduct, judging, and staff-needed questions create follow-up tickets and ping the configured role in the help channel.
+Staff can train PipHackLup with hackathon details from Discord using `/train add` and `/train import`, or from the website trainer after linking a Discord organizer account and selecting a managed server. Participants can ask `/ask` questions in chat, and PipHackLup answers from the staff-approved knowledge base. Low-confidence, mentor-needed, safety, conduct, judging, and staff-needed questions create durable follow-up tickets. Private or staff-sensitive escalations share full details only in a verified staff-private channel; any public mentor notification is redacted and never falls back with the participant's question or identity.
### Teams
@@ -30,7 +30,16 @@ Reports and staff actions create cases. Discord AutoMod templates are suggested
### Dashboard
-The dashboard shows operational state for setup, Q&A training, queues, teams, moderation, and CSV exports.
+Organizers sign in with Discord, see only servers they own or can manage, choose one explicit workspace, and add, manage, or remove PipHackLup for that server. The dashboard shows real guild-scoped setup, Q&A training, queues, teams, moderation, and CSV exports. Protected pages fail closed when identity, permission, installation, or database state cannot be verified; they never substitute demo records.
+
+### Account and server boundaries
+
+- Each browser session belongs to one Discord account and expires within 12 hours.
+- Manage Server-equivalent access is refreshed before privileged dashboard actions.
+- A requested server that is missing or no longer manageable never falls back to another server.
+- Install links are locked to the server the organizer selected.
+- Removing the bot affects only that server and retains saved event data for an intentional reinstall.
+- Bot operational state and dashboard data survive process restarts in the shared database.
## Not in V1
diff --git a/docs/security-baseline.md b/docs/security-baseline.md
index 53de811..1899496 100644
--- a/docs/security-baseline.md
+++ b/docs/security-baseline.md
@@ -13,15 +13,16 @@ PipHackLup is public and handles Discord organizer workflows, so new features sh
## Current Implementation
-- Web rate limiting lives in `apps/web/lib/rate-limit.ts`.
+- Web rate limiting lives in `apps/web/lib/rate-limit.ts`, uses atomic Postgres buckets in configured deployments, and removes expired rows before each upsert.
- Dashboard RBAC lives in `apps/web/lib/dashboard-security.ts`.
- Bot command throttling lives in `apps/bot/src/lib/rate-limit.ts`.
- Prompt-injection filtering lives in `packages/core/src/security.ts`.
- Staff-trained Q&A uses the filter before saving training and before answering suspicious questions.
+- Sensitive Q&A details are sent only to a live-verified staff-private channel; public mentor notifications are redacted.
## Future Hardening
-- Replace in-memory rate limiting with Redis or another shared store when traffic spans multiple runtime instances.
-- Add audit events for dashboard training writes and bot staff actions.
-- Add configured staff-role RBAC checks to dashboard flows once server settings support role sync.
+- Consider a dedicated rate-limit service only if Postgres bucket traffic becomes a measurable bottleneck.
+- Keep audit events and primary writes atomic where the workflow cannot safely tolerate a post-commit audit warning.
+- Keep configured staff-role and Discord-permission checks aligned as new dashboard or bot actions are added.
- Add security regression tests for any new API route or bot command.
diff --git a/package.json b/package.json
index 6e0859d..9e9755d 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,13 @@
"url": "https://github.com/rupayon123/PipHackLup/issues"
},
"packageManager": "pnpm@10.25.0",
+ "engines": {
+ "node": ">=22.12.0 <25"
+ },
"scripts": {
"build": "pnpm -r build",
"check": "pnpm -r check",
+ "check:migrations": "node scripts/verify-migration-history.mjs && pnpm --filter @piphacklup/db exec drizzle-kit check",
"dev": "pnpm --parallel --filter @piphacklup/bot --filter @piphacklup/web dev",
"dev:bot": "pnpm --filter @piphacklup/bot dev",
"dev:web": "pnpm --filter @piphacklup/web dev",
@@ -27,7 +31,7 @@
},
"devDependencies": {
"@types/node": "^25.0.1",
- "next": "^16.2.7",
+ "next": "^16.2.11",
"prettier": "^3.7.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -37,7 +41,10 @@
"pnpm": {
"overrides": {
"esbuild": "0.25.12",
- "postcss": "8.5.10"
+ "postcss": "8.5.26",
+ "nanoid": "3.3.18",
+ "sharp": "0.35.0",
+ "undici": "6.28.0"
}
}
}
diff --git a/packages/core/src/csv.ts b/packages/core/src/csv.ts
index f1ebc74..73580bc 100644
--- a/packages/core/src/csv.ts
+++ b/packages/core/src/csv.ts
@@ -2,7 +2,9 @@ export type CsvRow = Record;
export function toCsv(rows: CsvRow[], columns = inferColumns(rows)): string {
const header = columns.map(escapeCsvCell).join(",");
- const body = rows.map((row) => columns.map((column) => escapeCsvCell(row[column] ?? "")).join(","));
+ const body = rows.map((row) =>
+ columns.map((column) => escapeCsvCell(row[column] ?? "")).join(","),
+ );
return [header, ...body].join("\n");
}
@@ -12,7 +14,9 @@ export function fromCsv(csv: string): CsvRow[] {
if (!header) return [];
return rows.map((row) =>
- Object.fromEntries(header.map((column, index) => [column, row[index] ?? ""]))
+ Object.fromEntries(
+ header.map((column, index) => [column, row[index] ?? ""]),
+ ),
);
}
@@ -21,8 +25,9 @@ function inferColumns(rows: CsvRow[]): string[] {
}
function escapeCsvCell(value: string): string {
- if (!/[",\n\r]/.test(value)) return value;
- return `"${value.replaceAll('"', '""')}"`;
+ const safeValue = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
+ if (!/[",\n\r]/.test(safeValue)) return safeValue;
+ return `"${safeValue.replaceAll('"', '""')}"`;
}
function parseCsvRecords(input: string): string[][] {
diff --git a/packages/core/src/ids.ts b/packages/core/src/ids.ts
index cf069bc..bd6adb0 100644
--- a/packages/core/src/ids.ts
+++ b/packages/core/src/ids.ts
@@ -5,7 +5,9 @@ export function createId(prefix: string, seed = cryptoSafeSeed()): string {
function cryptoSafeSeed(): string {
const random = globalThis.crypto?.getRandomValues?.(new Uint32Array(2));
if (random) {
- return Array.from(random, (part) => part.toString(36).padStart(6, "0")).join("");
+ return Array.from(random, (part) =>
+ part.toString(36).padStart(6, "0"),
+ ).join("");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
diff --git a/packages/core/src/knowledge.ts b/packages/core/src/knowledge.ts
index 6b4bdda..37cbf2c 100644
--- a/packages/core/src/knowledge.ts
+++ b/packages/core/src/knowledge.ts
@@ -132,9 +132,7 @@ export function answerHackathonQuestion(
): KnowledgeAnswerResult {
const mergedSettings = { ...defaultKnowledgeSettings, ...settings };
const cleanQuestion = question.trim();
- const safetyFindings = analyzePromptInjectionRisk(cleanQuestion).filter(
- (finding) => finding.severity === "high",
- );
+ const safetyFindings = analyzePromptInjectionRisk(cleanQuestion);
if (safetyFindings.length > 0) {
return {
diff --git a/packages/core/src/moderation.ts b/packages/core/src/moderation.ts
index 16fdeef..1bfff8f 100644
--- a/packages/core/src/moderation.ts
+++ b/packages/core/src/moderation.ts
@@ -20,7 +20,9 @@ export interface AutoModRuleTemplate {
exampleTerms: string[];
}
-export function createModerationCase(input: CreateModerationCaseInput): ModerationCase {
+export function createModerationCase(
+ input: CreateModerationCaseInput,
+): ModerationCase {
const now = input.now ?? new Date().toISOString();
const moderationCase: ModerationCase = {
id: createId("case"),
@@ -30,18 +32,19 @@ export function createModerationCase(input: CreateModerationCaseInput): Moderati
reason: input.reason.trim(),
status: input.action === "report" ? "open" : "resolved",
createdAt: now,
- updatedAt: now
+ updatedAt: now,
};
if (input.reporterId) moderationCase.reporterId = input.reporterId;
if (input.moderatorId) moderationCase.moderatorId = input.moderatorId;
- if (input.evidenceMessageUrl) moderationCase.evidenceMessageUrl = input.evidenceMessageUrl;
+ if (input.evidenceMessageUrl)
+ moderationCase.evidenceMessageUrl = input.evidenceMessageUrl;
return moderationCase;
}
export function resolveModerationCase(
moderationCase: ModerationCase,
moderatorId: Snowflake,
- now = new Date().toISOString()
+ now = new Date().toISOString(),
): ModerationCase {
return { ...moderationCase, moderatorId, status: "resolved", updatedAt: now };
}
@@ -53,21 +56,26 @@ export function defaultAutoModTemplates(): AutoModRuleTemplate[] {
goal: "Block common scam, fake Nitro, wallet drain, and suspicious invite bait before it reaches participants.",
trigger: "keyword",
recommendedAction: "block_message",
- exampleTerms: ["free nitro", "airdrop claim", "verify wallet", "discord.gift"]
+ exampleTerms: [
+ "free nitro",
+ "airdrop claim",
+ "verify wallet",
+ "discord.gift",
+ ],
},
{
name: "Mass mention protection",
goal: "Stop raids or accidental everyone/here-style disruption during event day.",
trigger: "mention_spam",
recommendedAction: "block_message",
- exampleTerms: ["max_mentions: 8"]
+ exampleTerms: ["max_mentions: 8"],
},
{
name: "Spammy message bursts",
goal: "Reduce repeated text spam in public channels while keeping help channels usable.",
trigger: "spam",
recommendedAction: "send_alert",
- exampleTerms: ["repeated text", "excessive caps", "message burst"]
- }
+ exampleTerms: ["repeated text", "excessive caps", "message burst"],
+ },
];
}
diff --git a/packages/core/src/onboarding.ts b/packages/core/src/onboarding.ts
index d54c10f..a8130d2 100644
--- a/packages/core/src/onboarding.ts
+++ b/packages/core/src/onboarding.ts
@@ -2,7 +2,7 @@ import type { EventConfig, OnboardingState, OnboardingStep } from "./types.js";
export function buildOnboardingSteps(
config: Pick,
- state: OnboardingState
+ state: OnboardingState,
): OnboardingStep[] {
const gated = config.onboardingMode === "gated";
return [
@@ -11,42 +11,49 @@ export function buildOnboardingSteps(
label: "Read the event rules",
complete: state.hasReadRules,
required: gated,
- actionHint: "Open the rules channel and confirm you understand the event expectations."
+ actionHint:
+ "Open the rules channel and confirm you understand the event expectations.",
},
{
id: "nickname",
label: "Set your server nickname",
complete: state.hasNickname,
required: gated,
- actionHint: "Use /onboard nickname so staff and teammates can recognize you."
+ actionHint:
+ "Use /onboard nickname so staff and teammates can recognize you.",
},
{
id: "roles",
- label: "Pick the right roles",
+ label: "Verify your participant access",
complete: state.hasParticipantRole,
required: gated,
- actionHint: "Choose participant, mentor, judge, and notification roles from the onboarding panel."
+ actionHint:
+ "Read the rules and use the server acknowledgement control, or ask an organizer to verify the participant role.",
},
{
id: "profile",
label: "Create your hacker profile",
complete: state.hasProfile,
required: false,
- actionHint: "Share your skills, interests, timezone, and whether you are beginner-friendly."
+ actionHint:
+ "Share your skills, interests, timezone, and whether you are beginner-friendly.",
},
{
id: "team",
label: "Find or create a team",
complete: state.hasTeam,
required: false,
- actionHint: "Use /team to browse recruiting teams or join the matching pool."
- }
+ actionHint:
+ "Use /team to browse recruiting teams or join the matching pool.",
+ },
];
}
export function onboardingProgress(steps: OnboardingStep[]): number {
if (steps.length === 0) return 100;
- return Math.round((steps.filter((step) => step.complete).length / steps.length) * 100);
+ return Math.round(
+ (steps.filter((step) => step.complete).length / steps.length) * 100,
+ );
}
export function canAccessGatedServer(steps: OnboardingStep[]): boolean {
diff --git a/packages/core/src/queues.ts b/packages/core/src/queues.ts
index 6865e15..59c9f66 100644
--- a/packages/core/src/queues.ts
+++ b/packages/core/src/queues.ts
@@ -24,7 +24,7 @@ export function createQueueTicket(input: CreateTicketInput): QueueTicket {
description: input.description.trim(),
priority: input.priority ?? 1,
createdAt: now,
- updatedAt: now
+ updatedAt: now,
};
if (input.teamId) ticket.teamId = input.teamId;
@@ -34,13 +34,16 @@ export function createQueueTicket(input: CreateTicketInput): QueueTicket {
export function claimTicket(
ticket: QueueTicket,
mentorId: Snowflake,
- now = new Date().toISOString()
+ now = new Date().toISOString(),
): QueueTicket {
assertTransition(ticket, ["open", "escalated"], "claim");
return { ...ticket, status: "claimed", assignedTo: mentorId, updatedAt: now };
}
-export function escalateTicket(ticket: QueueTicket, now = new Date().toISOString()): QueueTicket {
+export function escalateTicket(
+ ticket: QueueTicket,
+ now = new Date().toISOString(),
+): QueueTicket {
assertTransition(ticket, ["open", "claimed"], "escalate");
return { ...ticket, status: "escalated", priority: 3, updatedAt: now };
}
@@ -48,30 +51,37 @@ export function escalateTicket(ticket: QueueTicket, now = new Date().toISOString
export function closeTicket(
ticket: QueueTicket,
now = new Date().toISOString(),
- transcriptChannelId?: Snowflake
+ transcriptChannelId?: Snowflake,
): QueueTicket {
assertTransition(ticket, ["open", "claimed", "escalated"], "close");
const next: QueueTicket = {
...ticket,
status: "closed",
updatedAt: now,
- closedAt: now
+ closedAt: now,
};
if (transcriptChannelId) next.transcriptChannelId = transcriptChannelId;
return next;
}
-export function cancelTicket(ticket: QueueTicket, now = new Date().toISOString()): QueueTicket {
+export function cancelTicket(
+ ticket: QueueTicket,
+ now = new Date().toISOString(),
+): QueueTicket {
assertTransition(ticket, ["open", "claimed", "escalated"], "cancel");
return { ...ticket, status: "canceled", updatedAt: now, closedAt: now };
}
export function orderQueue(tickets: QueueTicket[]): QueueTicket[] {
return tickets
- .filter((ticket) => ticket.status === "open" || ticket.status === "escalated")
+ .filter(
+ (ticket) => ticket.status === "open" || ticket.status === "escalated",
+ )
.toSorted((left, right) => {
- if (left.status !== right.status) return left.status === "escalated" ? -1 : 1;
- if (left.priority !== right.priority) return right.priority - left.priority;
+ if (left.status !== right.status)
+ return left.status === "escalated" ? -1 : 1;
+ if (left.priority !== right.priority)
+ return right.priority - left.priority;
return left.createdAt.localeCompare(right.createdAt);
});
}
@@ -79,15 +89,21 @@ export function orderQueue(tickets: QueueTicket[]): QueueTicket[] {
export function estimateWaitMinutes(
ticketsAhead: QueueTicket[],
activeStaffCount: number,
- averageTicketMinutes = 12
+ averageTicketMinutes = 12,
): number {
if (ticketsAhead.length === 0) return 0;
const staff = Math.max(activeStaffCount, 1);
return Math.ceil((ticketsAhead.length * averageTicketMinutes) / staff);
}
-function assertTransition(ticket: QueueTicket, allowed: QueueTicket["status"][], action: string): void {
+function assertTransition(
+ ticket: QueueTicket,
+ allowed: QueueTicket["status"][],
+ action: string,
+): void {
if (!allowed.includes(ticket.status)) {
- throw new Error(`Cannot ${action} ticket ${ticket.id} while status is ${ticket.status}`);
+ throw new Error(
+ `Cannot ${action} ticket ${ticket.id} while status is ${ticket.status}`,
+ );
}
}
diff --git a/packages/core/src/security.ts b/packages/core/src/security.ts
index 8f78d02..75de4c3 100644
--- a/packages/core/src/security.ts
+++ b/packages/core/src/security.ts
@@ -90,7 +90,7 @@ export function assertKnowledgeTrainingIsSafe(input: {
}): void {
const findings = analyzePromptInjectionRisk(
`${input.title.trim()}\n${input.answer.trim()}`,
- ).filter((finding) => finding.severity === "high");
+ );
if (findings.length > 0) {
throw new KnowledgeSafetyError(findings);
diff --git a/packages/core/src/teams.ts b/packages/core/src/teams.ts
index 0e3777c..6939414 100644
--- a/packages/core/src/teams.ts
+++ b/packages/core/src/teams.ts
@@ -30,7 +30,7 @@ export function createTeam(input: CreateTeamInput): TeamProfile {
desiredSkills: normalizeTags(input.desiredSkills ?? []),
maxSize: input.maxSize ?? 4,
createdAt: now,
- updatedAt: now
+ updatedAt: now,
};
if (input.projectIdea) team.projectIdea = input.projectIdea.trim();
return team;
@@ -39,7 +39,7 @@ export function createTeam(input: CreateTeamInput): TeamProfile {
export function addMemberToTeam(
team: TeamProfile,
memberId: Snowflake,
- now = new Date().toISOString()
+ now = new Date().toISOString(),
): TeamProfile {
if (team.memberIds.includes(memberId)) return team;
if (team.memberIds.length >= team.maxSize) {
@@ -51,17 +51,22 @@ export function addMemberToTeam(
...team,
memberIds,
status: memberIds.length >= team.maxSize ? "full" : "recruiting",
- updatedAt: now
+ updatedAt: now,
};
}
-export function scoreMemberForTeam(member: MemberProfile, team: TeamProfile): number {
+export function scoreMemberForTeam(
+ member: MemberProfile,
+ team: TeamProfile,
+): number {
const skills = new Set(normalizeTags(member.skills));
const interests = new Set(normalizeTags(member.interests));
const desired = normalizeTags(team.desiredSkills);
const skillMatches = desired.filter((skill) => skills.has(skill)).length;
const ideaTokens = normalizeTags(team.projectIdea?.split(/\W+/) ?? []);
- const interestMatches = ideaTokens.filter((token) => interests.has(token)).length;
+ const interestMatches = ideaTokens.filter((token) =>
+ interests.has(token),
+ ).length;
const beginnerBonus = member.beginnerFriendly ? 1 : 0;
return skillMatches * 4 + interestMatches * 2 + beginnerBonus;
@@ -70,7 +75,7 @@ export function scoreMemberForTeam(member: MemberProfile, team: TeamProfile): nu
export function suggestTeamMatches(
members: MemberProfile[],
teams: TeamProfile[],
- maxSuggestions = 6
+ maxSuggestions = 6,
): MatchResult[] {
const lookingMembers = members.filter((member) => member.lookingForTeam);
const recruitingTeams = teams.filter((team) => team.status === "recruiting");
@@ -83,14 +88,22 @@ export function suggestTeamMatches(
const ranked = lookingMembers
.filter((member) => !team.memberIds.includes(member.userId))
.map((member) => ({ member, score: scoreMemberForTeam(member, team) }))
- .toSorted((left, right) => right.score - left.score || left.member.updatedAt.localeCompare(right.member.updatedAt))
+ .toSorted(
+ (left, right) =>
+ right.score - left.score ||
+ left.member.updatedAt.localeCompare(right.member.updatedAt),
+ )
.slice(0, slots);
const addedMemberIds = ranked.map(({ member }) => member.userId);
const covered = new Set(
- ranked.flatMap(({ member }) => normalizeTags(member.skills)).concat(team.desiredSkills)
+ ranked
+ .flatMap(({ member }) => normalizeTags(member.skills))
+ .concat(team.desiredSkills),
+ );
+ const missingSkills = normalizeTags(team.desiredSkills).filter(
+ (skill) => !covered.has(skill),
);
- const missingSkills = normalizeTags(team.desiredSkills).filter((skill) => !covered.has(skill));
const score = ranked.reduce((total, item) => total + item.score, 0);
return [{ teamId: team.id, addedMemberIds, missingSkills, score }];
})
@@ -100,5 +113,7 @@ export function suggestTeamMatches(
}
export function normalizeTags(tags: string[]): string[] {
- return [...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))];
+ return [
+ ...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)),
+ ];
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index c4d2006..a07099f 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -51,6 +51,12 @@ export interface EventConfig {
moderationLog?: Snowflake;
auditLog?: Snowflake;
};
+ resources?: {
+ eventCategoryId?: Snowflake;
+ onboardingPanelMessageId?: Snowflake;
+ helpPanelMessageId?: Snowflake;
+ teamsPanelMessageId?: Snowflake;
+ };
}
export interface MemberProfile {
@@ -115,7 +121,14 @@ export interface AuditEvent {
guildId: Snowflake;
actorId: Snowflake;
action: string;
- targetType: "guild" | "member" | "team" | "ticket" | "case" | "settings";
+ targetType:
+ | "guild"
+ | "member"
+ | "team"
+ | "ticket"
+ | "case"
+ | "knowledge"
+ | "settings";
targetId: string;
metadata: Record