diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx new file mode 100644 index 0000000..6117691 --- /dev/null +++ b/apps/web/src/app/dashboard/page.tsx @@ -0,0 +1,330 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { + AccountControl, + ApiKeysControl, + CommandCopy, + EventInspector, + HookActions, +} from "@/components/dashboard-controls"; +import { ArrowIcon, CheckIcon, GridIcon, HookIcon } from "@/components/icons"; +import { auth } from "@/lib/auth"; +import { + accountStore, + apiTokenStore, + eventStore, + hookStore, +} from "@/lib/server-database"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function formatTime(date: Date) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +function relativeTime(date: Date) { + const seconds = Math.round((date.getTime() - Date.now()) / 1_000); + const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + if (Math.abs(seconds) < 60) return formatter.format(seconds, "second"); + const minutes = Math.round(seconds / 60); + if (Math.abs(minutes) < 60) return formatter.format(minutes, "minute"); + const hours = Math.round(minutes / 60); + if (Math.abs(hours) < 24) return formatter.format(hours, "hour"); + return formatter.format(Math.round(hours / 24), "day"); +} + +function readableBody( + body: Buffer, + headersValue: Record, +) { + const contentType = String(headersValue["content-type"] ?? ""); + const text = body.toString("utf8"); + if (contentType.includes("json")) { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + return /^[\s\x20-\x7e]*$/.test(text) ? text : body.toString("base64"); +} + +function statusLabel(status: string) { + if (status === "in_flight") return "In flight"; + return status[0]!.toUpperCase() + status.slice(1); +} + +export default async function DashboardPage({ + searchParams, +}: { + searchParams: Promise<{ + hook?: string; + event?: string; + status?: string; + }>; +}) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/sign-in"); + const account = await accountStore.ensurePersonalAccount({ + userId: session.user.id, + name: session.user.name, + }); + const params = await searchParams; + const [hooks, tokens] = await Promise.all([ + hookStore.listHooks({ accountId: account.accountId }), + apiTokenStore.listTokens({ accountId: account.accountId }), + ]); + const selectedHook = + hooks.find((hook) => hook.hookId === params.hook) ?? hooks[0]; + const allEvents = selectedHook + ? await eventStore.listRecentEvents({ + accountId: account.accountId, + hookId: selectedHook.hookId, + limit: 50, + }) + : []; + const statusFilter = ["pending", "delivered"].includes(params.status ?? "") + ? params.status + : undefined; + const events = statusFilter + ? allEvents.filter((event) => event.status === statusFilter) + : allEvents; + const selectedEventId = + events.find((event) => event.eventId === params.event)?.eventId ?? + events[0]?.eventId; + const selectedEvent = selectedEventId + ? await eventStore.getEvent({ + accountId: account.accountId, + eventId: selectedEventId, + }) + : null; + const command = selectedHook + ? `hooky listen --to http://localhost:3000/webhooks --hook ${selectedHook.name}` + : "hooky listen --to http://localhost:3000/webhooks --new local"; + + return ( +
+ + +
+
+
+

{selectedHook?.name ?? "Your hooks"}

+

+ {selectedHook ? ( + <> + Durable ingress {selectedHook.state} + + ) : ( + "Create an endpoint to begin." + )} +

+
+ +
+ +
+ + $ {command} + + +
+ +
+
+
+

Recent events

+ {selectedHook ? ( +
+ {[ + ["", "All"], + ["pending", "Pending"], + ["delivered", "Delivered"], + ].map(([value, label]) => ( + + {label} + + ))} +
+ ) : null} +
+ + {selectedHook && events.length ? ( +
+
+ Method / path + Received + Status + Attempts +
+ {events.map((event) => ( + + + + + {event.requestMethod} + + {event.requestPath} + + {relativeTime(event.receivedAt)} + + + {statusLabel(event.status)} + + {event.attemptCount} + + ))} +
+ ) : ( +
+ +

+ {selectedHook + ? "Waiting for the first event." + : "Create your first hook."} +

+

+ {selectedHook + ? "Send a webhook to this endpoint and it will appear here durably." + : "Hooky will create a public URL and keep every request until your CLI is ready."} +

+
+ )} + +
+ +

+ Install Hooky CLIbunx github:dak-engineering/hooky +

+ +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f25234e..a7ac9ab 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -43,7 +43,7 @@ a { text-decoration: none; } -main { +.landing-shell { display: flex; width: min(1180px, calc(100% - 48px)); min-height: 100vh; @@ -97,6 +97,19 @@ main { background: var(--surface); } +.landing-nav-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.sign-in-link { + padding: 10px 12px; + color: var(--muted); + font-size: 14px; + font-weight: 650; +} + .hero { display: grid; flex: 1; @@ -110,7 +123,7 @@ main { max-width: 580px; } -h1 { +.hero h1 { max-width: 700px; margin: 0; font-size: clamp(56px, 6.3vw, 92px); @@ -235,7 +248,7 @@ h1 { color: #f3bd61; } -footer { +.landing-footer { display: flex; min-height: 80px; align-items: center; @@ -245,11 +258,11 @@ footer { font-size: 13px; } -footer p { +.landing-footer p { margin: 0; } -footer span { +.landing-footer a { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } @@ -270,7 +283,7 @@ footer span { } @media (max-width: 560px) { - main { + .landing-shell { width: min(100% - 32px, 1180px); } @@ -283,7 +296,7 @@ footer span { padding: 52px 0 70px; } - h1 { + .hero h1 { font-size: clamp(48px, 15vw, 66px); } @@ -303,7 +316,7 @@ footer span { display: none; } - footer { + .landing-footer { align-items: flex-start; flex-direction: column; gap: 8px; @@ -319,3 +332,1003 @@ footer span { transition-duration: 0.01ms !important; } } + +/* Auth */ +.auth-shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 110px 24px 48px; + color: #eef2ef; + background: #080d0f; +} + +.auth-brand { + position: absolute; + top: 34px; + left: 38px; + display: inline-flex; + align-items: center; + gap: 11px; + font-size: 18px; + font-weight: 720; +} + +.auth-panel { + width: min(440px, 100%); +} + +.auth-panel h1 { + margin: 0; + font-size: clamp(38px, 5vw, 52px); + letter-spacing: -0.055em; + line-height: 1; +} + +.auth-panel > p { + margin: 18px 0 36px; + color: #8a9692; + font-size: 16px; + line-height: 1.6; +} + +.auth-form, +.modal-form { + display: grid; + gap: 18px; +} + +.auth-form label, +.modal-form label { + display: grid; + color: #b9c2bf; + font-size: 13px; + font-weight: 650; + gap: 8px; +} + +.auth-form input, +.modal-form input { + width: 100%; + min-height: 48px; + padding: 0 14px; + border: 1px solid #344046; + border-radius: 8px; + outline: none; + color: #eef2ef; + background: #0c1317; + font: inherit; + font-size: 15px; + transition: + border-color 140ms ease, + box-shadow 140ms ease; +} + +.auth-form input:focus, +.modal-form input:focus { + border-color: #3cdd78; + box-shadow: 0 0 0 3px rgb(60 221 120 / 14%); +} + +.button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 15px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 680; + transition: + background 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} + +.button:focus-visible, +.copy-button:focus-visible, +.sidebar-link:focus-visible, +.icon-button:focus-visible, +.account-control:focus-visible { + outline: 2px solid #3cdd78; + outline-offset: 2px; +} + +.button:disabled { + cursor: wait; + opacity: 0.55; +} + +.button-primary { + color: #06110a; + background: #3cdd78; +} + +.button-primary:hover:not(:disabled) { + background: #67e692; + transform: translateY(-1px); +} + +.button-primary-outline { + border-color: #3cdd78; + color: #62e58f; + background: transparent; +} + +.button-primary-outline:hover { + background: rgb(60 221 120 / 8%); +} + +.button-secondary { + border-color: #3a464c; + color: #eef2ef; + background: #0d1418; +} + +.button-secondary:hover:not(:disabled) { + border-color: #65716f; + background: #121b20; +} + +.auth-submit { + width: 100%; + min-height: 48px; + margin-top: 4px; +} + +.form-error { + margin: -4px 0 0; + color: #ff8d85; + font-size: 13px; +} + +.auth-switch { + margin: 4px 0 0; + color: #8a9692; + font-size: 13px; + text-align: center; +} + +.auth-switch a { + color: #62e58f; +} + +/* Dashboard */ +.dashboard-shell { + --dash-bg: #080d0f; + --dash-surface: #0c1317; + --dash-surface-strong: #11191d; + --dash-border: #263238; + --dash-text: #eef2ef; + --dash-muted: #8a9692; + --dash-accent: #3cdd78; + --dash-amber: #f4b91d; + display: grid; + min-height: 100vh; + color: var(--dash-text); + background: var(--dash-bg); + font-size: 14px; + grid-template-columns: 236px minmax(0, 1fr); +} + +.dashboard-sidebar { + position: sticky; + top: 0; + display: flex; + height: 100vh; + min-width: 0; + flex-direction: column; + padding: 27px 12px 14px; + border-right: 1px solid var(--dash-border); + background: #091014; +} + +.dashboard-brand { + display: inline-flex; + align-items: center; + gap: 10px; + margin: 0 12px 31px; + font-size: 18px; + font-weight: 730; + letter-spacing: -0.03em; +} + +.sidebar-navigation { + display: grid; + gap: 5px; +} + +.sidebar-link { + display: flex; + width: 100%; + min-height: 44px; + align-items: center; + gap: 12px; + padding: 0 14px; + border: 0; + border-radius: 7px; + color: #bdc6c3; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 14px; + text-align: left; +} + +.sidebar-link:hover, +.sidebar-link.active { + color: var(--dash-text); + background: #11191e; +} + +.sidebar-link.active { + box-shadow: inset 2px 0 var(--dash-accent); +} + +.sidebar-hooks { + margin-top: 28px; +} + +.sidebar-hooks > p { + margin: 0 14px 10px; + color: #5e6968; + font-size: 10px; + font-weight: 750; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.hook-link { + display: flex; + min-height: 54px; + align-items: center; + gap: 11px; + padding: 0 14px; + border-radius: 7px; + color: #aeb8b5; +} + +.hook-link:hover, +.hook-link.selected { + color: var(--dash-text); + background: #11191e; +} + +.hook-link.selected { + box-shadow: inset 2px 0 var(--dash-accent); +} + +.hook-link span { + overflow: hidden; + min-width: 0; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hook-link small { + display: block; + margin-top: 3px; + color: #687371; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.account-control { + display: grid; + width: 100%; + min-height: 58px; + margin-top: auto; + padding: 8px 10px; + border: 1px solid var(--dash-border); + border-radius: 8px; + color: var(--dash-text); + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; + text-align: left; + column-gap: 9px; + grid-template-columns: 34px 1fr; +} + +.account-control > span { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 7px; + color: #baf4cd; + background: #174e2d; + grid-row: span 2; +} + +.account-control small { + color: #6f7b78; + font-size: 10px; +} + +.dashboard-workspace { + min-width: 0; +} + +.dashboard-header { + display: flex; + min-height: 126px; + align-items: center; + justify-content: space-between; + padding: 26px 32px; +} + +.dashboard-header h1 { + margin: 0; + font-size: 30px; + letter-spacing: -0.04em; +} + +.dashboard-header p { + display: flex; + align-items: center; + gap: 9px; + margin: 9px 0 0; + color: #a5afac; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.dashboard-header p i { + width: 3px; + height: 3px; + border-radius: 50%; + background: #42614d; +} + +.dashboard-header p span { + color: var(--dash-accent); +} + +.header-actions { + display: flex; + gap: 10px; +} + +.command-strip { + display: flex; + min-height: 62px; + align-items: stretch; + margin: 0 32px 22px; + border: 1px solid var(--dash-border); + border-radius: 7px; +} + +.command-strip code { + overflow: hidden; + flex: 1; + padding: 21px 20px; + color: #d7ddda; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-strip code span { + color: var(--dash-accent); +} + +.copy-button { + display: inline-flex; + min-width: 94px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 14px; + border: 0; + border-left: 1px solid var(--dash-border); + color: #aab4b1; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.copy-button:hover { + color: var(--dash-text); + background: #11191e; +} + +.dashboard-content { + display: grid; + min-height: calc(100vh - 210px); + border-top: 1px solid var(--dash-border); + grid-template-columns: minmax(550px, 1.3fr) minmax(390px, 0.75fr); +} + +.events-region { + display: flex; + min-width: 0; + flex-direction: column; + padding: 24px 20px 0 32px; +} + +.events-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.events-heading h2, +.inspector-heading h2 { + margin: 0; + font-size: 19px; + letter-spacing: -0.025em; +} + +.status-filters { + display: flex; + border: 1px solid var(--dash-border); + border-radius: 7px; +} + +.status-filters a { + min-width: 70px; + padding: 9px 12px; + border-right: 1px solid var(--dash-border); + color: #8f9a97; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + text-align: center; +} + +.status-filters a:last-child { + border-right: 0; +} + +.status-filters a[aria-current="page"] { + color: var(--dash-text); + box-shadow: inset 0 0 0 1px var(--dash-accent); +} + +.events-table { + min-width: 0; +} + +.events-table-head, +.event-row-link { + display: grid; + align-items: center; + grid-template-columns: + minmax(245px, 1.5fr) minmax(118px, 0.8fr) minmax(118px, 0.8fr) + 72px; +} + +.events-table-head { + padding: 0 14px 11px; + color: #66726f; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.event-row-link { + min-height: 72px; + padding: 0 14px; + border-top: 1px solid var(--dash-border); + color: #d5dcda; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} + +.event-row-link:last-child { + border-bottom: 1px solid var(--dash-border); +} + +.event-row-link:hover { + background: #0d1519; +} + +.event-row-link.selected { + border: 1px solid rgb(60 221 120 / 72%); + border-radius: 5px; + background: #0d1619; +} + +.event-path { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-path svg { + width: 14px; + flex: 0 0 auto; +} + +.method-tag { + padding: 4px 7px; + border-radius: 4px; + color: var(--dash-accent); + background: rgb(60 221 120 / 10%); + font-size: 10px; +} + +.method-get { + color: #7ac9ff; + background: rgb(80 169 226 / 12%); +} + +.method-delete { + color: #ff8d85; + background: rgb(255 90 82 / 10%); +} + +.delivery-state { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--dash-accent); + font-size: 11px; +} + +.delivery-state svg { + width: 16px; +} + +.state-pending, +.state-in_flight { + color: var(--dash-amber); +} + +.state-dead { + color: #ff8d85; +} + +.events-empty, +.inspector-empty { + display: grid; + min-height: 300px; + place-items: center; + align-content: center; + color: #788481; + text-align: center; +} + +.events-empty svg { + width: 30px; + height: 30px; + color: var(--dash-accent); +} + +.events-empty h3 { + margin: 16px 0 7px; + color: var(--dash-text); + font-size: 16px; +} + +.events-empty p, +.inspector-empty p { + max-width: 390px; + margin: 0; + font-size: 12px; + line-height: 1.6; +} + +.cli-install { + display: flex; + min-height: 82px; + align-items: center; + gap: 13px; + margin: auto -20px 0 -32px; + padding: 0 18px 0 32px; + border-top: 1px solid var(--dash-border); +} + +.cli-install > span { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border: 1px solid var(--dash-border); + border-radius: 7px; + color: #c1cbc8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.cli-install p { + display: grid; + margin: 0; + color: #788481; + font-size: 11px; + gap: 5px; +} + +.cli-install code { + color: #d4dcda; +} + +.cli-install code::first-letter { + color: var(--dash-accent); +} + +.cli-install .copy-button { + margin-left: auto; + border-left: 0; +} + +.event-inspector { + min-width: 0; + padding: 26px 20px 32px; + border-left: 1px solid var(--dash-border); +} + +.inspector-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.inspector-heading p { + margin: 8px 0 0; + color: #8d9895; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.inspector-tabs { + display: flex; + gap: 26px; + margin-top: 25px; + border-bottom: 1px solid var(--dash-border); +} + +.inspector-tabs button { + padding: 0 2px 12px; + border: 0; + border-bottom: 2px solid transparent; + color: #899592; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.inspector-tabs button[aria-selected="true"] { + border-color: var(--dash-accent); + color: var(--dash-text); +} + +.payload-view { + overflow: auto; + min-height: 280px; + max-height: 420px; + margin: 0; + padding: 18px; + border: 1px solid var(--dash-border); + border-top: 0; + border-radius: 0 0 6px 6px; + color: #bfd0ca; + background: #080d10; + font-size: 11px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} + +.delivery-history { + margin-top: 27px; +} + +.delivery-history h3 { + margin: 0 0 18px; + font-size: 13px; +} + +.history-item { + position: relative; + display: flex; + gap: 11px; + min-height: 62px; +} + +.history-item::after { + position: absolute; + top: 17px; + bottom: -2px; + left: 8px; + width: 1px; + background: #245b36; + content: ""; +} + +.history-item:last-child::after { + display: none; +} + +.history-item > svg { + position: relative; + z-index: 1; + width: 17px; + flex: 0 0 auto; + color: var(--dash-accent); + background: var(--dash-bg); +} + +.history-item span { + display: flex; + flex-direction: column; + gap: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.history-item strong { + color: #dce3e1; + font-size: 11px; +} + +.history-item small { + color: #74807d; +} + +.history-item em { + color: var(--dash-accent); + font-size: 9px; + font-style: normal; +} + +/* Dashboard modal states */ +.modal-backdrop { + position: fixed; + z-index: 50; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgb(2 6 8 / 76%); + backdrop-filter: blur(5px); +} + +.modal { + width: min(540px, 100%); + padding: 25px; + border: 1px solid #3b484e; + border-radius: 10px; + color: #eef2ef; + background: #10181c; + box-shadow: 0 30px 90px rgb(0 0 0 / 45%); +} + +.modal-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.modal-heading h2 { + margin: 0; + font-size: 22px; + letter-spacing: -0.035em; +} + +.icon-button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 0; + color: #9ca7a4; + background: transparent; + cursor: pointer; +} + +.modal-form, +.secret-result { + margin-top: 18px; +} + +.modal-form > p, +.secret-result > p { + margin: 0 0 21px; + color: #9ca7a4; + font-size: 13px; + line-height: 1.55; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 23px; +} + +.secret-field { + display: flex; + min-height: 54px; + align-items: stretch; + border: 1px solid #445158; + border-radius: 7px; + background: #0a1115; +} + +.secret-field code { + overflow: hidden; + flex: 1; + padding: 18px 14px; + color: #e5ebe8; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-form { + align-items: end; + grid-template-columns: 1fr auto; +} + +.compact-form .form-error { + grid-column: 1 / -1; +} + +.token-list { + display: grid; + max-height: 250px; + margin-top: 22px; + border-top: 1px solid var(--dash-border); + overflow-y: auto; +} + +.token-row { + display: flex; + min-height: 65px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--dash-border); +} + +.token-row span { + display: grid; + gap: 5px; +} + +.token-row strong { + font-size: 12px; +} + +.token-row code, +.token-row em { + color: #76827f; + font-size: 10px; + font-style: normal; +} + +.token-row button { + border: 0; + color: #ff8d85; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 11px; +} + +.empty-note { + color: #788481; + font-size: 12px; +} + +@media (max-width: 1120px) { + .dashboard-shell { + grid-template-columns: 200px minmax(0, 1fr); + } + + .dashboard-content { + grid-template-columns: 1fr; + } + + .event-inspector { + border-top: 1px solid var(--dash-border); + border-left: 0; + } +} + +@media (max-width: 760px) { + .dashboard-shell { + display: block; + } + + .dashboard-sidebar { + position: static; + display: grid; + height: auto; + padding: 14px; + border-right: 0; + border-bottom: 1px solid var(--dash-border); + grid-template-columns: 1fr auto; + } + + .dashboard-brand { + margin: 0; + } + + .sidebar-navigation { + display: flex; + } + + .sidebar-navigation .sidebar-link:first-child, + .sidebar-hooks, + .account-control { + display: none; + } + + .sidebar-link { + width: auto; + min-height: 38px; + } + + .dashboard-header { + align-items: flex-start; + flex-direction: column; + gap: 20px; + padding: 25px 18px; + } + + .header-actions { + width: 100%; + } + + .header-actions .button { + flex: 1; + } + + .command-strip { + margin: 0 18px 18px; + } + + .dashboard-content { + min-height: auto; + } + + .events-region { + padding: 22px 14px 0; + } + + .events-heading { + align-items: flex-start; + flex-direction: column; + gap: 15px; + } + + .events-table { + overflow-x: auto; + } + + .events-table-head, + .event-row-link { + min-width: 650px; + } + + .cli-install { + margin: 45px -14px 0; + padding-left: 14px; + } + + .event-inspector { + padding: 24px 14px; + } + + .compact-form { + align-items: stretch; + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b9a66a7..970d9a2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -4,7 +4,7 @@ const repositoryUrl = "https://github.com/dak-engineering/hooky"; export default function HomePage() { return ( -
+
@@ -41,7 +46,8 @@ export default function HomePage() {

- $ hooky listen stripe-dev + $ hooky listen --to + localhost:3000/webhooks --hook stripe-dev

Public URL ready

https://hooks.example/e/wh_7vK9...

@@ -59,9 +65,9 @@ export default function HomePage() {
-
+
); diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx new file mode 100644 index 0000000..1313c0c --- /dev/null +++ b/apps/web/src/app/sign-in/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +import { AuthForm } from "@/components/auth-form"; + +export default function SignInPage() { + return ( +
+ + H + Hooky + +
+

Welcome back.

+

Pick up every webhook your local environment missed.

+ +
+
+ ); +} diff --git a/apps/web/src/app/sign-up/page.tsx b/apps/web/src/app/sign-up/page.tsx new file mode 100644 index 0000000..82036ea --- /dev/null +++ b/apps/web/src/app/sign-up/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +import { AuthForm } from "@/components/auth-form"; + +export default function SignUpPage() { + return ( +
+ + H + Hooky + +
+

Create your workspace.

+

Start with one durable endpoint. Add the CLI when you are ready.

+ +
+
+ ); +} diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx new file mode 100644 index 0000000..b94bd15 --- /dev/null +++ b/apps/web/src/components/auth-form.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { authClient } from "@/lib/auth-client"; + +export function AuthForm({ mode }: { mode: "sign-in" | "sign-up" }) { + const router = useRouter(); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const isSignUp = mode === "sign-up"; + + async function submit(formData: FormData) { + setPending(true); + setError(""); + const email = String(formData.get("email") ?? ""); + const password = String(formData.get("password") ?? ""); + const result = isSignUp + ? await authClient.signUp.email({ + email, + password, + name: String(formData.get("name") ?? ""), + callbackURL: "/dashboard", + }) + : await authClient.signIn.email({ + email, + password, + callbackURL: "/dashboard", + }); + setPending(false); + + if (result.error) { + setError(result.error.message ?? "Authentication failed"); + return; + } + router.push("/dashboard"); + router.refresh(); + } + + return ( +
+ {isSignUp ? ( + + ) : null} + + + {error ?

{error}

: null} + +

+ {isSignUp ? "Already have an account?" : "New to Hooky?"}{" "} + + {isSignUp ? "Sign in" : "Create an account"} + +

+
+ ); +} diff --git a/apps/web/src/components/dashboard-controls.tsx b/apps/web/src/components/dashboard-controls.tsx new file mode 100644 index 0000000..3e8523d --- /dev/null +++ b/apps/web/src/components/dashboard-controls.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { authClient } from "@/lib/auth-client"; + +import { CloseIcon, CopyIcon, KeyIcon, PlusIcon, RotateIcon } from "./icons"; + +function CopyButton({ + value, + label = "Copy", +}: { + value: string; + label?: string; +}) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +function Modal({ + children, + close, + title, +}: { + children: React.ReactNode; + close: () => void; + title: string; +}) { + useEffect(() => { + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") close(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [close]); + + return ( +
event.target === event.currentTarget && close()} + > +
+
+ + +
+ {children} +
+
+ ); +} + +export function HookActions({ hookId }: { hookId: string | undefined }) { + const router = useRouter(); + const [mode, setMode] = useState<"create" | "rotate" | null>(null); + const [secretUrl, setSecretUrl] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + function close() { + setMode(null); + setSecretUrl(""); + setError(""); + } + + async function create(formData: FormData) { + setPending(true); + setError(""); + const response = await fetch("/api/v1/hooks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: String(formData.get("name") ?? "") }), + }); + const payload = (await response.json()) as { + ingressUrl?: string; + error?: string; + }; + setPending(false); + if (!response.ok || !payload.ingressUrl) { + setError(payload.error ?? "Hook could not be created"); + return; + } + setSecretUrl(payload.ingressUrl); + router.refresh(); + } + + async function rotate() { + if (!hookId) return; + setMode("rotate"); + setPending(true); + setError(""); + const response = await fetch( + `/api/v1/hooks/${encodeURIComponent(hookId)}/rotate-ingress-secret`, + { method: "POST" }, + ); + const payload = (await response.json()) as { + ingressUrl?: string; + error?: string; + }; + setPending(false); + if (!response.ok || !payload.ingressUrl) { + setError(payload.error ?? "URL could not be rotated"); + return; + } + setSecretUrl(payload.ingressUrl); + } + + return ( + <> +
+ + +
+ {mode ? ( + + {secretUrl ? ( +
+

Copy it now. For security, Hooky only shows this URL once.

+
+ {secretUrl} + +
+
+ +
+
+ ) : mode === "create" ? ( +
+

Give this endpoint a name you’ll recognize locally.

+ + {error ?

{error}

: null} +
+ + +
+
+ ) : ( +
+

{error || "Creating a new URL and revoking the old one…"}

+
+ )} +
+ ) : null} + + ); +} + +export function ApiKeysControl({ + tokens, +}: { + tokens: Array<{ + tokenId: string; + name: string; + prefix: string; + lastUsedAt: string | null; + revokedAt: string | null; + }>; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [secret, setSecret] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function create(formData: FormData) { + setPending(true); + setError(""); + const response = await fetch("/api/v1/tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: String(formData.get("name") ?? "") }), + }); + const payload = (await response.json()) as { + token?: string; + error?: string; + }; + setPending(false); + if (!response.ok || !payload.token) { + setError(payload.error ?? "API key could not be created"); + return; + } + setSecret(payload.token); + router.refresh(); + } + + async function revoke(tokenId: string) { + await fetch(`/api/v1/tokens/${tokenId}`, { method: "DELETE" }); + router.refresh(); + } + + return ( + <> + + {open ? ( + { + setOpen(false); + setSecret(""); + setError(""); + }} + title={secret ? "API key created" : "API keys"} + > + {secret ? ( +
+

+ Copy it now. For security, Hooky only shows this token once. +

+
+ {secret} + +
+
+ +
+
+ ) : ( + <> +
+ + {error ?

{error}

: null} + +
+
+ {tokens.length ? ( + tokens.map((token) => ( +
+ + {token.name} + {token.prefix}… + + {token.revokedAt ? ( + Revoked + ) : ( + + )} +
+ )) + ) : ( +

No API keys yet.

+ )} +
+ + )} +
+ ) : null} + + ); +} + +export function CommandCopy({ command }: { command: string }) { + return ; +} + +export function AccountControl({ name }: { name: string }) { + const router = useRouter(); + return ( + + ); +} + +export function EventInspector({ + body, + headers, + query, +}: { + body: string; + headers: Record; + query: Record; +}) { + const [tab, setTab] = useState<"body" | "headers" | "query">("body"); + const value = + tab === "body" + ? body + : JSON.stringify(tab === "headers" ? headers : query, null, 2); + return ( + <> +
+ {(["body", "headers", "query"] as const).map((item) => ( + + ))} +
+
+        {value}
+      
+ + ); +} diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx new file mode 100644 index 0000000..e93fd53 --- /dev/null +++ b/apps/web/src/components/icons.tsx @@ -0,0 +1,146 @@ +import type { SVGProps } from "react"; + +function Icon({ children, ...props }: SVGProps) { + return ( + + ); +} + +export function GridIcon(props: SVGProps) { + return ( + + + + ); +} + +export function HookIcon(props: SVGProps) { + return ( + + + + ); +} + +export function KeyIcon(props: SVGProps) { + return ( + + + + + ); +} + +export function PlusIcon(props: SVGProps) { + return ( + + + + ); +} + +export function RotateIcon(props: SVGProps) { + return ( + + + + ); +} + +export function CopyIcon(props: SVGProps) { + return ( + + + + + ); +} + +export function CheckIcon(props: SVGProps) { + return ( + + + + + ); +} + +export function CloseIcon(props: SVGProps) { + return ( + + + + ); +} + +export function ArrowIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts new file mode 100644 index 0000000..2f75fd4 --- /dev/null +++ b/apps/web/src/lib/auth-client.ts @@ -0,0 +1,5 @@ +"use client"; + +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); diff --git a/apps/web/src/lib/authenticated-account.test.ts b/apps/web/src/lib/authenticated-account.test.ts new file mode 100644 index 0000000..c88a395 --- /dev/null +++ b/apps/web/src/lib/authenticated-account.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { hasTrustedOrigin } from "./authenticated-account"; + +describe("session origin protection", () => { + test("allows same-origin browser mutations with or without an Origin header", () => { + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { origin: "https://hooky.test" }, + }), + ), + ).toBe(true); + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { "sec-fetch-site": "same-origin" }, + }), + ), + ).toBe(true); + }); + + test("rejects cross-origin and non-browser cookie mutations", () => { + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { origin: "https://attacker.test" }, + }), + ), + ).toBe(false); + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { method: "POST" }), + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/lib/authenticated-account.ts b/apps/web/src/lib/authenticated-account.ts index a309c62..f2231e6 100644 --- a/apps/web/src/lib/authenticated-account.ts +++ b/apps/web/src/lib/authenticated-account.ts @@ -1,11 +1,18 @@ import { auth } from "./auth"; import { accountStore, apiTokenStore } from "./server-database"; -function hasTrustedOrigin(request: Request) { +export function hasTrustedOrigin(request: Request) { if (["GET", "HEAD", "OPTIONS"].includes(request.method)) { return true; } - return request.headers.get("origin") === new URL(request.url).origin; + if (request.headers.get("sec-fetch-site") === "same-origin") { + return true; + } + const origin = request.headers.get("origin"); + if (origin) { + return origin === new URL(request.url).origin; + } + return false; } export async function authenticateAccount(request: Request) { diff --git a/apps/web/src/lib/hooks-api.ts b/apps/web/src/lib/hooks-api.ts index 77442c9..95b1b90 100644 --- a/apps/web/src/lib/hooks-api.ts +++ b/apps/web/src/lib/hooks-api.ts @@ -18,7 +18,10 @@ function unauthorized() { } function ingressUrl(request: Request, token: string) { - return new URL(`/e/${token}`, request.url).toString(); + return new URL( + `/e/${token}`, + process.env.BETTER_AUTH_URL ?? request.url, + ).toString(); } export function createHooksCollectionHandlers({ diff --git a/apps/web/src/lib/server-database.ts b/apps/web/src/lib/server-database.ts index e598132..e762d53 100644 --- a/apps/web/src/lib/server-database.ts +++ b/apps/web/src/lib/server-database.ts @@ -4,6 +4,7 @@ import { createDatabasePool, createDrizzleDatabase, DeliveryStore, + EventStore, HookStore, } from "@hooky/database"; @@ -28,3 +29,4 @@ export const accountStore = new AccountStore(databasePool); export const apiTokenStore = new ApiTokenStore(databasePool); export const hookStore = new HookStore(databasePool); export const deliveryStore = new DeliveryStore(databasePool); +export const eventStore = new EventStore(databasePool); diff --git a/design/concepts/creation-states.png b/design/concepts/creation-states.png new file mode 100644 index 0000000..eb98fb2 Binary files /dev/null and b/design/concepts/creation-states.png differ diff --git a/design/concepts/dashboard.png b/design/concepts/dashboard.png new file mode 100644 index 0000000..8386a8b Binary files /dev/null and b/design/concepts/dashboard.png differ diff --git a/design/qa/creation-render.png b/design/qa/creation-render.png new file mode 100644 index 0000000..9a1ce21 Binary files /dev/null and b/design/qa/creation-render.png differ diff --git a/design/qa/dashboard-mobile.png b/design/qa/dashboard-mobile.png new file mode 100644 index 0000000..7c074dd Binary files /dev/null and b/design/qa/dashboard-mobile.png differ diff --git a/design/qa/dashboard-render.png b/design/qa/dashboard-render.png new file mode 100644 index 0000000..c5497f8 Binary files /dev/null and b/design/qa/dashboard-render.png differ diff --git a/e2e/dashboard.e2e.ts b/e2e/dashboard.e2e.ts new file mode 100644 index 0000000..88c2e25 --- /dev/null +++ b/e2e/dashboard.e2e.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +test("creates a hook, captures an event, and creates a CLI key", async ({ + page, + request, +}) => { + const email = `developer-${Date.now()}-${Math.random()}@example.test`; + await page.goto("/sign-up"); + await page.getByLabel("Name").fill("Dak Engineering"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill("correct-horse-battery-staple"); + await page.getByRole("button", { name: "Create account" }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + await page.getByRole("button", { name: "New hook" }).click(); + await page.getByLabel("Hook name").fill("stripe-e2e"); + await page.getByRole("button", { name: "Create hook" }).click(); + await expect( + page.getByRole("heading", { name: "Webhook URL created" }), + ).toBeVisible(); + const ingressUrl = await page.locator(".secret-field code").textContent(); + expect(ingressUrl).toMatch(/^http:\/\/127\.0\.0\.1:3000\/e\/hk_/); + + const webhookResponse = await request.post(ingressUrl!, { + data: { order: "ord_e2e", amount: 4999, currency: "usd" }, + headers: { "stripe-signature": "e2e-signature" }, + }); + expect(webhookResponse.status()).toBe(202); + + await page.getByRole("button", { name: "Done" }).click(); + await page.reload(); + await expect( + page.getByRole("row", { name: /POST \/ now Pending 0/ }), + ).toBeVisible(); + await expect(page.locator(".payload-view")).toContainText("ord_e2e"); + await expect(page.locator(".payload-view")).toContainText("4999"); + + await page.getByRole("button", { name: "API keys" }).click(); + await page.getByLabel("Key name").fill("MacBook listener"); + await page.getByRole("button", { name: "Create API key" }).click(); + await expect( + page.getByRole("heading", { name: "API key created" }), + ).toBeVisible(); + await expect(page.locator(".secret-field code")).toContainText("hky_"); +}); diff --git a/e2e/test-server.ts b/e2e/test-server.ts new file mode 100644 index 0000000..5d6e90e --- /dev/null +++ b/e2e/test-server.ts @@ -0,0 +1,39 @@ +import { spawn } from "node:child_process"; + +import { createTestDatabase } from "../packages/database/src/testing/test-database"; + +const database = await createTestDatabase(); +const serverCommand = process.env.HOOKY_E2E_PRODUCTION ? "start" : "dev"; +const webServer = spawn( + "bun", + ["run", "--cwd", "apps/web", serverCommand, "--hostname", "127.0.0.1"], + { + env: { + ...process.env, + DATABASE_URL: database.connectionString, + BETTER_AUTH_SECRET: "e2e-secret-that-is-at-least-thirty-two-characters", + BETTER_AUTH_URL: "http://127.0.0.1:3000", + }, + stdio: "inherit", + }, +); + +function stop() { + webServer.kill("SIGTERM"); +} + +process.once("SIGINT", stop); +process.once("SIGTERM", stop); + +await new Promise((resolve, reject) => { + webServer.once("error", reject); + webServer.once("exit", (code, signal) => { + if (code && code !== 0 && !signal) { + reject(new Error(`Next.js exited with code ${code}`)); + return; + } + resolve(); + }); +}); + +await database.close(); diff --git a/package.json b/package.json index 37f4b68..7f44fb4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "db:generate": "bun run --cwd packages/database generate", "db:migrate": "bun run --cwd packages/database migrate", "dev": "turbo run dev --filter=@hooky/web", + "e2e:server": "bun e2e/test-server.ts", "lint": "turbo run lint", "prettier": "prettier --write .", "prettier:check": "prettier --check .", diff --git a/packages/database/src/event-store.test.ts b/packages/database/src/event-store.test.ts new file mode 100644 index 0000000..0fc8dd7 --- /dev/null +++ b/packages/database/src/event-store.test.ts @@ -0,0 +1,129 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { DeliveryStore } from "./delivery-store"; +import { EventStore } from "./event-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let deliveryStore: DeliveryStore; +let eventStore: EventStore; + +beforeAll(async () => { + database = await createTestDatabase(); + deliveryStore = new DeliveryStore(database.pool); + eventStore = new EventStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("event store", () => { + test("lists recent event and delivery state for one tenant hook", async () => { + const owner = await database.seedAccountAndHook(); + const other = await database.seedAccountAndHook(); + const recorded = await deliveryStore.recordWebhookEvent({ + ...owner, + requestMethod: "POST", + requestPath: "/checkout", + query: { attempt: "1" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"order":"ord_123"}'), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + expect( + await eventStore.listRecentEvents({ + accountId: owner.accountId, + hookId: owner.hookId, + limit: 50, + }), + ).toEqual([ + { + eventId: recorded.eventId, + deliveryId: recorded.deliveryId, + requestMethod: "POST", + requestPath: "/checkout", + status: "pending", + attemptCount: 0, + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }, + ]); + expect( + await eventStore.listRecentEvents({ + accountId: other.accountId, + hookId: owner.hookId, + limit: 50, + }), + ).toEqual([]); + }); + + test("returns captured details and delivery history only to its tenant", async () => { + const owner = await database.seedAccountAndHook(); + const other = await database.seedAccountAndHook(); + const recorded = await deliveryStore.recordWebhookEvent({ + ...owner, + requestMethod: "POST", + requestPath: "/invoice.paid", + query: { source: "stripe" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"invoice":"in_123"}'), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await deliveryStore.claimDeliveries({ + ...owner, + listenerId: "cli-a8f2", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + await deliveryStore.acknowledgeDelivery({ + accountId: owner.accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + now: new Date("2026-08-11T20:00:02.000Z"), + }); + + const detail = await eventStore.getEvent({ + accountId: owner.accountId, + eventId: recorded.eventId, + }); + + expect(detail).toMatchObject({ + eventId: recorded.eventId, + hookId: owner.hookId, + requestMethod: "POST", + requestPath: "/invoice.paid", + query: { source: "stripe" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"invoice":"in_123"}'), + status: "delivered", + attempts: [ + { + attemptNumber: 1, + listenerId: "cli-a8f2", + outcome: "delivered", + startedAt: new Date("2026-08-11T20:00:01.000Z"), + finishedAt: new Date("2026-08-11T20:00:02.000Z"), + }, + ], + }); + expect( + await eventStore.getEvent({ + accountId: other.accountId, + eventId: recorded.eventId, + }), + ).toBeNull(); + }); +}); diff --git a/packages/database/src/event-store.ts b/packages/database/src/event-store.ts new file mode 100644 index 0000000..8632724 --- /dev/null +++ b/packages/database/src/event-store.ts @@ -0,0 +1,151 @@ +import type { Pool } from "pg"; + +export class EventStore { + constructor(private readonly pool: Pool) {} + + async listRecentEvents({ + accountId, + hookId, + limit, + }: { + accountId: string; + hookId: string; + limit: number; + }) { + const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit))); + const result = await this.pool.query<{ + event_id: string; + delivery_id: string; + request_method: string; + request_path: string; + status: "pending" | "in_flight" | "delivered" | "dead"; + attempt_count: number; + received_at: Date; + }>( + ` + select + webhook_events.id as event_id, + deliveries.id as delivery_id, + webhook_events.request_method, + webhook_events.request_path, + deliveries.status, + deliveries.attempt_count, + webhook_events.received_at + from webhook_events + inner join deliveries + on deliveries.event_id = webhook_events.id + and deliveries.account_id = webhook_events.account_id + where webhook_events.account_id = $1 + and webhook_events.hook_id = $2 + order by webhook_events.received_at desc, webhook_events.id desc + limit $3 + `, + [accountId, hookId, safeLimit], + ); + + return result.rows.map((row) => ({ + eventId: row.event_id, + deliveryId: row.delivery_id, + requestMethod: row.request_method, + requestPath: row.request_path, + status: row.status, + attemptCount: row.attempt_count, + receivedAt: row.received_at, + })); + } + + async getEvent({ + accountId, + eventId, + }: { + accountId: string; + eventId: string; + }) { + const eventResult = await this.pool.query<{ + event_id: string; + hook_id: string; + delivery_id: string; + request_method: string; + request_path: string; + query: Record; + headers: Record; + body: Buffer; + body_sha256: string; + received_at: Date; + status: "pending" | "in_flight" | "delivered" | "dead"; + attempt_count: number; + delivered_at: Date | null; + last_error: string | null; + }>( + ` + select + webhook_events.id as event_id, + webhook_events.hook_id, + deliveries.id as delivery_id, + webhook_events.request_method, + webhook_events.request_path, + webhook_events.query, + webhook_events.headers, + webhook_events.body, + webhook_events.body_sha256, + webhook_events.received_at, + deliveries.status, + deliveries.attempt_count, + deliveries.delivered_at, + deliveries.last_error + from webhook_events + inner join deliveries + on deliveries.event_id = webhook_events.id + and deliveries.account_id = webhook_events.account_id + where webhook_events.account_id = $1 and webhook_events.id = $2 + `, + [accountId, eventId], + ); + const event = eventResult.rows[0]; + if (!event) { + return null; + } + + const attemptsResult = await this.pool.query<{ + attempt_number: number; + listener_id: string; + outcome: "delivered" | "failed" | "expired" | null; + error: string | null; + started_at: Date; + finished_at: Date | null; + }>( + ` + select attempt_number, listener_id, outcome, error, started_at, finished_at + from delivery_attempts + where account_id = $1 and delivery_id = $2 + order by attempt_number + `, + [accountId, event.delivery_id], + ); + + return { + eventId: event.event_id, + hookId: event.hook_id, + deliveryId: event.delivery_id, + requestMethod: event.request_method, + requestPath: event.request_path, + query: event.query, + headers: event.headers, + body: event.body, + bodySha256: event.body_sha256, + receivedAt: event.received_at, + status: event.status, + attemptCount: event.attempt_count, + deliveredAt: event.delivered_at, + lastError: event.last_error, + attempts: attemptsResult.rows.map((attempt) => ({ + attemptNumber: attempt.attempt_number, + listenerId: attempt.listener_id, + outcome: attempt.outcome, + error: attempt.error, + startedAt: attempt.started_at, + finishedAt: attempt.finished_at, + })), + }; + } +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 963a921..af5b9e9 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -6,5 +6,6 @@ export { HookUnavailableError, type ClaimedDelivery, } from "./delivery-store"; +export { EventStore } from "./event-store"; export { HookStore } from "./hook-store"; export * as schema from "./schema"; diff --git a/packages/database/src/testing/test-database.ts b/packages/database/src/testing/test-database.ts index e9ef9a1..a1f1781 100644 --- a/packages/database/src/testing/test-database.ts +++ b/packages/database/src/testing/test-database.ts @@ -87,9 +87,11 @@ async function startEphemeralPostgres() { connectionString, async stop() { await pool.end(); - execFileSync("pg_ctl", ["-D", dataDirectory, "stop", "-m", "fast"], { - stdio: "ignore", - }); + if (process.exitCode === null && process.signalCode === null) { + execFileSync("pg_ctl", ["-D", dataDirectory, "stop", "-m", "fast"], { + stdio: "ignore", + }); + } await rm(dataDirectory, { recursive: true, force: true }); }, }; @@ -119,6 +121,8 @@ export async function createTestDatabase() { } return { + connectionString: + configuredConnectionString ?? ephemeral?.connectionString ?? "", pool, async reset() { await pool.query( diff --git a/playwright.config.ts b/playwright.config.ts index 5294e50..fb59474 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,8 +18,7 @@ export default defineConfig({ }, ], webServer: { - command: - "cd apps/web && exec ./node_modules/.bin/next dev --hostname 127.0.0.1", + command: "bun run e2e:server", url: "http://127.0.0.1:3000/api/health", reuseExistingServer: false, timeout: 120_000,