From 831345df5632e04542735d5a178548f7f3686f60 Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Tue, 11 Aug 2026 16:52:09 -0700 Subject: [PATCH] feat: add resilient local listener CLI --- .../v1/deliveries/[delivery-id]/ack/route.ts | 19 + .../[delivery-id]/heartbeat/route.ts | 19 + .../v1/deliveries/[delivery-id]/nack/route.ts | 19 + .../hooks/[hook-id]/deliveries/claim/route.ts | 19 + .../[hook-id]/rotate-ingress-secret/route.ts | 4 +- apps/web/src/app/api/v1/hooks/route.ts | 4 +- .../src/app/api/v1/tokens/[token-id]/route.ts | 18 + apps/web/src/app/api/v1/tokens/route.ts | 14 + apps/web/src/lib/authenticated-account.ts | 20 +- apps/web/src/lib/listener-api.test.ts | 154 +++ apps/web/src/lib/listener-api.ts | 197 +++ apps/web/src/lib/server-database.ts | 2 + apps/web/src/lib/tokens-api.test.ts | 66 + apps/web/src/lib/tokens-api.ts | 93 ++ bun.lock | 12 + package.json | 3 + packages/cli/.gitignore | 1 + packages/cli/eslint.config.mjs | 13 + packages/cli/package.json | 17 + packages/cli/src/api-client.test.ts | 40 + packages/cli/src/api-client.ts | 161 +++ packages/cli/src/bin.ts | 14 + packages/cli/src/cli.test.ts | 51 + packages/cli/src/cli.ts | 145 ++ packages/cli/src/config.test.ts | 30 + packages/cli/src/config.ts | 41 + packages/cli/src/listener.test.ts | 113 ++ packages/cli/src/listener.ts | 253 ++++ packages/cli/tsconfig.json | 8 + .../migrations/0002_steep_scalphunter.sql | 15 + .../migrations/meta/0002_snapshot.json | 1167 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/api-token-store.test.ts | 89 ++ packages/database/src/api-token-store.ts | 125 ++ packages/database/src/index.ts | 1 + packages/database/src/schema.ts | 20 + 36 files changed, 2969 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/api/v1/deliveries/[delivery-id]/ack/route.ts create mode 100644 apps/web/src/app/api/v1/deliveries/[delivery-id]/heartbeat/route.ts create mode 100644 apps/web/src/app/api/v1/deliveries/[delivery-id]/nack/route.ts create mode 100644 apps/web/src/app/api/v1/hooks/[hook-id]/deliveries/claim/route.ts create mode 100644 apps/web/src/app/api/v1/tokens/[token-id]/route.ts create mode 100644 apps/web/src/app/api/v1/tokens/route.ts create mode 100644 apps/web/src/lib/listener-api.test.ts create mode 100644 apps/web/src/lib/listener-api.ts create mode 100644 apps/web/src/lib/tokens-api.test.ts create mode 100644 apps/web/src/lib/tokens-api.ts create mode 100644 packages/cli/.gitignore create mode 100644 packages/cli/eslint.config.mjs create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/api-client.test.ts create mode 100644 packages/cli/src/api-client.ts create mode 100755 packages/cli/src/bin.ts create mode 100644 packages/cli/src/cli.test.ts create mode 100644 packages/cli/src/cli.ts create mode 100644 packages/cli/src/config.test.ts create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/listener.test.ts create mode 100644 packages/cli/src/listener.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/database/migrations/0002_steep_scalphunter.sql create mode 100644 packages/database/migrations/meta/0002_snapshot.json create mode 100644 packages/database/src/api-token-store.test.ts create mode 100644 packages/database/src/api-token-store.ts diff --git a/apps/web/src/app/api/v1/deliveries/[delivery-id]/ack/route.ts b/apps/web/src/app/api/v1/deliveries/[delivery-id]/ack/route.ts new file mode 100644 index 0000000..6545bb3 --- /dev/null +++ b/apps/web/src/app/api/v1/deliveries/[delivery-id]/ack/route.ts @@ -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"]); +} diff --git a/apps/web/src/app/api/v1/deliveries/[delivery-id]/heartbeat/route.ts b/apps/web/src/app/api/v1/deliveries/[delivery-id]/heartbeat/route.ts new file mode 100644 index 0000000..0108ead --- /dev/null +++ b/apps/web/src/app/api/v1/deliveries/[delivery-id]/heartbeat/route.ts @@ -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"]); +} diff --git a/apps/web/src/app/api/v1/deliveries/[delivery-id]/nack/route.ts b/apps/web/src/app/api/v1/deliveries/[delivery-id]/nack/route.ts new file mode 100644 index 0000000..0590b30 --- /dev/null +++ b/apps/web/src/app/api/v1/deliveries/[delivery-id]/nack/route.ts @@ -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"]); +} diff --git a/apps/web/src/app/api/v1/hooks/[hook-id]/deliveries/claim/route.ts b/apps/web/src/app/api/v1/hooks/[hook-id]/deliveries/claim/route.ts new file mode 100644 index 0000000..09f446d --- /dev/null +++ b/apps/web/src/app/api/v1/hooks/[hook-id]/deliveries/claim/route.ts @@ -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"]); +} diff --git a/apps/web/src/app/api/v1/hooks/[hook-id]/rotate-ingress-secret/route.ts b/apps/web/src/app/api/v1/hooks/[hook-id]/rotate-ingress-secret/route.ts index ea92431..1a3ff3f 100644 --- a/apps/web/src/app/api/v1/hooks/[hook-id]/rotate-ingress-secret/route.ts +++ b/apps/web/src/app/api/v1/hooks/[hook-id]/rotate-ingress-secret/route.ts @@ -1,4 +1,4 @@ -import { authenticateAccount } from "@/lib/authenticated-account"; +import { authenticateApiAccount } from "@/lib/authenticated-account"; import { createRotateIngressSecretHandler } from "@/lib/hooks-api"; import { hookStore } from "@/lib/server-database"; @@ -6,7 +6,7 @@ export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const rotate = createRotateIngressSecretHandler({ - authenticate: authenticateAccount, + authenticate: authenticateApiAccount, rotateIngressSecret: (input) => hookStore.rotateIngressSecret(input), }); diff --git a/apps/web/src/app/api/v1/hooks/route.ts b/apps/web/src/app/api/v1/hooks/route.ts index 17d982c..e627b46 100644 --- a/apps/web/src/app/api/v1/hooks/route.ts +++ b/apps/web/src/app/api/v1/hooks/route.ts @@ -1,4 +1,4 @@ -import { authenticateAccount } from "@/lib/authenticated-account"; +import { authenticateApiAccount } from "@/lib/authenticated-account"; import { createHooksCollectionHandlers } from "@/lib/hooks-api"; import { hookStore } from "@/lib/server-database"; @@ -6,7 +6,7 @@ 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), }); diff --git a/apps/web/src/app/api/v1/tokens/[token-id]/route.ts b/apps/web/src/app/api/v1/tokens/[token-id]/route.ts new file mode 100644 index 0000000..5bd93fb --- /dev/null +++ b/apps/web/src/app/api/v1/tokens/[token-id]/route.ts @@ -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"]); +} diff --git a/apps/web/src/app/api/v1/tokens/route.ts b/apps/web/src/app/api/v1/tokens/route.ts new file mode 100644 index 0000000..42e33e5 --- /dev/null +++ b/apps/web/src/app/api/v1/tokens/route.ts @@ -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; diff --git a/apps/web/src/lib/authenticated-account.ts b/apps/web/src/lib/authenticated-account.ts index dbf40d7..a309c62 100644 --- a/apps/web/src/lib/authenticated-account.ts +++ b/apps/web/src/lib/authenticated-account.ts @@ -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; @@ -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); +} diff --git a/apps/web/src/lib/listener-api.test.ts b/apps/web/src/lib/listener-api.test.ts new file mode 100644 index 0000000..becd254 --- /dev/null +++ b/apps/web/src/lib/listener-api.test.ts @@ -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> = []; + 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) => + 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(), + }, + ]); + }); +}); diff --git a/apps/web/src/lib/listener-api.ts b/apps/web/src/lib/listener-api.ts new file mode 100644 index 0000000..2963b00 --- /dev/null +++ b/apps/web/src/lib/listener-api.ts @@ -0,0 +1,197 @@ +import type { ClaimedDelivery } from "@hooky/database"; +import { z } from "zod"; + +const claimInput = z.object({ + listenerId: z.string().trim().min(1).max(128), + limit: z.number().int().min(1).max(10).default(5), + leaseDurationSeconds: z.number().int().min(10).max(300).default(30), +}); +const leaseInput = z.object({ + leaseToken: z.string().min(1).max(256), +}); +const rejectInput = leaseInput.extend({ + error: z.string().trim().min(1).max(1_000), + retryDelaySeconds: z.number().int().min(1).max(3_600).default(1), +}); +const heartbeatInput = leaseInput.extend({ + leaseDurationSeconds: z.number().int().min(10).max(300).default(30), +}); + +type Authentication = { accountId: string } | null; + +function unauthorized() { + return Response.json({ error: "Authentication required" }, { status: 401 }); +} + +async function parseJson(request: Request, schema: T) { + return schema.safeParse(await request.json().catch(() => undefined)); +} + +function invalidInput() { + return Response.json({ error: "Invalid request body" }, { status: 400 }); +} + +function serializeDelivery(delivery: ClaimedDelivery) { + return { + deliveryId: delivery.deliveryId, + eventId: delivery.eventId, + attemptNumber: delivery.attemptNumber, + leaseToken: delivery.leaseToken, + leasedUntil: delivery.leasedUntil, + requestMethod: delivery.requestMethod, + requestPath: delivery.requestPath, + query: delivery.query, + headers: delivery.headers, + bodyBase64: delivery.body.toString("base64"), + receivedAt: delivery.receivedAt, + }; +} + +export function createClaimHandler({ + authenticate, + claimDeliveries, + now, +}: { + authenticate: (request: Request) => Promise; + claimDeliveries: (input: { + accountId: string; + hookId: string; + listenerId: string; + limit: number; + leaseDurationSeconds: number; + now: Date; + }) => Promise; + now: () => Date; +}) { + return async function claim(request: Request, hookId: string) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + const input = await parseJson(request, claimInput); + if (!input.success) { + return invalidInput(); + } + + const deliveries = await claimDeliveries({ + accountId: authentication.accountId, + hookId, + ...input.data, + now: now(), + }); + return Response.json({ deliveries: deliveries.map(serializeDelivery) }); + }; +} + +export function createAcknowledgeHandler({ + authenticate, + acknowledgeDelivery, + now, +}: { + authenticate: (request: Request) => Promise; + acknowledgeDelivery: (input: { + accountId: string; + deliveryId: string; + leaseToken: string; + now: Date; + }) => Promise; + now: () => Date; +}) { + return async function acknowledge(request: Request, deliveryId: string) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + const input = await parseJson(request, leaseInput); + if (!input.success) { + return invalidInput(); + } + + const accepted = await acknowledgeDelivery({ + accountId: authentication.accountId, + deliveryId, + leaseToken: input.data.leaseToken, + now: now(), + }); + return Response.json({ accepted }, { status: accepted ? 200 : 409 }); + }; +} + +export function createRejectHandler({ + authenticate, + rejectDelivery, + now, +}: { + authenticate: (request: Request) => Promise; + rejectDelivery: (input: { + accountId: string; + deliveryId: string; + leaseToken: string; + error: string; + retryAt: Date; + now: Date; + }) => Promise; + now: () => Date; +}) { + return async function reject(request: Request, deliveryId: string) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + const input = await parseJson(request, rejectInput); + if (!input.success) { + return invalidInput(); + } + + const requestTime = now(); + const accepted = await rejectDelivery({ + accountId: authentication.accountId, + deliveryId, + leaseToken: input.data.leaseToken, + error: input.data.error, + retryAt: new Date( + requestTime.getTime() + input.data.retryDelaySeconds * 1_000, + ), + now: requestTime, + }); + return Response.json({ accepted }, { status: accepted ? 200 : 409 }); + }; +} + +export function createHeartbeatHandler({ + authenticate, + extendDeliveryLease, + now, +}: { + authenticate: (request: Request) => Promise; + extendDeliveryLease: (input: { + accountId: string; + deliveryId: string; + leaseToken: string; + leaseDurationSeconds: number; + now: Date; + }) => Promise; + now: () => Date; +}) { + return async function heartbeat(request: Request, deliveryId: string) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + const input = await parseJson(request, heartbeatInput); + if (!input.success) { + return invalidInput(); + } + + const leasedUntil = await extendDeliveryLease({ + accountId: authentication.accountId, + deliveryId, + ...input.data, + now: now(), + }); + return Response.json( + { accepted: Boolean(leasedUntil), leasedUntil }, + { status: leasedUntil ? 200 : 409 }, + ); + }; +} diff --git a/apps/web/src/lib/server-database.ts b/apps/web/src/lib/server-database.ts index ecec305..e598132 100644 --- a/apps/web/src/lib/server-database.ts +++ b/apps/web/src/lib/server-database.ts @@ -1,5 +1,6 @@ import { AccountStore, + ApiTokenStore, createDatabasePool, createDrizzleDatabase, DeliveryStore, @@ -24,5 +25,6 @@ if (process.env.NODE_ENV !== "production") { export const database = createDrizzleDatabase(databasePool); export const accountStore = new AccountStore(databasePool); +export const apiTokenStore = new ApiTokenStore(databasePool); export const hookStore = new HookStore(databasePool); export const deliveryStore = new DeliveryStore(databasePool); diff --git a/apps/web/src/lib/tokens-api.test.ts b/apps/web/src/lib/tokens-api.test.ts new file mode 100644 index 0000000..4f226f8 --- /dev/null +++ b/apps/web/src/lib/tokens-api.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; + +import { + createTokenCollectionHandlers, + createTokenRevocationHandler, +} from "./tokens-api"; + +describe("tokens API", () => { + test("returns a newly created secret exactly once", async () => { + const handlers = createTokenCollectionHandlers({ + authenticate: async () => ({ accountId: "account-one" }), + createToken: async (input) => ({ + tokenId: "token-one", + name: input.name, + prefix: "hky_prefix", + token: "hky_plaintext_secret", + createdAt: new Date("2026-08-11T20:00:00.000Z"), + }), + listTokens: async () => [ + { + tokenId: "token-one", + name: "MacBook", + prefix: "hky_prefix", + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + createdAt: new Date("2026-08-11T20:00:00.000Z"), + }, + ], + }); + const created = await handlers.POST( + new Request("https://hooky.test/api/v1/tokens", { + method: "POST", + body: JSON.stringify({ name: "MacBook", accountId: "attacker" }), + }), + ); + const listed = await handlers.GET( + new Request("https://hooky.test/api/v1/tokens"), + ); + + expect((await created.json()).token).toBe("hky_plaintext_secret"); + expect(JSON.stringify(await listed.json())).not.toContain( + "hky_plaintext_secret", + ); + }); + + test("revokes within the authenticated account", async () => { + let input: { accountId: string; tokenId: string } | undefined; + const revoke = createTokenRevocationHandler({ + authenticate: async () => ({ accountId: "account-one" }), + revokeToken: async (value) => { + input = value; + return true; + }, + }); + const response = await revoke( + new Request("https://hooky.test/api/v1/tokens/token-one", { + method: "DELETE", + }), + "token-one", + ); + + expect(response.status).toBe(200); + expect(input).toEqual({ accountId: "account-one", tokenId: "token-one" }); + }); +}); diff --git a/apps/web/src/lib/tokens-api.ts b/apps/web/src/lib/tokens-api.ts new file mode 100644 index 0000000..8b31b0b --- /dev/null +++ b/apps/web/src/lib/tokens-api.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; + +const tokenInput = z.object({ + name: z.string().trim().min(1).max(80), +}); + +type Authentication = { accountId: string } | null; +type TokenMetadata = { + tokenId: string; + name: string; + prefix: string; + lastUsedAt: Date | null; + expiresAt: Date | null; + revokedAt: Date | null; + createdAt: Date; +}; + +function unauthorized() { + return Response.json({ error: "Authentication required" }, { status: 401 }); +} + +export function createTokenCollectionHandlers({ + authenticate, + createToken, + listTokens, +}: { + authenticate: (request: Request) => Promise; + createToken: (input: { accountId: string; name: string }) => Promise<{ + tokenId: string; + name: string; + prefix: string; + token: string; + createdAt: Date; + }>; + listTokens: (input: { accountId: string }) => Promise; +}) { + return { + async GET(request: Request) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + return Response.json({ + tokens: await listTokens({ accountId: authentication.accountId }), + }); + }, + async POST(request: Request) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + const input = tokenInput.safeParse( + await request.json().catch(() => undefined), + ); + if (!input.success) { + return Response.json( + { error: "A token name between 1 and 80 characters is required" }, + { status: 400 }, + ); + } + return Response.json( + await createToken({ + accountId: authentication.accountId, + name: input.data.name, + }), + { status: 201 }, + ); + }, + }; +} + +export function createTokenRevocationHandler({ + authenticate, + revokeToken, +}: { + authenticate: (request: Request) => Promise; + revokeToken: (input: { + accountId: string; + tokenId: string; + }) => Promise; +}) { + return async function revoke(request: Request, tokenId: string) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + const revoked = await revokeToken({ + accountId: authentication.accountId, + tokenId, + }); + return Response.json({ revoked }, { status: revoked ? 200 : 404 }); + }; +} diff --git a/bun.lock b/bun.lock index 66646e0..1354b24 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,16 @@ "typescript": "^5.9.3", }, }, + "packages/cli": { + "name": "@hooky/cli", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "1.3.14", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "8.67.0", + }, + }, "packages/database": { "name": "@hooky/database", "version": "0.0.0", @@ -189,6 +199,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@hooky/cli": ["@hooky/cli@workspace:packages/cli"], + "@hooky/database": ["@hooky/database@workspace:packages/database"], "@hooky/web": ["@hooky/web@workspace:apps/web"], diff --git a/package.json b/package.json index 4ac0825..37f4b68 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "hooky", "version": "0.0.0", "private": true, + "bin": { + "hooky": "packages/cli/src/bin.ts" + }, "packageManager": "bun@1.3.14", "workspaces": [ "apps/*", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs new file mode 100644 index 0000000..3bd2098 --- /dev/null +++ b/packages/cli/eslint.config.mjs @@ -0,0 +1,13 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**"], + }, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/consistent-type-imports": "error", + }, + }, +); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..27e7288 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hooky/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "bun build src/bin.ts --target=bun --outfile dist/hooky.js", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "8.67.0" + } +} diff --git a/packages/cli/src/api-client.test.ts b/packages/cli/src/api-client.test.ts new file mode 100644 index 0000000..b3e9e75 --- /dev/null +++ b/packages/cli/src/api-client.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { HookyApiClient, HookyApiError } from "./api-client"; + +describe("Hooky API client", () => { + test("sends bearer authentication and parses responses", async () => { + let request: Request | undefined; + const client = new HookyApiClient({ + apiUrl: "https://hooky.test/", + token: "hky_secret", + fetchImplementation: async (input, init) => { + request = new Request(input, init); + return Response.json({ + hooks: [{ hookId: "hook-one", name: "stripe", state: "active" }], + }); + }, + }); + + const hooks = await client.listHooks(); + + expect(hooks).toEqual([ + { hookId: "hook-one", name: "stripe", state: "active" }, + ]); + expect(request?.url).toBe("https://hooky.test/api/v1/hooks"); + expect(request?.headers.get("authorization")).toBe("Bearer hky_secret"); + }); + + test("raises useful API errors", async () => { + const client = new HookyApiClient({ + apiUrl: "https://hooky.test", + token: "bad-token", + fetchImplementation: async () => + Response.json({ error: "Authentication required" }, { status: 401 }), + }); + + await expect(client.listHooks()).rejects.toEqual( + new HookyApiError("Authentication required", 401), + ); + }); +}); diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts new file mode 100644 index 0000000..f27e8e4 --- /dev/null +++ b/packages/cli/src/api-client.ts @@ -0,0 +1,161 @@ +export type HookSummary = { + hookId: string; + name: string; + state: "active" | "disabled"; + createdAt?: string; + updatedAt?: string; +}; + +export type ClaimedDelivery = { + deliveryId: string; + eventId: string; + attemptNumber: number; + leaseToken: string; + leasedUntil: string; + requestMethod: string; + requestPath: string; + query: Record; + headers: Record; + bodyBase64: string; + receivedAt: string; +}; + +export type FetchImplementation = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export class HookyApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "HookyApiError"; + } +} + +export class HookyApiClient { + private readonly apiUrl: string; + private readonly token: string; + private readonly fetchImplementation: FetchImplementation; + + constructor({ + apiUrl, + token, + fetchImplementation = fetch, + }: { + apiUrl: string; + token: string; + fetchImplementation?: FetchImplementation; + }) { + this.apiUrl = apiUrl.replace(/\/$/, ""); + this.token = token; + this.fetchImplementation = fetchImplementation; + } + + private async request( + path: string, + { method = "GET", body }: { method?: string; body?: unknown } = {}, + ): Promise { + const response = await this.fetchImplementation(`${this.apiUrl}${path}`, { + method, + headers: { + authorization: `Bearer ${this.token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = (await response.json().catch(() => ({}))) as { + error?: string; + } & T; + + if (!response.ok) { + throw new HookyApiError( + payload.error ?? `Hooky API returned ${response.status}`, + response.status, + ); + } + return payload; + } + + async listHooks() { + const response = await this.request<{ hooks: HookSummary[] }>( + "/api/v1/hooks", + ); + return response.hooks; + } + + async createHook(name: string) { + return this.request("/api/v1/hooks", { + method: "POST", + body: { name }, + }); + } + + async claimDeliveries({ + hookId, + listenerId, + limit = 5, + leaseDurationSeconds = 30, + }: { + hookId: string; + listenerId: string; + limit?: number; + leaseDurationSeconds?: number; + }) { + const response = await this.request<{ deliveries: ClaimedDelivery[] }>( + `/api/v1/hooks/${encodeURIComponent(hookId)}/deliveries/claim`, + { + method: "POST", + body: { listenerId, limit, leaseDurationSeconds }, + }, + ); + return response.deliveries; + } + + async acknowledge({ + deliveryId, + leaseToken, + }: { + deliveryId: string; + leaseToken: string; + }) { + await this.request(`/api/v1/deliveries/${deliveryId}/ack`, { + method: "POST", + body: { leaseToken }, + }); + } + + async reject({ + deliveryId, + leaseToken, + error, + retryDelaySeconds, + }: { + deliveryId: string; + leaseToken: string; + error: string; + retryDelaySeconds: number; + }) { + await this.request(`/api/v1/deliveries/${deliveryId}/nack`, { + method: "POST", + body: { leaseToken, error, retryDelaySeconds }, + }); + } + + async heartbeat({ + deliveryId, + leaseToken, + leaseDurationSeconds = 30, + }: { + deliveryId: string; + leaseToken: string; + leaseDurationSeconds?: number; + }) { + await this.request(`/api/v1/deliveries/${deliveryId}/heartbeat`, { + method: "POST", + body: { leaseToken, leaseDurationSeconds }, + }); + } +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts new file mode 100755 index 0000000..ea69a7c --- /dev/null +++ b/packages/cli/src/bin.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env bun + +import { runCli } from "./cli"; + +const controller = new AbortController(); +process.once("SIGINT", () => controller.abort()); +process.once("SIGTERM", () => controller.abort()); + +try { + await runCli({ args: process.argv.slice(2), signal: controller.signal }); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 0000000..3856745 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCli } from "./cli"; +import { readConfig } from "./config"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("CLI", () => { + test("validates a token before saving login credentials", async () => { + const directory = await mkdtemp(join(tmpdir(), "hooky-cli-test-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "config.json"); + let authorization = ""; + const output: string[] = []; + + await runCli({ + args: [ + "login", + "--token", + "hky_secret", + "--api-url", + "https://hooky.test", + ], + configPath, + fetchImplementation: async (input, init) => { + authorization = + new Request(input, init).headers.get("authorization") ?? ""; + return Response.json({ hooks: [] }); + }, + writeOutput: (message) => output.push(message), + }); + + expect(authorization).toBe("Bearer hky_secret"); + expect(await readConfig(configPath)).toEqual({ + apiUrl: "https://hooky.test", + token: "hky_secret", + }); + expect(output).toEqual(["Authenticated with https://hooky.test"]); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..b2b1a36 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,145 @@ +import type { FetchImplementation } from "./api-client"; +import { HookyApiClient } from "./api-client"; +import { defaultConfigPath, readConfig, writeConfig } from "./config"; +import { listenForDeliveries, selectHook } from "./listener"; + +const defaultApiUrl = "https://hooky.vercel.app"; + +const usage = `Hooky — deliver public webhooks to a local endpoint + +Usage: + hooky login --token [--api-url ] + hooky hooks + hooky listen --to [--hook | --new ] + +Environment: + HOOKY_TOKEN API token (takes precedence over saved config) + HOOKY_API_URL Hooky service URL + HOOKY_CONFIG_PATH Override the credential file path`; + +function option(args: string[], name: string) { + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +async function credentials({ + environment, + configPath, +}: { + environment: Record; + configPath: string; +}) { + const saved = await readConfig(configPath); + const token = environment.HOOKY_TOKEN ?? saved?.token; + const apiUrl = environment.HOOKY_API_URL ?? saved?.apiUrl ?? defaultApiUrl; + if (!token) { + throw new Error("Run `hooky login --token ` first"); + } + return { token, apiUrl }; +} + +export async function runCli({ + args, + environment = process.env, + configPath = defaultConfigPath(environment), + fetchImplementation = fetch, + signal = new AbortController().signal, + writeOutput = (message) => console.log(message), +}: { + args: string[]; + environment?: Record; + configPath?: string; + fetchImplementation?: FetchImplementation; + signal?: AbortSignal; + writeOutput?: (message: string) => void; +}) { + const [command] = args; + if (!command || command === "help" || command === "--help") { + writeOutput(usage); + return; + } + if (command === "--version" || command === "version") { + writeOutput("0.1.0"); + return; + } + + if (command === "login") { + const token = option(args, "--token") ?? environment.HOOKY_TOKEN; + if (!token) { + throw new Error("login requires --token "); + } + const apiUrl = + option(args, "--api-url") ?? environment.HOOKY_API_URL ?? defaultApiUrl; + const client = new HookyApiClient({ + apiUrl, + token, + fetchImplementation, + }); + await client.listHooks(); + await writeConfig(configPath, { apiUrl, token }); + writeOutput(`Authenticated with ${apiUrl}`); + return; + } + + const configured = await credentials({ environment, configPath }); + const client = new HookyApiClient({ + ...configured, + fetchImplementation, + }); + + if (command === "hooks") { + const hooks = await client.listHooks(); + if (hooks.length === 0) { + writeOutput("No hooks yet."); + return; + } + for (const hook of hooks) { + writeOutput(`${hook.name}\t${hook.hookId}\t${hook.state}`); + } + return; + } + + if (command === "listen") { + const destination = option(args, "--to"); + if (!destination) { + throw new Error("listen requires --to "); + } + const parsedDestination = new URL(destination); + if (!["http:", "https:"].includes(parsedDestination.protocol)) { + throw new Error("--to must be an HTTP or HTTPS URL"); + } + const selected = await selectHook({ + selector: option(args, "--hook"), + createName: option(args, "--new"), + listHooks: () => client.listHooks(), + createHook: (name) => client.createHook(name), + }); + if ("ingressUrl" in selected) { + writeOutput(`Webhook URL: ${selected.ingressUrl}`); + } + writeOutput(`Listening on ${selected.name} → ${parsedDestination}`); + await listenForDeliveries({ + hookId: selected.hookId, + destination: parsedDestination.toString(), + signal, + client, + fetchImplementation, + onResult: (result) => + writeOutput( + result.delivered + ? `✓ ${result.deliveryId} → ${result.status}` + : `↻ ${result.deliveryId} → ${result.error}`, + ), + }); + return; + } + + throw new Error(`Unknown command "${command}"\n\n${usage}`); +} diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts new file mode 100644 index 0000000..02d34e2 --- /dev/null +++ b/packages/cli/src/config.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readConfig, writeConfig } from "./config"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("CLI config", () => { + test("stores credentials in a user-only file", async () => { + const directory = await mkdtemp(join(tmpdir(), "hooky-config-test-")); + temporaryDirectories.push(directory); + const path = join(directory, "nested", "config.json"); + const config = { apiUrl: "https://hooky.test", token: "hky_secret" }; + + await writeConfig(path, config); + + expect(await readConfig(path)).toEqual(config); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); +}); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..4e34f21 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,41 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export type HookyConfig = { + apiUrl: string; + token: string; +}; + +export function defaultConfigPath(environment = process.env) { + return ( + environment.HOOKY_CONFIG_PATH ?? + join(homedir(), ".config", "hooky", "config.json") + ); +} + +export async function readConfig(path: string): Promise { + try { + const value = JSON.parse( + await readFile(path, "utf8"), + ) as Partial; + return typeof value.apiUrl === "string" && typeof value.token === "string" + ? { apiUrl: value.apiUrl, token: value.token } + : null; + } catch (error) { + if (error && typeof error === "object" && "code" in error) { + if (error.code === "ENOENT") { + return null; + } + } + throw error; + } +} + +export async function writeConfig(path: string, config: HookyConfig) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await chmod(path, 0o600); +} diff --git a/packages/cli/src/listener.test.ts b/packages/cli/src/listener.test.ts new file mode 100644 index 0000000..ad1258b --- /dev/null +++ b/packages/cli/src/listener.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; + +import { forwardDelivery, selectHook } from "./listener"; + +const delivery = { + deliveryId: "delivery-one", + eventId: "event-one", + attemptNumber: 2, + leaseToken: "lease-secret", + leasedUntil: "2026-08-11T20:00:30.000Z", + requestMethod: "POST", + requestPath: "/stripe", + query: { attempt: ["1", "2"], source: "stripe" }, + headers: { + host: "hooky.test", + "content-length": "4", + "content-type": "application/octet-stream", + "stripe-signature": "signed", + }, + bodyBase64: Buffer.from([0, 255, 1, 2]).toString("base64"), + receivedAt: "2026-08-11T20:00:00.000Z", +}; + +describe("listener", () => { + test("forwards exact bytes and safe headers, then ACKs a success", async () => { + let localRequest: Request | undefined; + const acknowledgements: string[] = []; + await forwardDelivery({ + delivery, + destination: "http://127.0.0.1:3000/webhooks?configured=yes", + fetchImplementation: async (input, init) => { + localRequest = new Request(input, init); + return new Response(null, { status: 204 }); + }, + acknowledge: async (value) => { + acknowledgements.push(value.deliveryId); + }, + reject: async () => { + throw new Error("must not reject"); + }, + }); + + expect(localRequest?.url).toBe( + "http://127.0.0.1:3000/webhooks?configured=yes&attempt=1&attempt=2&source=stripe", + ); + expect(localRequest?.headers.get("host")).toBeNull(); + expect(localRequest?.headers.get("stripe-signature")).toBe("signed"); + expect(Buffer.from(await localRequest!.arrayBuffer())).toEqual( + Buffer.from([0, 255, 1, 2]), + ); + expect(acknowledgements).toEqual(["delivery-one"]); + }); + + test("NACKs local failures with bounded exponential retry", async () => { + let rejected: + | { + deliveryId: string; + leaseToken: string; + error: string; + retryDelaySeconds: number; + } + | undefined; + await forwardDelivery({ + delivery, + destination: "http://127.0.0.1:3000/webhooks", + fetchImplementation: async () => new Response("down", { status: 503 }), + acknowledge: async () => { + throw new Error("must not acknowledge"); + }, + reject: async (value) => { + rejected = value; + }, + }); + + expect(rejected).toEqual({ + deliveryId: "delivery-one", + leaseToken: "lease-secret", + error: "Local destination returned 503", + retryDelaySeconds: 2, + }); + }); + + test("selects an existing hook or creates one on request", async () => { + const hooks = [ + { hookId: "hook-one", name: "stripe-dev", state: "active" as const }, + { hookId: "hook-two", name: "github-dev", state: "active" as const }, + ]; + + expect( + await selectHook({ + selector: "stripe-dev", + createName: undefined, + listHooks: async () => hooks, + createHook: async () => { + throw new Error("must not create"); + }, + }), + ).toMatchObject({ hookId: "hook-one" }); + expect( + await selectHook({ + selector: undefined, + createName: "linear-dev", + listHooks: async () => hooks, + createHook: async (name) => ({ + hookId: "hook-three", + name, + state: "active" as const, + ingressUrl: "https://hooky.test/e/new", + }), + }), + ).toMatchObject({ hookId: "hook-three" }); + }); +}); diff --git a/packages/cli/src/listener.ts b/packages/cli/src/listener.ts new file mode 100644 index 0000000..cfe41bc --- /dev/null +++ b/packages/cli/src/listener.ts @@ -0,0 +1,253 @@ +import type { + ClaimedDelivery, + FetchImplementation, + HookSummary, +} from "./api-client"; + +const hopByHopHeaders = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +function destinationUrl( + destination: string, + query: Record, +) { + const url = new URL(destination); + for (const [key, value] of Object.entries(query)) { + for (const item of Array.isArray(value) ? value : [value]) { + url.searchParams.append(key, item); + } + } + return url; +} + +function forwardedHeaders(headers: Record) { + const result = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (hopByHopHeaders.has(name.toLowerCase())) { + continue; + } + for (const item of Array.isArray(value) ? value : [value]) { + result.append(name, item); + } + } + return result; +} + +function retryDelay(attemptNumber: number) { + return Math.min(60, 2 ** Math.max(0, attemptNumber - 1)); +} + +export async function forwardDelivery({ + delivery, + destination, + fetchImplementation, + acknowledge, + reject, +}: { + delivery: ClaimedDelivery; + destination: string; + fetchImplementation: FetchImplementation; + acknowledge: (input: { + deliveryId: string; + leaseToken: string; + }) => Promise; + reject: (input: { + deliveryId: string; + leaseToken: string; + error: string; + retryDelaySeconds: number; + }) => Promise; +}) { + try { + const response = await fetchImplementation( + destinationUrl(destination, delivery.query), + { + method: delivery.requestMethod, + headers: forwardedHeaders(delivery.headers), + body: ["GET", "HEAD"].includes(delivery.requestMethod) + ? undefined + : Buffer.from(delivery.bodyBase64, "base64"), + redirect: "manual", + }, + ); + + if (response.ok) { + await acknowledge({ + deliveryId: delivery.deliveryId, + leaseToken: delivery.leaseToken, + }); + return { delivered: true, status: response.status }; + } + + const error = `Local destination returned ${response.status}`; + await reject({ + deliveryId: delivery.deliveryId, + leaseToken: delivery.leaseToken, + error, + retryDelaySeconds: retryDelay(delivery.attemptNumber), + }); + return { delivered: false, status: response.status, error }; + } catch (cause) { + const error = `Local destination was unreachable: ${cause instanceof Error ? cause.message : String(cause)}`; + await reject({ + deliveryId: delivery.deliveryId, + leaseToken: delivery.leaseToken, + error, + retryDelaySeconds: retryDelay(delivery.attemptNumber), + }); + return { delivered: false, error }; + } +} + +export async function selectHook({ + selector, + createName, + listHooks, + createHook, +}: { + selector: string | undefined; + createName: string | undefined; + listHooks: () => Promise; + createHook: (name: string) => Promise; +}) { + if (selector && createName) { + throw new Error("Use either --hook or --new, not both"); + } + if (createName) { + return createHook(createName); + } + + const activeHooks = (await listHooks()).filter( + (hook) => hook.state === "active", + ); + if (selector) { + const selected = activeHooks.find( + (hook) => hook.hookId === selector || hook.name === selector, + ); + if (!selected) { + throw new Error(`No active hook matches "${selector}"`); + } + return selected; + } + if (activeHooks.length === 1) { + return activeHooks[0]!; + } + if (activeHooks.length === 0) { + return createHook("local"); + } + throw new Error("Multiple hooks exist; choose one with --hook "); +} + +function waitForPoll(signal: AbortSignal, delayMilliseconds: number) { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timeout = setTimeout(resolve, delayMilliseconds); + signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); +} + +export async function listenForDeliveries({ + hookId, + destination, + signal, + client, + fetchImplementation = fetch, + onResult = () => undefined, +}: { + hookId: string; + destination: string; + signal: AbortSignal; + client: { + claimDeliveries: (input: { + hookId: string; + listenerId: string; + limit: number; + leaseDurationSeconds: number; + }) => Promise; + acknowledge: (input: { + deliveryId: string; + leaseToken: string; + }) => Promise; + reject: (input: { + deliveryId: string; + leaseToken: string; + error: string; + retryDelaySeconds: number; + }) => Promise; + heartbeat: (input: { + deliveryId: string; + leaseToken: string; + leaseDurationSeconds: number; + }) => Promise; + }; + fetchImplementation?: FetchImplementation; + onResult?: (result: { + deliveryId: string; + delivered: boolean; + status?: number; + error?: string; + }) => void; +}) { + const listenerId = `cli-${crypto.randomUUID()}`; + + while (!signal.aborted) { + const deliveries = await client.claimDeliveries({ + hookId, + listenerId, + limit: 5, + leaseDurationSeconds: 30, + }); + if (deliveries.length === 0) { + await waitForPoll(signal, 750); + continue; + } + + for (const delivery of deliveries) { + if (signal.aborted) { + return; + } + const heartbeat = setInterval(() => { + void client + .heartbeat({ + deliveryId: delivery.deliveryId, + leaseToken: delivery.leaseToken, + leaseDurationSeconds: 30, + }) + .catch(() => undefined); + }, 10_000); + + try { + const result = await forwardDelivery({ + delivery, + destination, + fetchImplementation, + acknowledge: (input) => client.acknowledge(input), + reject: (input) => client.reject(input), + }); + onResult({ deliveryId: delivery.deliveryId, ...result }); + } finally { + clearInterval(heartbeat); + } + } + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..303736f --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["bun"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/database/migrations/0002_steep_scalphunter.sql b/packages/database/migrations/0002_steep_scalphunter.sql new file mode 100644 index 0000000..eaf987c --- /dev/null +++ b/packages/database/migrations/0002_steep_scalphunter.sql @@ -0,0 +1,15 @@ +CREATE TABLE "api_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "name" text NOT NULL, + "prefix" text NOT NULL, + "token_hash" text NOT NULL, + "last_used_at" timestamp with time zone, + "expires_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "api_tokens_account_id_index" ON "api_tokens" USING btree ("account_id"); \ No newline at end of file diff --git a/packages/database/migrations/meta/0002_snapshot.json b/packages/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..9440dfc --- /dev/null +++ b/packages/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,1167 @@ +{ + "id": "de0c11fc-bc3d-486a-898d-20e286d90331", + "prevId": "9e3a483a-506c-42bf-b738-4e58a21bdb78", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_members": { + "name": "account_members", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "account_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_members_user_id_unique": { + "name": "account_members_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_members_account_id_accounts_id_fk": { + "name": "account_members_account_id_accounts_id_fk", + "tableFrom": "account_members", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_members_user_id_auth_users_id_fk": { + "name": "account_members_user_id_auth_users_id_fk", + "tableFrom": "account_members", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_members_account_id_user_id_pk": { + "name": "account_members_account_id_user_id_pk", + "columns": ["account_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_tokens_account_id_index": { + "name": "api_tokens_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_account_id_accounts_id_fk": { + "name": "api_tokens_account_id_accounts_id_fk", + "tableFrom": "api_tokens", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_index": { + "name": "auth_accounts_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_sessions_user_id_index": { + "name": "auth_sessions_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_index": { + "name": "auth_verifications_identifier_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hook_id": { + "name": "hook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "leased_by": { + "name": "leased_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token_hash": { + "name": "lease_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leased_until": { + "name": "leased_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_claim_index": { + "name": "deliveries_claim_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "leased_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_account_hook_fk": { + "name": "deliveries_account_hook_fk", + "tableFrom": "deliveries", + "tableTo": "hooks", + "columnsFrom": ["account_id", "hook_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_account_event_fk": { + "name": "deliveries_account_event_fk", + "tableFrom": "deliveries", + "tableTo": "webhook_events", + "columnsFrom": ["account_id", "event_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deliveries_account_id_id_unique": { + "name": "deliveries_account_id_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id", "id"] + }, + "deliveries_event_id_unique": { + "name": "deliveries_event_id_unique", + "nullsNotDistinct": false, + "columns": ["event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_attempts": { + "name": "delivery_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listener_id": { + "name": "listener_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_token_hash": { + "name": "lease_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "delivery_attempt_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "delivery_attempts_account_delivery_index": { + "name": "delivery_attempts_account_delivery_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_attempts_account_delivery_fk": { + "name": "delivery_attempts_account_delivery_fk", + "tableFrom": "delivery_attempts", + "tableTo": "deliveries", + "columnsFrom": ["account_id", "delivery_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_attempts_delivery_number_unique": { + "name": "delivery_attempts_delivery_number_unique", + "nullsNotDistinct": false, + "columns": ["delivery_id", "attempt_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hook_secrets": { + "name": "hook_secrets", + "schema": "", + "columns": { + "hook_id": { + "name": "hook_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ingress_secret_hash": { + "name": "ingress_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hook_secrets_hook_id_hooks_id_fk": { + "name": "hook_secrets_hook_id_hooks_id_fk", + "tableFrom": "hook_secrets", + "tableTo": "hooks", + "columnsFrom": ["hook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hook_secrets_account_id_accounts_id_fk": { + "name": "hook_secrets_account_id_accounts_id_fk", + "tableFrom": "hook_secrets", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hook_secrets_account_hook_fk": { + "name": "hook_secrets_account_hook_fk", + "tableFrom": "hook_secrets", + "tableTo": "hooks", + "columnsFrom": ["account_id", "hook_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hook_secrets_ingress_secret_hash_unique": { + "name": "hook_secrets_ingress_secret_hash_unique", + "nullsNotDistinct": false, + "columns": ["ingress_secret_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hooks": { + "name": "hooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "hook_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hooks_account_id_name_unique": { + "name": "hooks_account_id_name_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hooks_account_id_accounts_id_fk": { + "name": "hooks_account_id_accounts_id_fk", + "tableFrom": "hooks", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hooks_account_id_id_unique": { + "name": "hooks_account_id_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hook_id": { + "name": "hook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_path": { + "name": "request_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "body": { + "name": "body", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "body_sha256": { + "name": "body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "webhook_events_hook_received_at_index": { + "name": "webhook_events_hook_received_at_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_account_hook_fk": { + "name": "webhook_events_account_hook_fk", + "tableFrom": "webhook_events", + "tableTo": "hooks", + "columnsFrom": ["account_id", "hook_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_events_account_id_id_unique": { + "name": "webhook_events_account_id_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_member_role": { + "name": "account_member_role", + "schema": "public", + "values": ["owner", "member"] + }, + "public.delivery_attempt_outcome": { + "name": "delivery_attempt_outcome", + "schema": "public", + "values": ["delivered", "failed", "expired"] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": ["pending", "in_flight", "delivered", "dead"] + }, + "public.hook_state": { + "name": "hook_state", + "schema": "public", + "values": ["active", "disabled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 812de0f..8d96c79 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1786491433034, "tag": "0001_friendly_wendell_rand", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1786491892517, + "tag": "0002_steep_scalphunter", + "breakpoints": true } ] } diff --git a/packages/database/src/api-token-store.test.ts b/packages/database/src/api-token-store.test.ts new file mode 100644 index 0000000..da0a995 --- /dev/null +++ b/packages/database/src/api-token-store.test.ts @@ -0,0 +1,89 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { ApiTokenStore } from "./api-token-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let store: ApiTokenStore; + +beforeAll(async () => { + database = await createTestDatabase(); + store = new ApiTokenStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("API token store", () => { + test("creates a one-time token and authenticates it", async () => { + const { accountId } = await database.seedAccount(); + const created = await store.createToken({ + accountId, + name: "MacBook listener", + }); + + expect(created.token).toMatch(/^hky_[a-f0-9]{32}_[A-Za-z0-9_-]{43}$/); + expect(await store.authenticateToken(created.token)).toMatchObject({ + accountId, + tokenId: created.tokenId, + }); + const stored = await database.pool.query<{ + token_hash: string; + }>("select token_hash from api_tokens where id = $1", [created.tokenId]); + expect(stored.rows[0]?.token_hash).not.toContain(created.token); + }); + + test("revocation is immediate and tenant scoped", async () => { + const owner = await database.seedAccount(); + const other = await database.seedAccount(); + const created = await store.createToken({ + accountId: owner.accountId, + name: "Local listener", + }); + + expect( + await store.revokeToken({ + accountId: other.accountId, + tokenId: created.tokenId, + }), + ).toBe(false); + expect( + await store.revokeToken({ + accountId: owner.accountId, + tokenId: created.tokenId, + }), + ).toBe(true); + expect(await store.authenticateToken(created.token)).toBeNull(); + }); + + test("lists metadata without hashes or plaintext secrets", async () => { + const { accountId } = await database.seedAccount(); + const created = await store.createToken({ + accountId, + name: "CI listener", + }); + + const listed = await store.listTokens({ accountId }); + + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ + tokenId: created.tokenId, + name: "CI listener", + prefix: created.token.slice(0, 12), + revokedAt: null, + }); + expect(JSON.stringify(listed)).not.toContain(created.token); + }); +}); diff --git a/packages/database/src/api-token-store.ts b/packages/database/src/api-token-store.ts new file mode 100644 index 0000000..c9cd4c3 --- /dev/null +++ b/packages/database/src/api-token-store.ts @@ -0,0 +1,125 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import type { Pool } from "pg"; + +const apiTokenPattern = /^hky_([a-f0-9]{32})_([A-Za-z0-9_-]{43})$/; + +function hashSecret(secret: string) { + return createHash("sha256").update(secret).digest("hex"); +} + +function compactUuid(id: string) { + return id.replaceAll("-", ""); +} + +function expandUuid(id: string) { + return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`; +} + +export class ApiTokenStore { + constructor(private readonly pool: Pool) {} + + async createToken({ accountId, name }: { accountId: string; name: string }) { + const normalizedName = name.trim(); + if (normalizedName.length < 1 || normalizedName.length > 80) { + throw new Error("Token name must be between 1 and 80 characters"); + } + + const tokenId = randomUUID(); + const secret = randomBytes(32).toString("base64url"); + const token = `hky_${compactUuid(tokenId)}_${secret}`; + const prefix = token.slice(0, 12); + const result = await this.pool.query<{ created_at: Date }>( + ` + insert into api_tokens (id, account_id, name, prefix, token_hash) + values ($1, $2, $3, $4, $5) + returning created_at + `, + [tokenId, accountId, normalizedName, prefix, hashSecret(secret)], + ); + + return { + tokenId, + name: normalizedName, + prefix, + token, + createdAt: result.rows[0]!.created_at, + }; + } + + async authenticateToken(token: string) { + const match = apiTokenPattern.exec(token); + if (!match) { + return null; + } + + const [, compactId, secret] = match; + const result = await this.pool.query<{ + account_id: string; + id: string; + }>( + ` + update api_tokens + set last_used_at = now() + where id = $1 + and token_hash = $2 + and revoked_at is null + and (expires_at is null or expires_at > now()) + returning id, account_id + `, + [expandUuid(compactId!), hashSecret(secret!)], + ); + const authenticated = result.rows[0]; + return authenticated + ? { tokenId: authenticated.id, accountId: authenticated.account_id } + : null; + } + + async listTokens({ accountId }: { accountId: string }) { + const result = await this.pool.query<{ + id: string; + name: string; + prefix: string; + last_used_at: Date | null; + expires_at: Date | null; + revoked_at: Date | null; + created_at: Date; + }>( + ` + select id, name, prefix, last_used_at, expires_at, revoked_at, created_at + from api_tokens + where account_id = $1 + order by created_at desc, id + `, + [accountId], + ); + + return result.rows.map((row) => ({ + tokenId: row.id, + name: row.name, + prefix: row.prefix, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + createdAt: row.created_at, + })); + } + + async revokeToken({ + accountId, + tokenId, + }: { + accountId: string; + tokenId: string; + }) { + const result = await this.pool.query( + ` + update api_tokens + set revoked_at = coalesce(revoked_at, now()) + where account_id = $1 and id = $2 and revoked_at is null + `, + [accountId, tokenId], + ); + return result.rowCount === 1; + } +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index ce8a263..963a921 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,5 +1,6 @@ export { createDatabasePool, createDrizzleDatabase } from "./database"; export { AccountStore, type AccountMembership } from "./account-store"; +export { ApiTokenStore } from "./api-token-store"; export { DeliveryStore, HookUnavailableError, diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 15127a3..d9436fc 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -130,6 +130,26 @@ export const accountMembers = pgTable( ], ); +export const apiTokens = pgTable( + "api_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + name: text("name").notNull(), + prefix: text("prefix").notNull(), + tokenHash: text("token_hash").notNull().unique(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [index("api_tokens_account_id_index").on(table.accountId)], +); + export const hooks = pgTable( "hooks", {