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
4 changes: 4 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +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
1 change: 1 addition & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@hooky/database"],
};

export default nextConfig;
8 changes: 7 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hooky/database": "workspace:*",
"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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { authenticateAccount } from "@/lib/authenticated-account";
import { createRotateIngressSecretHandler } from "@/lib/hooks-api";
import { hookStore } from "@/lib/server-database";

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

const rotate = createRotateIngressSecretHandler({
authenticate: authenticateAccount,
rotateIngressSecret: (input) => hookStore.rotateIngressSecret(input),
});

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

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

const handlers = createHooksCollectionHandlers({
authenticate: authenticateAccount,
createHook: (input) => hookStore.createHook(input),
listHooks: (input) => hookStore.listHooks(input),
});

export const { GET, POST } = handlers;
29 changes: 29 additions & 0 deletions apps/web/src/app/e/[token]/[[...path]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createIngressHandler } from "@/lib/ingress-handler";
import { deliveryStore, hookStore } from "@/lib/server-database";

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

const ingress = createIngressHandler({
maxBodyBytes: 4_000_000,
resolveIngressToken: (token) => hookStore.resolveIngressToken(token),
recordWebhookEvent: (input) => deliveryStore.recordWebhookEvent(input),
});

async function handle(
request: Request,
{
params,
}: { params: Promise<{ token: string; path?: string[] | undefined }> },
) {
const { token, path = [] } = await params;
return ingress(request, { token, path });
}

export const GET = handle;
export const HEAD = handle;
export const POST = handle;
export const PUT = handle;
export const PATCH = handle;
export const DELETE = handle;
export const OPTIONS = handle;
51 changes: 51 additions & 0 deletions apps/web/src/lib/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createDrizzleDatabase } from "@hooky/database";
import { createTestDatabase } from "@hooky/database/testing";

import { createHookyAuth } from "./auth";

let testDatabase: Awaited<ReturnType<typeof createTestDatabase>>;

beforeAll(async () => {
testDatabase = await createTestDatabase();
});

afterAll(async () => {
await testDatabase.close();
});

describe("authentication", () => {
test("signs up with email/password and persists a session", async () => {
const testAuth = createHookyAuth({
database: createDrizzleDatabase(testDatabase.pool),
baseURL: "http://localhost:3000",
secret: "test-secret-that-is-more-than-thirty-two-characters",
secureCookies: false,
});
const response = await testAuth.handler(
new Request("http://localhost:3000/api/auth/sign-up/email", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "http://localhost:3000",
},
body: JSON.stringify({
name: "Katherine Johnson",
email: "[email protected]",
password: "correct-horse-battery-staple",
}),
}),
);
const cookie = response.headers.get("set-cookie")?.split(";", 1)[0];
const session = await testAuth.api.getSession({
headers: new Headers({ cookie: cookie ?? "" }),
});

expect(response.status).toBe(200);
expect(cookie).toContain("hooky.session_token=");
expect(session?.user).toMatchObject({
name: "Katherine Johnson",
email: "[email protected]",
});
});
});
71 changes: 71 additions & 0 deletions apps/web/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createDrizzleDatabase, schema } from "@hooky/database";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";

import { database } from "./server-database";

const fallbackDevelopmentSecret =
"hooky-development-only-secret-change-before-deploying";

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";

export function createHookyAuth({
database,
baseURL,
secret,
secureCookies,
}: {
database: ReturnType<typeof createDrizzleDatabase>;
baseURL: string | undefined;
secret: string;
secureCookies: boolean;
}) {
return betterAuth({
appName: "Hooky",
baseURL,
secret,
database: drizzleAdapter(database, {
provider: "pg",
schema: {
user: schema.authUsers,
session: schema.authSessions,
account: schema.authAccounts,
verification: schema.authVerifications,
},
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 128,
},
trustedOrigins: baseURL ? [baseURL] : ["http://localhost:3000"],
rateLimit: {
enabled: true,
window: 60,
max: 100,
},
advanced: {
cookiePrefix: "hooky",
useSecureCookies: secureCookies,
disableCSRFCheck: false,
disableOriginCheck: false,
},
plugins: [nextCookies()],
});
}

export const auth = createHookyAuth({
database,
baseURL: configuredOrigin,
secret: process.env.BETTER_AUTH_SECRET ?? fallbackDevelopmentSecret,
secureCookies: process.env.NODE_ENV === "production",
});
14 changes: 14 additions & 0 deletions apps/web/src/lib/authenticated-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { auth } from "./auth";
import { accountStore } from "./server-database";

export async function authenticateAccount(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return null;
}

return accountStore.ensurePersonalAccount({
userId: session.user.id,
name: session.user.name,
});
}
101 changes: 101 additions & 0 deletions apps/web/src/lib/hooks-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";

import {
createHooksCollectionHandlers,
createRotateIngressSecretHandler,
} from "./hooks-api";

describe("hooks API", () => {
test("requires an authenticated account", async () => {
const handlers = createHooksCollectionHandlers({
authenticate: async () => null,
createHook: async () => {
throw new Error("must not be called");
},
listHooks: async () => [],
});

const response = await handlers.GET(
new Request("https://hooky.test/api/v1/hooks"),
);

expect(response.status).toBe(401);
});

test("creates an ingress URL without leaking tenant selection", async () => {
const handlers = createHooksCollectionHandlers({
authenticate: async () => ({ accountId: "account-one" }),
createHook: async ({ accountId, name }) => ({
hookId: `${accountId}:${name}`,
name,
state: "active" as const,
createdAt: new Date("2026-08-11T20:00:00.000Z"),
ingressToken: "hk_secret",
}),
listHooks: async () => [],
});

const response = await handlers.POST(
new Request("https://hooky.test/api/v1/hooks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "stripe-dev", accountId: "attacker" }),
}),
);

expect(response.status).toBe(201);
expect(await response.json()).toEqual({
hookId: "account-one:stripe-dev",
name: "stripe-dev",
state: "active",
createdAt: "2026-08-11T20:00:00.000Z",
ingressUrl: "https://hooky.test/e/hk_secret",
});
});

test("rejects invalid JSON input", async () => {
const handlers = createHooksCollectionHandlers({
authenticate: async () => ({ accountId: "account-one" }),
createHook: async () => {
throw new Error("must not be called");
},
listHooks: async () => [],
});
const response = await handlers.POST(
new Request("https://hooky.test/api/v1/hooks", {
method: "POST",
body: JSON.stringify({ name: "" }),
}),
);

expect(response.status).toBe(400);
});

test("rotates only through the authenticated account", async () => {
let rotatedFor: { accountId: string; hookId: string } | undefined;
const handler = createRotateIngressSecretHandler({
authenticate: async () => ({ accountId: "account-one" }),
rotateIngressSecret: async (input) => {
rotatedFor = input;
return { hookId: input.hookId, ingressToken: "hk_rotated" };
},
});

const response = await handler(
new Request(
"https://hooky.test/api/v1/hooks/hook-one/rotate-ingress-secret",
{ method: "POST" },
),
"hook-one",
);

expect(rotatedFor).toEqual({
accountId: "account-one",
hookId: "hook-one",
});
expect(await response.json()).toEqual({
hookId: "hook-one",
ingressUrl: "https://hooky.test/e/hk_rotated",
});
});
});
Loading
Loading