Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TEST_DATABASE_URL: postgresql://postgres:[email protected]:5432/hooky_test
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: hooky_test
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d hooky_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand Down
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
101 changes: 95 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +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).

## Use Hooky

Hooky is not published to npm. Run it directly from this public GitHub repository:

```bash
bunx github:dak-engineering/hooky --help
```

Or install the command globally from GitHub:

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

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

```bash
hooky login --token hky_your_token
```

Create a new webhook URL and relay its events to a local route:

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

The command prints the one-time public webhook URL. Give that URL to Stripe, GitHub, Clerk, or any service that sends HTTP webhooks.

To listen on a hook that already exists in your account:

```bash
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"]
```

- 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 workspace currently contains the Next.js web application in `apps/web`. Additional database, relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them.
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.

## Development
## Local development

Install dependencies and start the web application:
Requirements are Bun 1.3+, PostgreSQL 16+, and Chromium for browser tests.

```bash
bun install
cp .env.example apps/web/.env.local
```

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), with its health endpoint at [http://localhost:3000/api/health](http://localhost:3000/api/health).
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.
6 changes: 6 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Use the pooled Neon connection string for application traffic.
DATABASE_URL=postgresql://user:[email protected]/database?sslmode=require

# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters
BETTER_AUTH_URL=http://localhost:3000
18 changes: 18 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ 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;
9 changes: 8 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hooky/database": "workspace:*",
"@vercel/functions": "^3.9.3",
"better-auth": "1.6.27",
"drizzle-orm": "0.45.2",
"next": "16.3.0",
"pg": "8.23.0",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"zod": "4.4.3"
},
"devDependencies": {
"@types/bun": "1.3.14",
"@types/node": "^20.19.32",
"@types/pg": "8.21.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.2",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";

import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
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");
},
});
19 changes: 19 additions & 0 deletions apps/web/src/app/api/v1/deliveries/[delivery-id]/ack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { authenticateApiAccount } from "@/lib/authenticated-account";
import { createAcknowledgeHandler } from "@/lib/listener-api";
import { deliveryStore } from "@/lib/server-database";

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

const acknowledge = createAcknowledgeHandler({
authenticate: authenticateApiAccount,
acknowledgeDelivery: (input) => deliveryStore.acknowledgeDelivery(input),
now: () => new Date(),
});

export async function POST(
request: Request,
{ params }: { params: Promise<{ "delivery-id": string }> },
) {
return acknowledge(request, (await params)["delivery-id"]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { authenticateApiAccount } from "@/lib/authenticated-account";
import { createHeartbeatHandler } from "@/lib/listener-api";
import { deliveryStore } from "@/lib/server-database";

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

const heartbeat = createHeartbeatHandler({
authenticate: authenticateApiAccount,
extendDeliveryLease: (input) => deliveryStore.extendDeliveryLease(input),
now: () => new Date(),
});

export async function POST(
request: Request,
{ params }: { params: Promise<{ "delivery-id": string }> },
) {
return heartbeat(request, (await params)["delivery-id"]);
}
19 changes: 19 additions & 0 deletions apps/web/src/app/api/v1/deliveries/[delivery-id]/nack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { authenticateApiAccount } from "@/lib/authenticated-account";
import { createRejectHandler } from "@/lib/listener-api";
import { deliveryStore } from "@/lib/server-database";

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

const reject = createRejectHandler({
authenticate: authenticateApiAccount,
rejectDelivery: (input) => deliveryStore.rejectDelivery(input),
now: () => new Date(),
});

export async function POST(
request: Request,
{ params }: { params: Promise<{ "delivery-id": string }> },
) {
return reject(request, (await params)["delivery-id"]);
}
19 changes: 19 additions & 0 deletions apps/web/src/app/api/v1/hooks/[hook-id]/deliveries/claim/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { authenticateApiAccount } from "@/lib/authenticated-account";
import { createClaimHandler } from "@/lib/listener-api";
import { deliveryStore } from "@/lib/server-database";

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

const claim = createClaimHandler({
authenticate: authenticateApiAccount,
claimDeliveries: (input) => deliveryStore.claimDeliveries(input),
now: () => new Date(),
});

export async function POST(
request: Request,
{ params }: { params: Promise<{ "hook-id": string }> },
) {
return claim(request, (await params)["hook-id"]);
}
Loading
Loading