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
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"]);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { authenticateAccount } from "@/lib/authenticated-account";
import { authenticateApiAccount } 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,
authenticate: authenticateApiAccount,
rotateIngressSecret: (input) => hookStore.rotateIngressSecret(input),
});

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/v1/hooks/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { authenticateAccount } from "@/lib/authenticated-account";
import { authenticateApiAccount } 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,
authenticate: authenticateApiAccount,
createHook: (input) => hookStore.createHook(input),
listHooks: (input) => hookStore.listHooks(input),
});
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/app/api/v1/tokens/[token-id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { authenticateAccount } from "@/lib/authenticated-account";
import { apiTokenStore } from "@/lib/server-database";
import { createTokenRevocationHandler } from "@/lib/tokens-api";

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

const revoke = createTokenRevocationHandler({
authenticate: authenticateAccount,
revokeToken: (input) => apiTokenStore.revokeToken(input),
});

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

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

const handlers = createTokenCollectionHandlers({
authenticate: authenticateAccount,
createToken: (input) => apiTokenStore.createToken(input),
listTokens: (input) => apiTokenStore.listTokens(input),
});

export const { GET, POST } = handlers;
20 changes: 19 additions & 1 deletion apps/web/src/lib/authenticated-account.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { auth } from "./auth";
import { accountStore } from "./server-database";
import { accountStore, apiTokenStore } from "./server-database";

function hasTrustedOrigin(request: Request) {
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
return true;
}
return request.headers.get("origin") === new URL(request.url).origin;
}

export async function authenticateAccount(request: Request) {
if (!hasTrustedOrigin(request)) {
return null;
}
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return null;
Expand All @@ -12,3 +22,11 @@ export async function authenticateAccount(request: Request) {
name: session.user.name,
});
}

export async function authenticateApiAccount(request: Request) {
const authorization = request.headers.get("authorization");
if (authorization?.startsWith("Bearer ")) {
return apiTokenStore.authenticateToken(authorization.slice(7));
}
return authenticateAccount(request);
}
154 changes: 154 additions & 0 deletions apps/web/src/lib/listener-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test";

import {
createAcknowledgeHandler,
createClaimHandler,
createHeartbeatHandler,
createRejectHandler,
} from "./listener-api";

const authenticated = async () => ({ accountId: "account-one" });

describe("listener API", () => {
test("requires authentication before claiming deliveries", async () => {
const handler = createClaimHandler({
authenticate: async () => null,
claimDeliveries: async () => [],
now: () => new Date(),
});
const response = await handler(
new Request("https://hooky.test/claim", {
method: "POST",
body: JSON.stringify({ listenerId: "listener-one" }),
}),
"hook-one",
);

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

test("serializes claimed bytes and lease data for the CLI", async () => {
const handler = createClaimHandler({
authenticate: authenticated,
claimDeliveries: async (input) => [
{
deliveryId: "delivery-one",
eventId: "event-one",
attemptNumber: 1,
leaseToken: "lease-secret",
leasedUntil: new Date("2026-08-11T20:00:30.000Z"),
requestMethod: "POST",
requestPath: "/stripe",
query: { attempt: ["1", "2"] },
headers: { "stripe-signature": "signed" },
body: Buffer.from([0, 255, 1]),
receivedAt: new Date("2026-08-11T20:00:00.000Z"),
...input,
},
],
now: () => new Date("2026-08-11T20:00:01.000Z"),
});
const response = await handler(
new Request("https://hooky.test/claim", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
listenerId: "listener-one",
limit: 5,
leaseDurationSeconds: 30,
}),
}),
"hook-one",
);
const payload = (await response.json()) as {
deliveries: Array<{ bodyBase64: string; accountId?: string }>;
};

expect(response.status).toBe(200);
expect(payload.deliveries[0]?.bodyBase64).toBe("AP8B");
expect(payload.deliveries[0]?.accountId).toBeUndefined();
});

test("ACK, NACK, and heartbeat stay scoped to the authenticated account", async () => {
const calls: Array<Record<string, unknown>> = [];
const now = () => new Date("2026-08-11T20:00:10.000Z");
const acknowledge = createAcknowledgeHandler({
authenticate: authenticated,
acknowledgeDelivery: async (input) => {
calls.push(input);
return true;
},
now,
});
const reject = createRejectHandler({
authenticate: authenticated,
rejectDelivery: async (input) => {
calls.push(input);
return true;
},
now,
});
const heartbeat = createHeartbeatHandler({
authenticate: authenticated,
extendDeliveryLease: async (input) => {
calls.push(input);
return new Date("2026-08-11T20:00:40.000Z");
},
now,
});
const body = (value: Record<string, unknown>) =>
new Request("https://hooky.test/delivery", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(value),
});

expect(
(await acknowledge(body({ leaseToken: "lease-one" }), "delivery-one"))
.status,
).toBe(200);
expect(
(
await reject(
body({
leaseToken: "lease-two",
error: "localhost returned 503",
retryDelaySeconds: 4,
}),
"delivery-two",
)
).status,
).toBe(200);
expect(
(
await heartbeat(
body({ leaseToken: "lease-three", leaseDurationSeconds: 30 }),
"delivery-three",
)
).status,
).toBe(200);
expect(calls).toEqual([
{
accountId: "account-one",
deliveryId: "delivery-one",
leaseToken: "lease-one",
now: now(),
},
{
accountId: "account-one",
deliveryId: "delivery-two",
leaseToken: "lease-two",
error: "localhost returned 503",
retryAt: new Date("2026-08-11T20:00:14.000Z"),
now: now(),
},
{
accountId: "account-one",
deliveryId: "delivery-three",
leaseToken: "lease-three",
leaseDurationSeconds: 30,
now: now(),
},
]);
});
});
Loading
Loading