Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DATABASE_URL=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
CRON_SECRET=
RETENTION_DAYS=30
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ node_modules/
.turbo/
dist/
out/
.vercel/

# Test output
playwright-report/
Expand Down
104 changes: 85 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,120 @@
# Hooky

Hooky is a durable webhook inbox and local development relay. It stores incoming webhooks in the cloud and delivers them when a developer's local environment is ready—without exposing localhost to the public internet.
Hooky is a durable webhook inbox and local-development relay. A public Hooky URL accepts and stores a webhook before returning `202`, then the Hooky CLI leases that event and forwards its original method, path, query, headers, and bytes to localhost. Your development machine never exposes a public port.

The product plan and implementation decisions live in [PRD issue #1](https://github.com/dak-engineering/hooky/issues/1).
The hosted application is [hooky.vercel.app](https://hooky.vercel.app). The product plan and implementation decisions live in [PRD issue #1](https://github.com/dak-engineering/hooky/issues/1).

## Repository
## Use Hooky

This Bun workspace currently contains:
Hooky is not published to npm. Run it directly from this public GitHub repository:

- `apps/web`: the Next.js web application and API surface.
- `packages/database`: the Neon/PostgreSQL schema, migrations, and durable delivery state machine.
```bash
bunx github:dak-engineering/hooky --help
```

Relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them.
Or install the command globally from GitHub:

## Development
```bash
bun install --global github:dak-engineering/hooky
hooky --help
```

Install dependencies and start the web application:
Create an account in the web app, open **API keys**, and copy a newly created key. Authenticate the CLI:

```bash
bun install
bun run dev
hooky login --token hky_your_token
```

The application runs at [http://localhost:3000](http://localhost:3000), with its health endpoint at [http://localhost:3000/api/health](http://localhost:3000/api/health).
Create a new webhook URL and relay its events to a local route:

## Database
```bash
hooky listen --new stripe-dev --to http://localhost:3000/webhooks/stripe
```

Application traffic must use Neon's pooled connection string through `DATABASE_URL`. The database package keeps captured webhook events immutable and uses short PostgreSQL statements with `FOR UPDATE SKIP LOCKED` for concurrent delivery claims.
The command prints the one-time public webhook URL. Give that URL to Stripe, GitHub, Clerk, or any service that sends HTTP webhooks.

Generate a migration after changing the Drizzle schema:
To listen on a hook that already exists in your account:

```bash
bun run db:generate
hooky hooks
hooky listen --hook stripe-dev --to http://localhost:3000/webhooks/stripe
```

The CLI stores credentials in `~/.config/hooky/config.json` with user-only permissions. You can instead use `HOOKY_TOKEN`, `HOOKY_API_URL`, and `HOOKY_CONFIG_PATH`.

## Delivery model

```mermaid
flowchart LR
sender["Webhook sender"] -->|"public HTTPS request"| ingress["Hooky on Vercel"]
ingress -->|"atomic event + delivery commit"| neon["Neon Postgres"]
cli["Hooky CLI"] -->|"authenticated claim / ACK / NACK"| ingress
cli -->|"original HTTP request"| local["localhost application"]
```

Apply committed migrations to the database in `DATABASE_URL`:
- Hook ingress secrets and API keys are random, one-time credentials stored only as SHA-256 hashes.
- Each accepted request and pending delivery are committed together before Hooky responds.
- A listener claims work using a time-bounded database lease. Unacknowledged work becomes claimable again after the lease expires.
- Successful local responses ACK the delivery. Network errors and unsuccessful responses NACK it with bounded exponential retry.
- Every account boundary is enforced in the database queries used by management and listener APIs.
- Captured events are retained for 30 days by default. A daily authenticated Vercel Cron job performs bounded cleanup.

## Repository

This Bun/Turborepo workspace contains:

- `apps/web`: Next.js application, authenticated dashboard, ingress, management API, listener API, health check, and retention cron.
- `packages/database`: Drizzle schema, migrations, tenant-scoped stores, and PostgreSQL delivery state machine.
- `packages/cli`: GitHub-installable Bun CLI and local forwarding loop.
- `e2e`: Playwright coverage for the landing page and the sign-up → hook → webhook → event → API-key flow.

## Local development

Requirements are Bun 1.3+, PostgreSQL 16+, and Chromium for browser tests.

```bash
bun run db:migrate
bun install
cp .env.example apps/web/.env.local
```

Database integration tests use `TEST_DATABASE_URL` when supplied. Otherwise, they start and remove an ephemeral local PostgreSQL cluster with `initdb` and `pg_ctl`.
Set these values:

```dotenv
DATABASE_URL=postgresql://...
BETTER_AUTH_SECRET=a-random-secret-at-least-32-characters-long
BETTER_AUTH_URL=http://localhost:3000
CRON_SECRET=another-random-secret
RETENTION_DAYS=30
```

Then migrate and run the application:

```bash
DATABASE_URL='postgresql://...' bun run db:migrate
bun run dev
```

The application runs at [http://localhost:3000](http://localhost:3000). Readiness is available at [http://localhost:3000/api/health](http://localhost:3000/api/health).

Application traffic should use Neon's pooled `DATABASE_URL`. Run migrations with a direct connection string when available because migration tools hold longer sessions than request handlers.

## Validation

Database tests use `TEST_DATABASE_URL` when supplied. Otherwise they create and remove an ephemeral local PostgreSQL cluster.

```bash
bun run prettier
bun run lint
bun run prettier:check
bun run typecheck
bun test
bun run test:e2e
bun run build
bun run db:generate
```

## Deployment

The production project is `dak/hooky` on Vercel, connected to this repository with `apps/web` as its Root Directory. Neon supplies pooled `DATABASE_URL` credentials to the Vercel project. Production also requires `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and `CRON_SECRET`; `RETENTION_DAYS` defaults to 30.

The service emits metadata-only structured ingress logs with Vercel request correlation. It never logs webhook bodies, captured headers, ingress secrets, API keys, or database credentials.
17 changes: 17 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@hooky/database"],
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
],
},
];
},
};

export default nextConfig;
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"dependencies": {
"@hooky/database": "workspace:*",
"@vercel/functions": "^3.9.3",
"better-auth": "1.6.27",
"drizzle-orm": "0.45.2",
"next": "16.3.0",
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/app/api/cron/retention/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createRetentionHandler } from "@/lib/retention-handler";
import { retentionStore } from "@/lib/server-database";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const configuredRetentionDays = Number(process.env.RETENTION_DAYS ?? "30");

export const GET = createRetentionHandler({
cronSecret: process.env.CRON_SECRET,
retentionDays: Number.isFinite(configuredRetentionDays)
? configuredRetentionDays
: 30,
deleteEventsReceivedBefore: (input) =>
retentionStore.deleteEventsReceivedBefore(input),
});
20 changes: 18 additions & 2 deletions apps/web/src/app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { describe, expect, test } from "bun:test";

import { GET } from "./route";
import { createHealthHandler } from "./route";

describe("health endpoint", () => {
test("reports that the web service is ready", async () => {
const response = GET();
const response = await createHealthHandler({
checkDatabase: async () => undefined,
})();

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
service: "hooky-web",
status: "ok",
});
});

test("reports an unavailable dependency without leaking its error", async () => {
const response = await createHealthHandler({
checkDatabase: async () => {
throw new Error("postgresql://[email protected]/hooky");
},
})();

expect(response.status).toBe(503);
expect(await response.json()).toEqual({
service: "hooky-web",
status: "unavailable",
});
});
});
35 changes: 30 additions & 5 deletions apps/web/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
export function GET() {
return Response.json({
service: "hooky-web",
status: "ok",
});
import { databasePool } from "@/lib/server-database";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export function createHealthHandler({
checkDatabase,
}: {
checkDatabase: () => Promise<void>;
}) {
return async function healthHandler() {
try {
await checkDatabase();
return Response.json(
{ service: "hooky-web", status: "ok" },
{ headers: { "cache-control": "no-store" } },
);
} catch {
return Response.json(
{ service: "hooky-web", status: "unavailable" },
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
};
}

export const GET = createHealthHandler({
checkDatabase: async () => {
await databasePool.query("select 1");
},
});
7 changes: 2 additions & 5 deletions apps/web/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";

import { database } from "./server-database";
import { resolveDeploymentOrigin } from "./deployment-origin";

const fallbackDevelopmentSecret =
"hooky-development-only-secret-change-before-deploying";
Expand All @@ -12,11 +13,7 @@ if (process.env.VERCEL && !process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required on Vercel");
}

if (process.env.VERCEL && !process.env.BETTER_AUTH_URL) {
throw new Error("BETTER_AUTH_URL is required on Vercel");
}

const configuredOrigin = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const configuredOrigin = resolveDeploymentOrigin(process.env);

export function createHookyAuth({
database,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/deployment-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";

import { resolveDeploymentOrigin } from "./deployment-origin";

describe("deployment origin", () => {
test("prefers the explicitly configured canonical URL", () => {
expect(
resolveDeploymentOrigin({
BETTER_AUTH_URL: "https://hooky.vercel.app",
VERCEL_URL: "hooky-preview.vercel.app",
}),
).toBe("https://hooky.vercel.app");
});

test("uses the current Vercel deployment URL for previews", () => {
expect(
resolveDeploymentOrigin({ VERCEL_URL: "hooky-preview.vercel.app" }),
).toBe("https://hooky-preview.vercel.app");
});

test("falls back to localhost for local development", () => {
expect(resolveDeploymentOrigin({})).toBe("http://localhost:3000");
});
});
15 changes: 15 additions & 0 deletions apps/web/src/lib/deployment-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function resolveDeploymentOrigin(
environment: Record<string, string | undefined>,
) {
const configuredOrigin = environment.BETTER_AUTH_URL?.trim();
if (configuredOrigin) {
return configuredOrigin;
}

const vercelUrl = environment.VERCEL_URL?.trim();
if (vercelUrl) {
return `https://${vercelUrl}`;
}

return "http://localhost:3000";
}
36 changes: 36 additions & 0 deletions apps/web/src/lib/ingress-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("ingress handler", () => {
);

expect(response.status).toBe(202);
expect(response.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/);
expect(await response.json()).toEqual({
deliveryId: "delivery-one",
eventId: "event-one",
Expand All @@ -52,6 +53,41 @@ describe("ingress handler", () => {
]);
});

test("preserves an upstream Vercel request id for correlation", async () => {
const logs: Record<string, unknown>[] = [];
const handler = createIngressHandler({
maxBodyBytes: 1024,
resolveIngressToken: async () => ({
accountId: "account-one",
hookId: "hook-one",
}),
recordWebhookEvent: async () => ({
eventId: "event-one",
deliveryId: "delivery-one",
}),
log: (entry) => logs.push(entry),
});

const response = await handler(
new Request("https://hooky.test/e/token", {
method: "POST",
headers: { "x-vercel-id": "sfo1::abc-123" },
}),
{ token: "token", path: [] },
);

expect(response.headers.get("x-request-id")).toBe("sfo1::abc-123");
expect(logs).toEqual([
expect.objectContaining({
level: "info",
message: "webhook.accepted",
requestId: "sfo1::abc-123",
method: "POST",
bodyBytes: 0,
}),
]);
});

test("rejects unknown tokens without recording", async () => {
let recorded = false;
const handler = createIngressHandler({
Expand Down
Loading
Loading