diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..708ed58 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL= +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 +CRON_SECRET= +RETENTION_DAYS=30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f50b51b..feb0726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,22 @@ jobs: validate: runs-on: ubuntu-latest timeout-minutes: 20 + env: + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/hooky_test + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: hooky_test + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d hooky_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 1212376..a6dd0cc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .turbo/ dist/ out/ +.vercel/ # Test output playwright-report/ diff --git a/README.md b/README.md index 84c2918..a0c4aa9 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,120 @@ # Hooky -Hooky is a durable webhook inbox and local development relay. It stores incoming webhooks in the cloud and delivers them when a developer's local environment is ready—without exposing localhost to the public internet. +Hooky is a durable webhook inbox and local-development relay. A public Hooky URL accepts and stores a webhook before returning `202`, then the Hooky CLI leases that event and forwards its original method, path, query, headers, and bytes to localhost. Your development machine never exposes a public port. -The product plan and implementation decisions live in [PRD issue #1](https://github.com/dak-engineering/hooky/issues/1). +The hosted application is [hooky.vercel.app](https://hooky.vercel.app). The product plan and implementation decisions live in [PRD issue #1](https://github.com/dak-engineering/hooky/issues/1). + +## Use Hooky + +Hooky is not published to npm. Run it directly from this public GitHub repository: + +```bash +bunx github:dak-engineering/hooky --help +``` + +Or install the command globally from GitHub: + +```bash +bun install --global github:dak-engineering/hooky +hooky --help +``` + +Create an account in the web app, open **API keys**, and copy a newly created key. Authenticate the CLI: + +```bash +hooky login --token hky_your_token +``` + +Create a new webhook URL and relay its events to a local route: + +```bash +hooky listen --new stripe-dev --to http://localhost:3000/webhooks/stripe +``` + +The command prints the one-time public webhook URL. Give that URL to Stripe, GitHub, Clerk, or any service that sends HTTP webhooks. + +To listen on a hook that already exists in your account: + +```bash +hooky hooks +hooky listen --hook stripe-dev --to http://localhost:3000/webhooks/stripe +``` + +The CLI stores credentials in `~/.config/hooky/config.json` with user-only permissions. You can instead use `HOOKY_TOKEN`, `HOOKY_API_URL`, and `HOOKY_CONFIG_PATH`. + +## Delivery model + +```mermaid +flowchart LR + sender["Webhook sender"] -->|"public HTTPS request"| ingress["Hooky on Vercel"] + ingress -->|"atomic event + delivery commit"| neon["Neon Postgres"] + cli["Hooky CLI"] -->|"authenticated claim / ACK / NACK"| ingress + cli -->|"original HTTP request"| local["localhost application"] +``` + +- Hook ingress secrets and API keys are random, one-time credentials stored only as SHA-256 hashes. +- Each accepted request and pending delivery are committed together before Hooky responds. +- A listener claims work using a time-bounded database lease. Unacknowledged work becomes claimable again after the lease expires. +- Successful local responses ACK the delivery. Network errors and unsuccessful responses NACK it with bounded exponential retry. +- Every account boundary is enforced in the database queries used by management and listener APIs. +- Captured events are retained for 30 days by default. A daily authenticated Vercel Cron job performs bounded cleanup. ## Repository -This Bun workspace currently contains the Next.js web application in `apps/web`. Additional database, relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them. +This Bun/Turborepo workspace contains: + +- `apps/web`: Next.js application, authenticated dashboard, ingress, management API, listener API, health check, and retention cron. +- `packages/database`: Drizzle schema, migrations, tenant-scoped stores, and PostgreSQL delivery state machine. +- `packages/cli`: GitHub-installable Bun CLI and local forwarding loop. +- `e2e`: Playwright coverage for the landing page and the sign-up → hook → webhook → event → API-key flow. -## Development +## Local development -Install dependencies and start the web application: +Requirements are Bun 1.3+, PostgreSQL 16+, and Chromium for browser tests. ```bash bun install +cp .env.example apps/web/.env.local +``` + +Set these values: + +```dotenv +DATABASE_URL=postgresql://... +BETTER_AUTH_SECRET=a-random-secret-at-least-32-characters-long +BETTER_AUTH_URL=http://localhost:3000 +CRON_SECRET=another-random-secret +RETENTION_DAYS=30 +``` + +Then migrate and run the application: + +```bash +DATABASE_URL='postgresql://...' bun run db:migrate bun run dev ``` -The application runs at [http://localhost:3000](http://localhost:3000), with its health endpoint at [http://localhost:3000/api/health](http://localhost:3000/api/health). +The application runs at [http://localhost:3000](http://localhost:3000). Readiness is available at [http://localhost:3000/api/health](http://localhost:3000/api/health). + +Application traffic should use Neon's pooled `DATABASE_URL`. Run migrations with a direct connection string when available because migration tools hold longer sessions than request handlers. ## Validation +Database tests use `TEST_DATABASE_URL` when supplied. Otherwise they create and remove an ephemeral local PostgreSQL cluster. + ```bash +bun run prettier bun run lint bun run prettier:check bun run typecheck bun test bun run test:e2e bun run build +bun run db:generate ``` + +## Deployment + +The production project is `dak/hooky` on Vercel, connected to this repository with `apps/web` as its Root Directory. Neon supplies pooled `DATABASE_URL` credentials to the Vercel project. Production also requires `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and `CRON_SECRET`; `RETENTION_DAYS` defaults to 30. + +The service emits metadata-only structured ingress logs with Vercel request correlation. It never logs webhook bodies, captured headers, ingress secrets, API keys, or database credentials. diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..44b8c9d --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,6 @@ +# Use the pooled Neon connection string for application traffic. +DATABASE_URL=postgresql://user:password@endpoint-pooler.region.aws.neon.tech/database?sslmode=require + +# Generate with: openssl rand -base64 32 +BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters +BETTER_AUTH_URL=http://localhost:3000 diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 2f6491c..c9e06e6 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,24 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, + transpilePackages: ["@hooky/database"], + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 83480e2..0041017 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,13 +10,20 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@hooky/database": "workspace:*", + "@vercel/functions": "^3.9.3", + "better-auth": "1.6.27", + "drizzle-orm": "0.45.2", "next": "16.3.0", + "pg": "8.23.0", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "zod": "4.4.3" }, "devDependencies": { "@types/bun": "1.3.14", "@types/node": "^20.19.32", + "@types/pg": "8.21.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..076e578 --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,5 @@ +import { toNextJsHandler } from "better-auth/next-js"; + +import { auth } from "@/lib/auth"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/web/src/app/api/cron/retention/route.ts b/apps/web/src/app/api/cron/retention/route.ts new file mode 100644 index 0000000..b9dec63 --- /dev/null +++ b/apps/web/src/app/api/cron/retention/route.ts @@ -0,0 +1,16 @@ +import { createRetentionHandler } from "@/lib/retention-handler"; +import { retentionStore } from "@/lib/server-database"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const configuredRetentionDays = Number(process.env.RETENTION_DAYS ?? "30"); + +export const GET = createRetentionHandler({ + cronSecret: process.env.CRON_SECRET, + retentionDays: Number.isFinite(configuredRetentionDays) + ? configuredRetentionDays + : 30, + deleteEventsReceivedBefore: (input) => + retentionStore.deleteEventsReceivedBefore(input), +}); diff --git a/apps/web/src/app/api/health/route.test.ts b/apps/web/src/app/api/health/route.test.ts index 0ad9e64..9edde97 100644 --- a/apps/web/src/app/api/health/route.test.ts +++ b/apps/web/src/app/api/health/route.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { GET } from "./route"; +import { createHealthHandler } from "./route"; describe("health endpoint", () => { test("reports that the web service is ready", async () => { - const response = GET(); + const response = await createHealthHandler({ + checkDatabase: async () => undefined, + })(); expect(response.status).toBe(200); expect(await response.json()).toEqual({ @@ -12,4 +14,18 @@ describe("health endpoint", () => { status: "ok", }); }); + + test("reports an unavailable dependency without leaking its error", async () => { + const response = await createHealthHandler({ + checkDatabase: async () => { + throw new Error("postgresql://secret@database.internal/hooky"); + }, + })(); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + service: "hooky-web", + status: "unavailable", + }); + }); }); diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index 5a56843..4ad7ce0 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,6 +1,31 @@ -export function GET() { - return Response.json({ - service: "hooky-web", - status: "ok", - }); +import { databasePool } from "@/lib/server-database"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function createHealthHandler({ + checkDatabase, +}: { + checkDatabase: () => Promise; +}) { + return async function healthHandler() { + try { + await checkDatabase(); + return Response.json( + { service: "hooky-web", status: "ok" }, + { headers: { "cache-control": "no-store" } }, + ); + } catch { + return Response.json( + { service: "hooky-web", status: "unavailable" }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + }; } + +export const GET = createHealthHandler({ + checkDatabase: async () => { + await databasePool.query("select 1"); + }, +}); 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 new file mode 100644 index 0000000..1a3ff3f --- /dev/null +++ b/apps/web/src/app/api/v1/hooks/[hook-id]/rotate-ingress-secret/route.ts @@ -0,0 +1,18 @@ +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: authenticateApiAccount, + rotateIngressSecret: (input) => hookStore.rotateIngressSecret(input), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ "hook-id": string }> }, +) { + return rotate(request, (await params)["hook-id"]); +} diff --git a/apps/web/src/app/api/v1/hooks/route.ts b/apps/web/src/app/api/v1/hooks/route.ts new file mode 100644 index 0000000..e627b46 --- /dev/null +++ b/apps/web/src/app/api/v1/hooks/route.ts @@ -0,0 +1,14 @@ +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: authenticateApiAccount, + createHook: (input) => hookStore.createHook(input), + listHooks: (input) => hookStore.listHooks(input), +}); + +export const { GET, POST } = handlers; 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/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx new file mode 100644 index 0000000..6117691 --- /dev/null +++ b/apps/web/src/app/dashboard/page.tsx @@ -0,0 +1,330 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { + AccountControl, + ApiKeysControl, + CommandCopy, + EventInspector, + HookActions, +} from "@/components/dashboard-controls"; +import { ArrowIcon, CheckIcon, GridIcon, HookIcon } from "@/components/icons"; +import { auth } from "@/lib/auth"; +import { + accountStore, + apiTokenStore, + eventStore, + hookStore, +} from "@/lib/server-database"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function formatTime(date: Date) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +function relativeTime(date: Date) { + const seconds = Math.round((date.getTime() - Date.now()) / 1_000); + const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + if (Math.abs(seconds) < 60) return formatter.format(seconds, "second"); + const minutes = Math.round(seconds / 60); + if (Math.abs(minutes) < 60) return formatter.format(minutes, "minute"); + const hours = Math.round(minutes / 60); + if (Math.abs(hours) < 24) return formatter.format(hours, "hour"); + return formatter.format(Math.round(hours / 24), "day"); +} + +function readableBody( + body: Buffer, + headersValue: Record, +) { + const contentType = String(headersValue["content-type"] ?? ""); + const text = body.toString("utf8"); + if (contentType.includes("json")) { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + return /^[\s\x20-\x7e]*$/.test(text) ? text : body.toString("base64"); +} + +function statusLabel(status: string) { + if (status === "in_flight") return "In flight"; + return status[0]!.toUpperCase() + status.slice(1); +} + +export default async function DashboardPage({ + searchParams, +}: { + searchParams: Promise<{ + hook?: string; + event?: string; + status?: string; + }>; +}) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/sign-in"); + const account = await accountStore.ensurePersonalAccount({ + userId: session.user.id, + name: session.user.name, + }); + const params = await searchParams; + const [hooks, tokens] = await Promise.all([ + hookStore.listHooks({ accountId: account.accountId }), + apiTokenStore.listTokens({ accountId: account.accountId }), + ]); + const selectedHook = + hooks.find((hook) => hook.hookId === params.hook) ?? hooks[0]; + const allEvents = selectedHook + ? await eventStore.listRecentEvents({ + accountId: account.accountId, + hookId: selectedHook.hookId, + limit: 50, + }) + : []; + const statusFilter = ["pending", "delivered"].includes(params.status ?? "") + ? params.status + : undefined; + const events = statusFilter + ? allEvents.filter((event) => event.status === statusFilter) + : allEvents; + const selectedEventId = + events.find((event) => event.eventId === params.event)?.eventId ?? + events[0]?.eventId; + const selectedEvent = selectedEventId + ? await eventStore.getEvent({ + accountId: account.accountId, + eventId: selectedEventId, + }) + : null; + const command = selectedHook + ? `hooky listen --to http://localhost:3000/webhooks --hook ${selectedHook.name}` + : "hooky listen --to http://localhost:3000/webhooks --new local"; + + return ( +
+ + +
+
+
+

{selectedHook?.name ?? "Your hooks"}

+

+ {selectedHook ? ( + <> + Durable ingress {selectedHook.state} + + ) : ( + "Create an endpoint to begin." + )} +

+
+ +
+ +
+ + $ {command} + + +
+ +
+
+
+

Recent events

+ {selectedHook ? ( +
+ {[ + ["", "All"], + ["pending", "Pending"], + ["delivered", "Delivered"], + ].map(([value, label]) => ( + + {label} + + ))} +
+ ) : null} +
+ + {selectedHook && events.length ? ( +
+
+ Method / path + Received + Status + Attempts +
+ {events.map((event) => ( + + + + + {event.requestMethod} + + {event.requestPath} + + {relativeTime(event.receivedAt)} + + + {statusLabel(event.status)} + + {event.attemptCount} + + ))} +
+ ) : ( +
+ +

+ {selectedHook + ? "Waiting for the first event." + : "Create your first hook."} +

+

+ {selectedHook + ? "Send a webhook to this endpoint and it will appear here durably." + : "Hooky will create a public URL and keep every request until your CLI is ready."} +

+
+ )} + +
+ +

+ Install Hooky CLIbunx github:dak-engineering/hooky +

+ +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/e/[token]/[[...path]]/route.ts b/apps/web/src/app/e/[token]/[[...path]]/route.ts new file mode 100644 index 0000000..08ced6e --- /dev/null +++ b/apps/web/src/app/e/[token]/[[...path]]/route.ts @@ -0,0 +1,29 @@ +import { createIngressHandler } from "@/lib/ingress-handler"; +import { deliveryStore, hookStore } from "@/lib/server-database"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const ingress = createIngressHandler({ + maxBodyBytes: 4_000_000, + resolveIngressToken: (token) => hookStore.resolveIngressToken(token), + recordWebhookEvent: (input) => deliveryStore.recordWebhookEvent(input), +}); + +async function handle( + request: Request, + { + params, + }: { params: Promise<{ token: string; path?: string[] | undefined }> }, +) { + const { token, path = [] } = await params; + return ingress(request, { token, path }); +} + +export const GET = handle; +export const HEAD = handle; +export const POST = handle; +export const PUT = handle; +export const PATCH = handle; +export const DELETE = handle; +export const OPTIONS = handle; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f25234e..a7ac9ab 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -43,7 +43,7 @@ a { text-decoration: none; } -main { +.landing-shell { display: flex; width: min(1180px, calc(100% - 48px)); min-height: 100vh; @@ -97,6 +97,19 @@ main { background: var(--surface); } +.landing-nav-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.sign-in-link { + padding: 10px 12px; + color: var(--muted); + font-size: 14px; + font-weight: 650; +} + .hero { display: grid; flex: 1; @@ -110,7 +123,7 @@ main { max-width: 580px; } -h1 { +.hero h1 { max-width: 700px; margin: 0; font-size: clamp(56px, 6.3vw, 92px); @@ -235,7 +248,7 @@ h1 { color: #f3bd61; } -footer { +.landing-footer { display: flex; min-height: 80px; align-items: center; @@ -245,11 +258,11 @@ footer { font-size: 13px; } -footer p { +.landing-footer p { margin: 0; } -footer span { +.landing-footer a { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } @@ -270,7 +283,7 @@ footer span { } @media (max-width: 560px) { - main { + .landing-shell { width: min(100% - 32px, 1180px); } @@ -283,7 +296,7 @@ footer span { padding: 52px 0 70px; } - h1 { + .hero h1 { font-size: clamp(48px, 15vw, 66px); } @@ -303,7 +316,7 @@ footer span { display: none; } - footer { + .landing-footer { align-items: flex-start; flex-direction: column; gap: 8px; @@ -319,3 +332,1003 @@ footer span { transition-duration: 0.01ms !important; } } + +/* Auth */ +.auth-shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 110px 24px 48px; + color: #eef2ef; + background: #080d0f; +} + +.auth-brand { + position: absolute; + top: 34px; + left: 38px; + display: inline-flex; + align-items: center; + gap: 11px; + font-size: 18px; + font-weight: 720; +} + +.auth-panel { + width: min(440px, 100%); +} + +.auth-panel h1 { + margin: 0; + font-size: clamp(38px, 5vw, 52px); + letter-spacing: -0.055em; + line-height: 1; +} + +.auth-panel > p { + margin: 18px 0 36px; + color: #8a9692; + font-size: 16px; + line-height: 1.6; +} + +.auth-form, +.modal-form { + display: grid; + gap: 18px; +} + +.auth-form label, +.modal-form label { + display: grid; + color: #b9c2bf; + font-size: 13px; + font-weight: 650; + gap: 8px; +} + +.auth-form input, +.modal-form input { + width: 100%; + min-height: 48px; + padding: 0 14px; + border: 1px solid #344046; + border-radius: 8px; + outline: none; + color: #eef2ef; + background: #0c1317; + font: inherit; + font-size: 15px; + transition: + border-color 140ms ease, + box-shadow 140ms ease; +} + +.auth-form input:focus, +.modal-form input:focus { + border-color: #3cdd78; + box-shadow: 0 0 0 3px rgb(60 221 120 / 14%); +} + +.button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 15px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 680; + transition: + background 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} + +.button:focus-visible, +.copy-button:focus-visible, +.sidebar-link:focus-visible, +.icon-button:focus-visible, +.account-control:focus-visible { + outline: 2px solid #3cdd78; + outline-offset: 2px; +} + +.button:disabled { + cursor: wait; + opacity: 0.55; +} + +.button-primary { + color: #06110a; + background: #3cdd78; +} + +.button-primary:hover:not(:disabled) { + background: #67e692; + transform: translateY(-1px); +} + +.button-primary-outline { + border-color: #3cdd78; + color: #62e58f; + background: transparent; +} + +.button-primary-outline:hover { + background: rgb(60 221 120 / 8%); +} + +.button-secondary { + border-color: #3a464c; + color: #eef2ef; + background: #0d1418; +} + +.button-secondary:hover:not(:disabled) { + border-color: #65716f; + background: #121b20; +} + +.auth-submit { + width: 100%; + min-height: 48px; + margin-top: 4px; +} + +.form-error { + margin: -4px 0 0; + color: #ff8d85; + font-size: 13px; +} + +.auth-switch { + margin: 4px 0 0; + color: #8a9692; + font-size: 13px; + text-align: center; +} + +.auth-switch a { + color: #62e58f; +} + +/* Dashboard */ +.dashboard-shell { + --dash-bg: #080d0f; + --dash-surface: #0c1317; + --dash-surface-strong: #11191d; + --dash-border: #263238; + --dash-text: #eef2ef; + --dash-muted: #8a9692; + --dash-accent: #3cdd78; + --dash-amber: #f4b91d; + display: grid; + min-height: 100vh; + color: var(--dash-text); + background: var(--dash-bg); + font-size: 14px; + grid-template-columns: 236px minmax(0, 1fr); +} + +.dashboard-sidebar { + position: sticky; + top: 0; + display: flex; + height: 100vh; + min-width: 0; + flex-direction: column; + padding: 27px 12px 14px; + border-right: 1px solid var(--dash-border); + background: #091014; +} + +.dashboard-brand { + display: inline-flex; + align-items: center; + gap: 10px; + margin: 0 12px 31px; + font-size: 18px; + font-weight: 730; + letter-spacing: -0.03em; +} + +.sidebar-navigation { + display: grid; + gap: 5px; +} + +.sidebar-link { + display: flex; + width: 100%; + min-height: 44px; + align-items: center; + gap: 12px; + padding: 0 14px; + border: 0; + border-radius: 7px; + color: #bdc6c3; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 14px; + text-align: left; +} + +.sidebar-link:hover, +.sidebar-link.active { + color: var(--dash-text); + background: #11191e; +} + +.sidebar-link.active { + box-shadow: inset 2px 0 var(--dash-accent); +} + +.sidebar-hooks { + margin-top: 28px; +} + +.sidebar-hooks > p { + margin: 0 14px 10px; + color: #5e6968; + font-size: 10px; + font-weight: 750; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.hook-link { + display: flex; + min-height: 54px; + align-items: center; + gap: 11px; + padding: 0 14px; + border-radius: 7px; + color: #aeb8b5; +} + +.hook-link:hover, +.hook-link.selected { + color: var(--dash-text); + background: #11191e; +} + +.hook-link.selected { + box-shadow: inset 2px 0 var(--dash-accent); +} + +.hook-link span { + overflow: hidden; + min-width: 0; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hook-link small { + display: block; + margin-top: 3px; + color: #687371; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.account-control { + display: grid; + width: 100%; + min-height: 58px; + margin-top: auto; + padding: 8px 10px; + border: 1px solid var(--dash-border); + border-radius: 8px; + color: var(--dash-text); + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; + text-align: left; + column-gap: 9px; + grid-template-columns: 34px 1fr; +} + +.account-control > span { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 7px; + color: #baf4cd; + background: #174e2d; + grid-row: span 2; +} + +.account-control small { + color: #6f7b78; + font-size: 10px; +} + +.dashboard-workspace { + min-width: 0; +} + +.dashboard-header { + display: flex; + min-height: 126px; + align-items: center; + justify-content: space-between; + padding: 26px 32px; +} + +.dashboard-header h1 { + margin: 0; + font-size: 30px; + letter-spacing: -0.04em; +} + +.dashboard-header p { + display: flex; + align-items: center; + gap: 9px; + margin: 9px 0 0; + color: #a5afac; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.dashboard-header p i { + width: 3px; + height: 3px; + border-radius: 50%; + background: #42614d; +} + +.dashboard-header p span { + color: var(--dash-accent); +} + +.header-actions { + display: flex; + gap: 10px; +} + +.command-strip { + display: flex; + min-height: 62px; + align-items: stretch; + margin: 0 32px 22px; + border: 1px solid var(--dash-border); + border-radius: 7px; +} + +.command-strip code { + overflow: hidden; + flex: 1; + padding: 21px 20px; + color: #d7ddda; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-strip code span { + color: var(--dash-accent); +} + +.copy-button { + display: inline-flex; + min-width: 94px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 14px; + border: 0; + border-left: 1px solid var(--dash-border); + color: #aab4b1; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.copy-button:hover { + color: var(--dash-text); + background: #11191e; +} + +.dashboard-content { + display: grid; + min-height: calc(100vh - 210px); + border-top: 1px solid var(--dash-border); + grid-template-columns: minmax(550px, 1.3fr) minmax(390px, 0.75fr); +} + +.events-region { + display: flex; + min-width: 0; + flex-direction: column; + padding: 24px 20px 0 32px; +} + +.events-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.events-heading h2, +.inspector-heading h2 { + margin: 0; + font-size: 19px; + letter-spacing: -0.025em; +} + +.status-filters { + display: flex; + border: 1px solid var(--dash-border); + border-radius: 7px; +} + +.status-filters a { + min-width: 70px; + padding: 9px 12px; + border-right: 1px solid var(--dash-border); + color: #8f9a97; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + text-align: center; +} + +.status-filters a:last-child { + border-right: 0; +} + +.status-filters a[aria-current="page"] { + color: var(--dash-text); + box-shadow: inset 0 0 0 1px var(--dash-accent); +} + +.events-table { + min-width: 0; +} + +.events-table-head, +.event-row-link { + display: grid; + align-items: center; + grid-template-columns: + minmax(245px, 1.5fr) minmax(118px, 0.8fr) minmax(118px, 0.8fr) + 72px; +} + +.events-table-head { + padding: 0 14px 11px; + color: #66726f; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.event-row-link { + min-height: 72px; + padding: 0 14px; + border-top: 1px solid var(--dash-border); + color: #d5dcda; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} + +.event-row-link:last-child { + border-bottom: 1px solid var(--dash-border); +} + +.event-row-link:hover { + background: #0d1519; +} + +.event-row-link.selected { + border: 1px solid rgb(60 221 120 / 72%); + border-radius: 5px; + background: #0d1619; +} + +.event-path { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-path svg { + width: 14px; + flex: 0 0 auto; +} + +.method-tag { + padding: 4px 7px; + border-radius: 4px; + color: var(--dash-accent); + background: rgb(60 221 120 / 10%); + font-size: 10px; +} + +.method-get { + color: #7ac9ff; + background: rgb(80 169 226 / 12%); +} + +.method-delete { + color: #ff8d85; + background: rgb(255 90 82 / 10%); +} + +.delivery-state { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--dash-accent); + font-size: 11px; +} + +.delivery-state svg { + width: 16px; +} + +.state-pending, +.state-in_flight { + color: var(--dash-amber); +} + +.state-dead { + color: #ff8d85; +} + +.events-empty, +.inspector-empty { + display: grid; + min-height: 300px; + place-items: center; + align-content: center; + color: #788481; + text-align: center; +} + +.events-empty svg { + width: 30px; + height: 30px; + color: var(--dash-accent); +} + +.events-empty h3 { + margin: 16px 0 7px; + color: var(--dash-text); + font-size: 16px; +} + +.events-empty p, +.inspector-empty p { + max-width: 390px; + margin: 0; + font-size: 12px; + line-height: 1.6; +} + +.cli-install { + display: flex; + min-height: 82px; + align-items: center; + gap: 13px; + margin: auto -20px 0 -32px; + padding: 0 18px 0 32px; + border-top: 1px solid var(--dash-border); +} + +.cli-install > span { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border: 1px solid var(--dash-border); + border-radius: 7px; + color: #c1cbc8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.cli-install p { + display: grid; + margin: 0; + color: #788481; + font-size: 11px; + gap: 5px; +} + +.cli-install code { + color: #d4dcda; +} + +.cli-install code::first-letter { + color: var(--dash-accent); +} + +.cli-install .copy-button { + margin-left: auto; + border-left: 0; +} + +.event-inspector { + min-width: 0; + padding: 26px 20px 32px; + border-left: 1px solid var(--dash-border); +} + +.inspector-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.inspector-heading p { + margin: 8px 0 0; + color: #8d9895; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.inspector-tabs { + display: flex; + gap: 26px; + margin-top: 25px; + border-bottom: 1px solid var(--dash-border); +} + +.inspector-tabs button { + padding: 0 2px 12px; + border: 0; + border-bottom: 2px solid transparent; + color: #899592; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.inspector-tabs button[aria-selected="true"] { + border-color: var(--dash-accent); + color: var(--dash-text); +} + +.payload-view { + overflow: auto; + min-height: 280px; + max-height: 420px; + margin: 0; + padding: 18px; + border: 1px solid var(--dash-border); + border-top: 0; + border-radius: 0 0 6px 6px; + color: #bfd0ca; + background: #080d10; + font-size: 11px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} + +.delivery-history { + margin-top: 27px; +} + +.delivery-history h3 { + margin: 0 0 18px; + font-size: 13px; +} + +.history-item { + position: relative; + display: flex; + gap: 11px; + min-height: 62px; +} + +.history-item::after { + position: absolute; + top: 17px; + bottom: -2px; + left: 8px; + width: 1px; + background: #245b36; + content: ""; +} + +.history-item:last-child::after { + display: none; +} + +.history-item > svg { + position: relative; + z-index: 1; + width: 17px; + flex: 0 0 auto; + color: var(--dash-accent); + background: var(--dash-bg); +} + +.history-item span { + display: flex; + flex-direction: column; + gap: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; +} + +.history-item strong { + color: #dce3e1; + font-size: 11px; +} + +.history-item small { + color: #74807d; +} + +.history-item em { + color: var(--dash-accent); + font-size: 9px; + font-style: normal; +} + +/* Dashboard modal states */ +.modal-backdrop { + position: fixed; + z-index: 50; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgb(2 6 8 / 76%); + backdrop-filter: blur(5px); +} + +.modal { + width: min(540px, 100%); + padding: 25px; + border: 1px solid #3b484e; + border-radius: 10px; + color: #eef2ef; + background: #10181c; + box-shadow: 0 30px 90px rgb(0 0 0 / 45%); +} + +.modal-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.modal-heading h2 { + margin: 0; + font-size: 22px; + letter-spacing: -0.035em; +} + +.icon-button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 0; + color: #9ca7a4; + background: transparent; + cursor: pointer; +} + +.modal-form, +.secret-result { + margin-top: 18px; +} + +.modal-form > p, +.secret-result > p { + margin: 0 0 21px; + color: #9ca7a4; + font-size: 13px; + line-height: 1.55; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 23px; +} + +.secret-field { + display: flex; + min-height: 54px; + align-items: stretch; + border: 1px solid #445158; + border-radius: 7px; + background: #0a1115; +} + +.secret-field code { + overflow: hidden; + flex: 1; + padding: 18px 14px; + color: #e5ebe8; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-form { + align-items: end; + grid-template-columns: 1fr auto; +} + +.compact-form .form-error { + grid-column: 1 / -1; +} + +.token-list { + display: grid; + max-height: 250px; + margin-top: 22px; + border-top: 1px solid var(--dash-border); + overflow-y: auto; +} + +.token-row { + display: flex; + min-height: 65px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--dash-border); +} + +.token-row span { + display: grid; + gap: 5px; +} + +.token-row strong { + font-size: 12px; +} + +.token-row code, +.token-row em { + color: #76827f; + font-size: 10px; + font-style: normal; +} + +.token-row button { + border: 0; + color: #ff8d85; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 11px; +} + +.empty-note { + color: #788481; + font-size: 12px; +} + +@media (max-width: 1120px) { + .dashboard-shell { + grid-template-columns: 200px minmax(0, 1fr); + } + + .dashboard-content { + grid-template-columns: 1fr; + } + + .event-inspector { + border-top: 1px solid var(--dash-border); + border-left: 0; + } +} + +@media (max-width: 760px) { + .dashboard-shell { + display: block; + } + + .dashboard-sidebar { + position: static; + display: grid; + height: auto; + padding: 14px; + border-right: 0; + border-bottom: 1px solid var(--dash-border); + grid-template-columns: 1fr auto; + } + + .dashboard-brand { + margin: 0; + } + + .sidebar-navigation { + display: flex; + } + + .sidebar-navigation .sidebar-link:first-child, + .sidebar-hooks, + .account-control { + display: none; + } + + .sidebar-link { + width: auto; + min-height: 38px; + } + + .dashboard-header { + align-items: flex-start; + flex-direction: column; + gap: 20px; + padding: 25px 18px; + } + + .header-actions { + width: 100%; + } + + .header-actions .button { + flex: 1; + } + + .command-strip { + margin: 0 18px 18px; + } + + .dashboard-content { + min-height: auto; + } + + .events-region { + padding: 22px 14px 0; + } + + .events-heading { + align-items: flex-start; + flex-direction: column; + gap: 15px; + } + + .events-table { + overflow-x: auto; + } + + .events-table-head, + .event-row-link { + min-width: 650px; + } + + .cli-install { + margin: 45px -14px 0; + padding-left: 14px; + } + + .event-inspector { + padding: 24px 14px; + } + + .compact-form { + align-items: stretch; + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b9a66a7..970d9a2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -4,7 +4,7 @@ const repositoryUrl = "https://github.com/dak-engineering/hooky"; export default function HomePage() { return ( -
+
@@ -41,7 +46,8 @@ export default function HomePage() {

- $ hooky listen stripe-dev + $ hooky listen --to + localhost:3000/webhooks --hook stripe-dev

Public URL ready

https://hooks.example/e/wh_7vK9...

@@ -59,9 +65,9 @@ export default function HomePage() {
-
+
); diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx new file mode 100644 index 0000000..1313c0c --- /dev/null +++ b/apps/web/src/app/sign-in/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +import { AuthForm } from "@/components/auth-form"; + +export default function SignInPage() { + return ( +
+ + H + Hooky + +
+

Welcome back.

+

Pick up every webhook your local environment missed.

+ +
+
+ ); +} diff --git a/apps/web/src/app/sign-up/page.tsx b/apps/web/src/app/sign-up/page.tsx new file mode 100644 index 0000000..82036ea --- /dev/null +++ b/apps/web/src/app/sign-up/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +import { AuthForm } from "@/components/auth-form"; + +export default function SignUpPage() { + return ( +
+ + H + Hooky + +
+

Create your workspace.

+

Start with one durable endpoint. Add the CLI when you are ready.

+ +
+
+ ); +} diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx new file mode 100644 index 0000000..b94bd15 --- /dev/null +++ b/apps/web/src/components/auth-form.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { authClient } from "@/lib/auth-client"; + +export function AuthForm({ mode }: { mode: "sign-in" | "sign-up" }) { + const router = useRouter(); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const isSignUp = mode === "sign-up"; + + async function submit(formData: FormData) { + setPending(true); + setError(""); + const email = String(formData.get("email") ?? ""); + const password = String(formData.get("password") ?? ""); + const result = isSignUp + ? await authClient.signUp.email({ + email, + password, + name: String(formData.get("name") ?? ""), + callbackURL: "/dashboard", + }) + : await authClient.signIn.email({ + email, + password, + callbackURL: "/dashboard", + }); + setPending(false); + + if (result.error) { + setError(result.error.message ?? "Authentication failed"); + return; + } + router.push("/dashboard"); + router.refresh(); + } + + return ( +
+ {isSignUp ? ( + + ) : null} + + + {error ?

{error}

: null} + +

+ {isSignUp ? "Already have an account?" : "New to Hooky?"}{" "} + + {isSignUp ? "Sign in" : "Create an account"} + +

+
+ ); +} diff --git a/apps/web/src/components/dashboard-controls.tsx b/apps/web/src/components/dashboard-controls.tsx new file mode 100644 index 0000000..3e8523d --- /dev/null +++ b/apps/web/src/components/dashboard-controls.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { authClient } from "@/lib/auth-client"; + +import { CloseIcon, CopyIcon, KeyIcon, PlusIcon, RotateIcon } from "./icons"; + +function CopyButton({ + value, + label = "Copy", +}: { + value: string; + label?: string; +}) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +function Modal({ + children, + close, + title, +}: { + children: React.ReactNode; + close: () => void; + title: string; +}) { + useEffect(() => { + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") close(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [close]); + + return ( +
event.target === event.currentTarget && close()} + > +
+
+ + +
+ {children} +
+
+ ); +} + +export function HookActions({ hookId }: { hookId: string | undefined }) { + const router = useRouter(); + const [mode, setMode] = useState<"create" | "rotate" | null>(null); + const [secretUrl, setSecretUrl] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + function close() { + setMode(null); + setSecretUrl(""); + setError(""); + } + + async function create(formData: FormData) { + setPending(true); + setError(""); + const response = await fetch("/api/v1/hooks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: String(formData.get("name") ?? "") }), + }); + const payload = (await response.json()) as { + ingressUrl?: string; + error?: string; + }; + setPending(false); + if (!response.ok || !payload.ingressUrl) { + setError(payload.error ?? "Hook could not be created"); + return; + } + setSecretUrl(payload.ingressUrl); + router.refresh(); + } + + async function rotate() { + if (!hookId) return; + setMode("rotate"); + setPending(true); + setError(""); + const response = await fetch( + `/api/v1/hooks/${encodeURIComponent(hookId)}/rotate-ingress-secret`, + { method: "POST" }, + ); + const payload = (await response.json()) as { + ingressUrl?: string; + error?: string; + }; + setPending(false); + if (!response.ok || !payload.ingressUrl) { + setError(payload.error ?? "URL could not be rotated"); + return; + } + setSecretUrl(payload.ingressUrl); + } + + return ( + <> +
+ + +
+ {mode ? ( + + {secretUrl ? ( +
+

Copy it now. For security, Hooky only shows this URL once.

+
+ {secretUrl} + +
+
+ +
+
+ ) : mode === "create" ? ( +
+

Give this endpoint a name you’ll recognize locally.

+ + {error ?

{error}

: null} +
+ + +
+
+ ) : ( +
+

{error || "Creating a new URL and revoking the old one…"}

+
+ )} +
+ ) : null} + + ); +} + +export function ApiKeysControl({ + tokens, +}: { + tokens: Array<{ + tokenId: string; + name: string; + prefix: string; + lastUsedAt: string | null; + revokedAt: string | null; + }>; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [secret, setSecret] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function create(formData: FormData) { + setPending(true); + setError(""); + const response = await fetch("/api/v1/tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: String(formData.get("name") ?? "") }), + }); + const payload = (await response.json()) as { + token?: string; + error?: string; + }; + setPending(false); + if (!response.ok || !payload.token) { + setError(payload.error ?? "API key could not be created"); + return; + } + setSecret(payload.token); + router.refresh(); + } + + async function revoke(tokenId: string) { + await fetch(`/api/v1/tokens/${tokenId}`, { method: "DELETE" }); + router.refresh(); + } + + return ( + <> + + {open ? ( + { + setOpen(false); + setSecret(""); + setError(""); + }} + title={secret ? "API key created" : "API keys"} + > + {secret ? ( +
+

+ Copy it now. For security, Hooky only shows this token once. +

+
+ {secret} + +
+
+ +
+
+ ) : ( + <> +
+ + {error ?

{error}

: null} + +
+
+ {tokens.length ? ( + tokens.map((token) => ( +
+ + {token.name} + {token.prefix}… + + {token.revokedAt ? ( + Revoked + ) : ( + + )} +
+ )) + ) : ( +

No API keys yet.

+ )} +
+ + )} +
+ ) : null} + + ); +} + +export function CommandCopy({ command }: { command: string }) { + return ; +} + +export function AccountControl({ name }: { name: string }) { + const router = useRouter(); + return ( + + ); +} + +export function EventInspector({ + body, + headers, + query, +}: { + body: string; + headers: Record; + query: Record; +}) { + const [tab, setTab] = useState<"body" | "headers" | "query">("body"); + const value = + tab === "body" + ? body + : JSON.stringify(tab === "headers" ? headers : query, null, 2); + return ( + <> +
+ {(["body", "headers", "query"] as const).map((item) => ( + + ))} +
+
+        {value}
+      
+ + ); +} diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx new file mode 100644 index 0000000..e93fd53 --- /dev/null +++ b/apps/web/src/components/icons.tsx @@ -0,0 +1,146 @@ +import type { SVGProps } from "react"; + +function Icon({ children, ...props }: SVGProps) { + return ( + + ); +} + +export function GridIcon(props: SVGProps) { + return ( + + + + ); +} + +export function HookIcon(props: SVGProps) { + return ( + + + + ); +} + +export function KeyIcon(props: SVGProps) { + return ( + + + + + ); +} + +export function PlusIcon(props: SVGProps) { + return ( + + + + ); +} + +export function RotateIcon(props: SVGProps) { + return ( + + + + ); +} + +export function CopyIcon(props: SVGProps) { + return ( + + + + + ); +} + +export function CheckIcon(props: SVGProps) { + return ( + + + + + ); +} + +export function CloseIcon(props: SVGProps) { + return ( + + + + ); +} + +export function ArrowIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts new file mode 100644 index 0000000..2f75fd4 --- /dev/null +++ b/apps/web/src/lib/auth-client.ts @@ -0,0 +1,5 @@ +"use client"; + +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); diff --git a/apps/web/src/lib/auth.test.ts b/apps/web/src/lib/auth.test.ts new file mode 100644 index 0000000..4b376c8 --- /dev/null +++ b/apps/web/src/lib/auth.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createDrizzleDatabase } from "@hooky/database"; +import { createTestDatabase } from "@hooky/database/testing"; + +import { createHookyAuth } from "./auth"; + +let testDatabase: Awaited>; + +beforeAll(async () => { + testDatabase = await createTestDatabase(); +}); + +afterAll(async () => { + await testDatabase.close(); +}); + +describe("authentication", () => { + test("signs up with email/password and persists a session", async () => { + const testAuth = createHookyAuth({ + database: createDrizzleDatabase(testDatabase.pool), + baseURL: "http://localhost:3000", + secret: "test-secret-that-is-more-than-thirty-two-characters", + secureCookies: false, + }); + const response = await testAuth.handler( + new Request("http://localhost:3000/api/auth/sign-up/email", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + name: "Katherine Johnson", + email: "katherine@example.test", + password: "correct-horse-battery-staple", + }), + }), + ); + const cookie = response.headers.get("set-cookie")?.split(";", 1)[0]; + const session = await testAuth.api.getSession({ + headers: new Headers({ cookie: cookie ?? "" }), + }); + + expect(response.status).toBe(200); + expect(cookie).toContain("hooky.session_token="); + expect(session?.user).toMatchObject({ + name: "Katherine Johnson", + email: "katherine@example.test", + }); + }); +}); diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..72e9796 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,68 @@ +import { createDrizzleDatabase, schema } from "@hooky/database"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { nextCookies } from "better-auth/next-js"; + +import { database } from "./server-database"; +import { resolveDeploymentOrigin } from "./deployment-origin"; + +const fallbackDevelopmentSecret = + "hooky-development-only-secret-change-before-deploying"; + +if (process.env.VERCEL && !process.env.BETTER_AUTH_SECRET) { + throw new Error("BETTER_AUTH_SECRET is required on Vercel"); +} + +const configuredOrigin = resolveDeploymentOrigin(process.env); + +export function createHookyAuth({ + database, + baseURL, + secret, + secureCookies, +}: { + database: ReturnType; + baseURL: string | undefined; + secret: string; + secureCookies: boolean; +}) { + return betterAuth({ + appName: "Hooky", + baseURL, + secret, + database: drizzleAdapter(database, { + provider: "pg", + schema: { + user: schema.authUsers, + session: schema.authSessions, + account: schema.authAccounts, + verification: schema.authVerifications, + }, + }), + emailAndPassword: { + enabled: true, + minPasswordLength: 10, + maxPasswordLength: 128, + }, + trustedOrigins: baseURL ? [baseURL] : ["http://localhost:3000"], + rateLimit: { + enabled: true, + window: 60, + max: 100, + }, + advanced: { + cookiePrefix: "hooky", + useSecureCookies: secureCookies, + disableCSRFCheck: false, + disableOriginCheck: false, + }, + plugins: [nextCookies()], + }); +} + +export const auth = createHookyAuth({ + database, + baseURL: configuredOrigin, + secret: process.env.BETTER_AUTH_SECRET ?? fallbackDevelopmentSecret, + secureCookies: process.env.NODE_ENV === "production", +}); diff --git a/apps/web/src/lib/authenticated-account.test.ts b/apps/web/src/lib/authenticated-account.test.ts new file mode 100644 index 0000000..c88a395 --- /dev/null +++ b/apps/web/src/lib/authenticated-account.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { hasTrustedOrigin } from "./authenticated-account"; + +describe("session origin protection", () => { + test("allows same-origin browser mutations with or without an Origin header", () => { + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { origin: "https://hooky.test" }, + }), + ), + ).toBe(true); + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { "sec-fetch-site": "same-origin" }, + }), + ), + ).toBe(true); + }); + + test("rejects cross-origin and non-browser cookie mutations", () => { + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { origin: "https://attacker.test" }, + }), + ), + ).toBe(false); + expect( + hasTrustedOrigin( + new Request("https://hooky.test/api/v1/hooks", { method: "POST" }), + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/lib/authenticated-account.ts b/apps/web/src/lib/authenticated-account.ts new file mode 100644 index 0000000..f2231e6 --- /dev/null +++ b/apps/web/src/lib/authenticated-account.ts @@ -0,0 +1,39 @@ +import { auth } from "./auth"; +import { accountStore, apiTokenStore } from "./server-database"; + +export function hasTrustedOrigin(request: Request) { + if (["GET", "HEAD", "OPTIONS"].includes(request.method)) { + return true; + } + if (request.headers.get("sec-fetch-site") === "same-origin") { + return true; + } + const origin = request.headers.get("origin"); + if (origin) { + return origin === new URL(request.url).origin; + } + return false; +} + +export async function authenticateAccount(request: Request) { + if (!hasTrustedOrigin(request)) { + return null; + } + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return null; + } + + return accountStore.ensurePersonalAccount({ + userId: session.user.id, + 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/deployment-origin.test.ts b/apps/web/src/lib/deployment-origin.test.ts new file mode 100644 index 0000000..33fce1c --- /dev/null +++ b/apps/web/src/lib/deployment-origin.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveDeploymentOrigin } from "./deployment-origin"; + +describe("deployment origin", () => { + test("prefers the explicitly configured canonical URL", () => { + expect( + resolveDeploymentOrigin({ + BETTER_AUTH_URL: "https://hooky.vercel.app", + VERCEL_URL: "hooky-preview.vercel.app", + }), + ).toBe("https://hooky.vercel.app"); + }); + + test("uses the current Vercel deployment URL for previews", () => { + expect( + resolveDeploymentOrigin({ VERCEL_URL: "hooky-preview.vercel.app" }), + ).toBe("https://hooky-preview.vercel.app"); + }); + + test("falls back to localhost for local development", () => { + expect(resolveDeploymentOrigin({})).toBe("http://localhost:3000"); + }); +}); diff --git a/apps/web/src/lib/deployment-origin.ts b/apps/web/src/lib/deployment-origin.ts new file mode 100644 index 0000000..b7313ab --- /dev/null +++ b/apps/web/src/lib/deployment-origin.ts @@ -0,0 +1,15 @@ +export function resolveDeploymentOrigin( + environment: Record, +) { + const configuredOrigin = environment.BETTER_AUTH_URL?.trim(); + if (configuredOrigin) { + return configuredOrigin; + } + + const vercelUrl = environment.VERCEL_URL?.trim(); + if (vercelUrl) { + return `https://${vercelUrl}`; + } + + return "http://localhost:3000"; +} diff --git a/apps/web/src/lib/hooks-api.test.ts b/apps/web/src/lib/hooks-api.test.ts new file mode 100644 index 0000000..1635be0 --- /dev/null +++ b/apps/web/src/lib/hooks-api.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; + +import { + createHooksCollectionHandlers, + createRotateIngressSecretHandler, +} from "./hooks-api"; + +describe("hooks API", () => { + test("requires an authenticated account", async () => { + const handlers = createHooksCollectionHandlers({ + authenticate: async () => null, + createHook: async () => { + throw new Error("must not be called"); + }, + listHooks: async () => [], + }); + + const response = await handlers.GET( + new Request("https://hooky.test/api/v1/hooks"), + ); + + expect(response.status).toBe(401); + }); + + test("creates an ingress URL without leaking tenant selection", async () => { + const handlers = createHooksCollectionHandlers({ + authenticate: async () => ({ accountId: "account-one" }), + createHook: async ({ accountId, name }) => ({ + hookId: `${accountId}:${name}`, + name, + state: "active" as const, + createdAt: new Date("2026-08-11T20:00:00.000Z"), + ingressToken: "hk_secret", + }), + listHooks: async () => [], + }); + + const response = await handlers.POST( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "stripe-dev", accountId: "attacker" }), + }), + ); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ + hookId: "account-one:stripe-dev", + name: "stripe-dev", + state: "active", + createdAt: "2026-08-11T20:00:00.000Z", + ingressUrl: "https://hooky.test/e/hk_secret", + }); + }); + + test("rejects invalid JSON input", async () => { + const handlers = createHooksCollectionHandlers({ + authenticate: async () => ({ accountId: "account-one" }), + createHook: async () => { + throw new Error("must not be called"); + }, + listHooks: async () => [], + }); + const response = await handlers.POST( + new Request("https://hooky.test/api/v1/hooks", { + method: "POST", + body: JSON.stringify({ name: "" }), + }), + ); + + expect(response.status).toBe(400); + }); + + test("rotates only through the authenticated account", async () => { + let rotatedFor: { accountId: string; hookId: string } | undefined; + const handler = createRotateIngressSecretHandler({ + authenticate: async () => ({ accountId: "account-one" }), + rotateIngressSecret: async (input) => { + rotatedFor = input; + return { hookId: input.hookId, ingressToken: "hk_rotated" }; + }, + }); + + const response = await handler( + new Request( + "https://hooky.test/api/v1/hooks/hook-one/rotate-ingress-secret", + { method: "POST" }, + ), + "hook-one", + ); + + expect(rotatedFor).toEqual({ + accountId: "account-one", + hookId: "hook-one", + }); + expect(await response.json()).toEqual({ + hookId: "hook-one", + ingressUrl: "https://hooky.test/e/hk_rotated", + }); + }); +}); diff --git a/apps/web/src/lib/hooks-api.ts b/apps/web/src/lib/hooks-api.ts new file mode 100644 index 0000000..95b1b90 --- /dev/null +++ b/apps/web/src/lib/hooks-api.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; + +const createHookInput = z.object({ + name: z.string().trim().min(1).max(80), +}); + +type Authentication = { accountId: string } | null; +type HookRecord = { + hookId: string; + name: string; + state: "active" | "disabled"; + createdAt: Date; + updatedAt?: Date; +}; + +function unauthorized() { + return Response.json({ error: "Authentication required" }, { status: 401 }); +} + +function ingressUrl(request: Request, token: string) { + return new URL( + `/e/${token}`, + process.env.BETTER_AUTH_URL ?? request.url, + ).toString(); +} + +export function createHooksCollectionHandlers({ + authenticate, + createHook, + listHooks, +}: { + authenticate: (request: Request) => Promise; + createHook: (input: { + accountId: string; + name: string; + }) => Promise; + listHooks: (input: { accountId: string }) => Promise; +}) { + return { + async GET(request: Request) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + + return Response.json( + { hooks: await listHooks({ accountId: authentication.accountId }) }, + { headers: { "cache-control": "no-store" } }, + ); + }, + async POST(request: Request) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + + const input = createHookInput.safeParse( + await request.json().catch(() => undefined), + ); + if (!input.success) { + return Response.json( + { error: "A hook name between 1 and 80 characters is required" }, + { status: 400 }, + ); + } + + try { + const { ingressToken, ...hook } = await createHook({ + accountId: authentication.accountId, + name: input.data.name, + }); + return Response.json( + { ...hook, ingressUrl: ingressUrl(request, ingressToken) }, + { status: 201 }, + ); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "23505" + ) { + return Response.json( + { error: "A hook with that name already exists" }, + { status: 409 }, + ); + } + throw error; + } + }, + }; +} + +export function createRotateIngressSecretHandler({ + authenticate, + rotateIngressSecret, +}: { + authenticate: (request: Request) => Promise; + rotateIngressSecret: (input: { + accountId: string; + hookId: string; + }) => Promise<{ hookId: string; ingressToken: string }>; +}) { + return async function rotate(request: Request, hookId: string) { + const authentication = await authenticate(request); + if (!authentication) { + return unauthorized(); + } + + try { + const rotated = await rotateIngressSecret({ + accountId: authentication.accountId, + hookId, + }); + return Response.json({ + hookId: rotated.hookId, + ingressUrl: ingressUrl(request, rotated.ingressToken), + }); + } catch (error) { + if (error instanceof Error && error.message === "Hook not found") { + return Response.json({ error: "Hook not found" }, { status: 404 }); + } + throw error; + } + }; +} diff --git a/apps/web/src/lib/ingress-handler.test.ts b/apps/web/src/lib/ingress-handler.test.ts new file mode 100644 index 0000000..d41e9b4 --- /dev/null +++ b/apps/web/src/lib/ingress-handler.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; + +import { createIngressHandler } from "./ingress-handler"; + +describe("ingress handler", () => { + test("commits exact request data before returning 202", async () => { + const calls: string[] = []; + const body = new Uint8Array([0, 255, 1, 2, 3]); + const handler = createIngressHandler({ + maxBodyBytes: 1024, + resolveIngressToken: async (token) => { + calls.push(`resolve:${token}`); + return { accountId: "account-one", hookId: "hook-one" }; + }, + recordWebhookEvent: async (input) => { + calls.push("record:start"); + expect(input).toMatchObject({ + accountId: "account-one", + hookId: "hook-one", + requestMethod: "POST", + requestPath: "/orders/complete", + query: { attempt: ["1", "2"], source: "stripe" }, + }); + expect(input.body).toEqual(Buffer.from(body)); + await Bun.sleep(5); + calls.push("record:committed"); + return { eventId: "event-one", deliveryId: "delivery-one" }; + }, + }); + + const response = await handler( + new Request( + "https://hooky.test/e/hk_token/orders/complete?attempt=1&attempt=2&source=stripe", + { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body, + }, + ), + { token: "hk_token", path: ["orders", "complete"] }, + ); + + expect(response.status).toBe(202); + expect(response.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/); + expect(await response.json()).toEqual({ + deliveryId: "delivery-one", + eventId: "event-one", + }); + expect(calls).toEqual([ + "resolve:hk_token", + "record:start", + "record:committed", + ]); + }); + + test("preserves an upstream Vercel request id for correlation", async () => { + const logs: Record[] = []; + const handler = createIngressHandler({ + maxBodyBytes: 1024, + resolveIngressToken: async () => ({ + accountId: "account-one", + hookId: "hook-one", + }), + recordWebhookEvent: async () => ({ + eventId: "event-one", + deliveryId: "delivery-one", + }), + log: (entry) => logs.push(entry), + }); + + const response = await handler( + new Request("https://hooky.test/e/token", { + method: "POST", + headers: { "x-vercel-id": "sfo1::abc-123" }, + }), + { token: "token", path: [] }, + ); + + expect(response.headers.get("x-request-id")).toBe("sfo1::abc-123"); + expect(logs).toEqual([ + expect.objectContaining({ + level: "info", + message: "webhook.accepted", + requestId: "sfo1::abc-123", + method: "POST", + bodyBytes: 0, + }), + ]); + }); + + test("rejects unknown tokens without recording", async () => { + let recorded = false; + const handler = createIngressHandler({ + maxBodyBytes: 1024, + resolveIngressToken: async () => null, + recordWebhookEvent: async () => { + recorded = true; + return { eventId: "event", deliveryId: "delivery" }; + }, + }); + + const response = await handler( + new Request("https://hooky.test/e/nope", { method: "POST" }), + { token: "nope", path: [] }, + ); + + expect(response.status).toBe(404); + expect(recorded).toBe(false); + }); + + test("rejects bodies over the configured limit", async () => { + const handler = createIngressHandler({ + maxBodyBytes: 4, + resolveIngressToken: async () => ({ + accountId: "account-one", + hookId: "hook-one", + }), + recordWebhookEvent: async () => ({ + eventId: "event", + deliveryId: "delivery", + }), + }); + + const response = await handler( + new Request("https://hooky.test/e/token", { + method: "POST", + body: "12345", + }), + { token: "token", path: [] }, + ); + + expect(response.status).toBe(413); + }); + + test("returns 503 if the durable commit fails", async () => { + const handler = createIngressHandler({ + maxBodyBytes: 1024, + resolveIngressToken: async () => ({ + accountId: "account-one", + hookId: "hook-one", + }), + recordWebhookEvent: async () => { + throw new Error("database unavailable"); + }, + }); + + const response = await handler( + new Request("https://hooky.test/e/token", { method: "POST" }), + { token: "token", path: [] }, + ); + + expect(response.status).toBe(503); + }); +}); diff --git a/apps/web/src/lib/ingress-handler.ts b/apps/web/src/lib/ingress-handler.ts new file mode 100644 index 0000000..ae34641 --- /dev/null +++ b/apps/web/src/lib/ingress-handler.ts @@ -0,0 +1,123 @@ +type RecordedWebhook = { + eventId: string; + deliveryId: string; +}; + +type ResolvedHook = { + accountId: string; + hookId: string; +}; + +function collectQuery(searchParams: URLSearchParams) { + const query: Record = {}; + + for (const key of new Set(searchParams.keys())) { + const values = searchParams.getAll(key); + query[key] = values.length === 1 ? values[0]! : values; + } + + return query; +} + +function collectHeaders(headers: Headers) { + return Object.fromEntries(headers.entries()); +} + +export function createIngressHandler({ + maxBodyBytes, + resolveIngressToken, + recordWebhookEvent, + log = (entry) => console.log(JSON.stringify(entry)), +}: { + maxBodyBytes: number; + resolveIngressToken: (token: string) => Promise; + recordWebhookEvent: (input: { + accountId: string; + hookId: string; + requestMethod: string; + requestPath: string; + query: Record; + headers: Record; + body: Buffer; + receivedAt: Date; + }) => Promise; + log?: (entry: Record) => void; +}) { + return async function handleIngress( + request: Request, + { token, path }: { token: string; path: string[] }, + ) { + const startedAt = Date.now(); + const requestId = request.headers.get("x-vercel-id") ?? crypto.randomUUID(); + const responseHeaders = { + "cache-control": "no-store", + "x-request-id": requestId, + }; + const contentLength = Number(request.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { + return Response.json( + { error: "Payload too large" }, + { status: 413, headers: responseHeaders }, + ); + } + + const resolved = await resolveIngressToken(token); + if (!resolved) { + return Response.json( + { error: "Webhook endpoint not found" }, + { status: 404, headers: responseHeaders }, + ); + } + + const body = Buffer.from(await request.arrayBuffer()); + if (body.byteLength > maxBodyBytes) { + return Response.json( + { error: "Payload too large" }, + { status: 413, headers: responseHeaders }, + ); + } + + try { + const recorded = await recordWebhookEvent({ + ...resolved, + requestMethod: request.method, + requestPath: `/${path.join("/")}`, + query: collectQuery(new URL(request.url).searchParams), + headers: collectHeaders(request.headers), + body, + receivedAt: new Date(), + }); + + log({ + level: "info", + message: "webhook.accepted", + requestId, + eventId: recorded.eventId, + hookId: resolved.hookId, + method: request.method, + bodyBytes: body.byteLength, + durationMs: Date.now() - startedAt, + }); + + return Response.json(recorded, { + status: 202, + headers: responseHeaders, + }); + } catch (error) { + log({ + level: "error", + message: "webhook.failed", + requestId, + hookId: resolved.hookId, + method: request.method, + bodyBytes: body.byteLength, + errorType: error instanceof Error ? error.name : "UnknownError", + durationMs: Date.now() - startedAt, + }); + return Response.json( + { error: "Webhook could not be durably accepted" }, + { status: 503, headers: responseHeaders }, + ); + } + }; +} 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/retention-handler.test.ts b/apps/web/src/lib/retention-handler.test.ts new file mode 100644 index 0000000..a58e285 --- /dev/null +++ b/apps/web/src/lib/retention-handler.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; + +import { createRetentionHandler } from "./retention-handler"; + +describe("retention handler", () => { + test("requires the configured cron bearer secret", async () => { + const handler = createRetentionHandler({ + cronSecret: "cron-secret", + retentionDays: 30, + deleteEventsReceivedBefore: async () => ({ deleted: 0 }), + now: () => new Date("2026-08-11T00:00:00.000Z"), + }); + + const response = await handler( + new Request("https://hooky.test/api/cron/retention"), + ); + + expect(response.status).toBe(401); + }); + + test("deletes events older than the configured retention window", async () => { + let receivedBefore: Date | undefined; + const handler = createRetentionHandler({ + cronSecret: "cron-secret", + retentionDays: 30, + deleteEventsReceivedBefore: async ({ before, limit }) => { + receivedBefore = before; + expect(limit).toBe(10_000); + return { deleted: 42 }; + }, + now: () => new Date("2026-08-11T00:00:00.000Z"), + }); + + const response = await handler( + new Request("https://hooky.test/api/cron/retention", { + headers: { authorization: "Bearer cron-secret" }, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ deleted: 42, retentionDays: 30 }); + expect(receivedBefore).toEqual(new Date("2026-07-12T00:00:00.000Z")); + }); +}); diff --git a/apps/web/src/lib/retention-handler.ts b/apps/web/src/lib/retention-handler.ts new file mode 100644 index 0000000..150118c --- /dev/null +++ b/apps/web/src/lib/retention-handler.ts @@ -0,0 +1,60 @@ +const millisecondsPerDay = 24 * 60 * 60 * 1000; + +function clampRetentionDays(value: number) { + return Math.min(365, Math.max(1, Math.trunc(value))); +} + +export function createRetentionHandler({ + cronSecret, + retentionDays, + deleteEventsReceivedBefore, + now = () => new Date(), + log = (entry) => console.log(JSON.stringify(entry)), +}: { + cronSecret: string | undefined; + retentionDays: number; + deleteEventsReceivedBefore: (input: { + before: Date; + limit: number; + }) => Promise<{ deleted: number }>; + now?: () => Date; + log?: (entry: Record) => void; +}) { + return async function handleRetention(request: Request) { + const startedAt = Date.now(); + const requestId = request.headers.get("x-vercel-id") ?? crypto.randomUUID(); + + if ( + !cronSecret || + request.headers.get("authorization") !== `Bearer ${cronSecret}` + ) { + return Response.json( + { error: "Unauthorized" }, + { status: 401, headers: { "x-request-id": requestId } }, + ); + } + + const safeRetentionDays = clampRetentionDays(retentionDays); + const before = new Date( + now().getTime() - safeRetentionDays * millisecondsPerDay, + ); + const result = await deleteEventsReceivedBefore({ + before, + limit: 10_000, + }); + + log({ + level: "info", + message: "retention.completed", + requestId, + deleted: result.deleted, + retentionDays: safeRetentionDays, + durationMs: Date.now() - startedAt, + }); + + return Response.json( + { ...result, retentionDays: safeRetentionDays }, + { headers: { "cache-control": "no-store", "x-request-id": requestId } }, + ); + }; +} diff --git a/apps/web/src/lib/server-database.ts b/apps/web/src/lib/server-database.ts new file mode 100644 index 0000000..d6ff476 --- /dev/null +++ b/apps/web/src/lib/server-database.ts @@ -0,0 +1,37 @@ +import { + AccountStore, + ApiTokenStore, + createDatabasePool, + createDrizzleDatabase, + DeliveryStore, + EventStore, + HookStore, + RetentionStore, +} from "@hooky/database"; +import { attachDatabasePool } from "@vercel/functions"; + +const connectionString = + process.env.DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:5432/hooky"; + +const globalDatabase = globalThis as typeof globalThis & { + hookyPool?: ReturnType; +}; + +export const databasePool = + globalDatabase.hookyPool ?? + createDatabasePool({ connectionString, maxConnections: 3 }); + +attachDatabasePool(databasePool); + +if (process.env.NODE_ENV !== "production") { + globalDatabase.hookyPool = databasePool; +} + +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); +export const eventStore = new EventStore(databasePool); +export const retentionStore = new RetentionStore(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/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000..6c39870 --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/retention", + "schedule": "0 4 * * *" + } + ] +} diff --git a/bun.lock b/bun.lock index be99215..dec44d8 100644 --- a/bun.lock +++ b/bun.lock @@ -15,13 +15,20 @@ "name": "@hooky/web", "version": "0.0.0", "dependencies": { + "@hooky/database": "workspace:*", + "@vercel/functions": "^3.9.3", + "better-auth": "1.6.27", + "drizzle-orm": "0.45.2", "next": "16.3.0", + "pg": "8.23.0", "react": "19.2.8", "react-dom": "19.2.8", + "zod": "4.4.3", }, "devDependencies": { "@types/bun": "1.3.14", "@types/node": "^20.19.32", + "@types/pg": "8.21.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", @@ -29,6 +36,32 @@ "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", + "dependencies": { + "drizzle-orm": "0.45.2", + "pg": "8.23.0", + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/pg": "8.21.0", + "drizzle-kit": "0.31.10", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "8.67.0", + }, + }, }, "trustedDependencies": [ "sharp", @@ -67,12 +100,88 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@better-auth/core": ["@better-auth/core@1.6.27", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-A6/mQW4AT2kSHCRZDh9+k8jPUqnCUUCymzBgIroK0l60wLioMtJdcmZiTwrAn6KVUw+4XWw64MgaRn7LOie3wg=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-BDJ02ra/fji/ah3aIpxjjNOu/JQVex0UisxS4S0vGZZyiAs+DL24mn3/iovyD5dWB3u3NaGg1n8zpTOntiIXcg=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-aavv7W4+b3QVObYo166baplWvwocCk8ORDxRqQ9ytzVl/waiUpSXAOtDHdXZHFsoVHFVPtEzyEq0Tqr3Qvvfig=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2" } }, "sha512-p4NB37MdaVFkxkpLEAKjORgTPhaOAPu1M2zgQXiTJJ3F6Uq3Y1YcCgoz+VxJu0TQfxibCsJD/dZlJMVgf+CF7A=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-IOYbJMIjEC//f+JpFlmIQjh6x1R/rGAfdzlojFk6HeAJqbsELuMcI+27dIoesbfHLF/icTrSpZtK986XhJrCfA=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-v7DXVyaFbkrfLoiFDtcVF7BkEZgFa/DgEGE7XjrAXmMACa5pjDvb7lm8W5X+/qgIbQP04eThhgFQ6EWOsjr8OQ=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-aYrSiVWQfua8w5YX8X1cM6ca48EdS0t5k+CvYXIsruaNIpR1DXMe1DOD0M5FWAVABnlCf9joyHXbFRSIZSNY/A=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -91,6 +200,10 @@ "@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"], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -169,6 +282,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.1.0", "", {}, "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q=="], + "@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw=="], @@ -189,6 +304,10 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA=="], + "@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="], + + "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -197,10 +316,14 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.10.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jh+pTGXLNz8+1tkUU13TI/f+ZOI+OvC4YbHi1H+57iSpLt5DR3xgptd+4sA07RdjdRR/RX/01uQQu2OkbzIefA=="], @@ -227,6 +350,8 @@ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@types/pg": ["@types/pg@8.21.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], @@ -295,6 +420,14 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@vercel/cli-config": ["@vercel/cli-config@0.2.3", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="], + + "@vercel/functions": ["@vercel/functions@3.9.3", "", { "dependencies": { "@vercel/oidc": "3.8.4" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw=="], + + "@vercel/oidc": ["@vercel/oidc@3.8.4", "", { "dependencies": { "@vercel/cli-config": "0.2.3", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -337,12 +470,18 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ=="], + "better-auth": ["better-auth@1.6.27", "", { "dependencies": { "@better-auth/core": "1.6.27", "@better-auth/drizzle-adapter": "1.6.27", "@better-auth/kysely-adapter": "1.6.27", "@better-auth/memory-adapter": "1.6.27", "@better-auth/mongo-adapter": "1.6.27", "@better-auth/prisma-adapter": "1.6.27", "@better-auth/telemetry": "1.6.27", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-x3jyxpAiBSsMO/DaXMFSVwM8bTqnYZlCKsZxBV43zL3ZtsGa/CzeUuNKxPT2Lsz7ahTBY90Ku1tibN6nRpVEbw=="], + + "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], + "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], @@ -387,10 +526,16 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.404", "", {}, "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g=="], @@ -415,6 +560,8 @@ "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -451,6 +598,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -491,6 +640,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], @@ -521,6 +672,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -571,6 +724,8 @@ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], @@ -589,6 +744,8 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], @@ -607,6 +764,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], @@ -623,10 +782,14 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -635,6 +798,8 @@ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "nanostores": ["nanostores@1.4.2", "", {}, "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g=="], + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -645,6 +810,8 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -661,8 +828,12 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -677,6 +848,22 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -689,6 +876,14 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], @@ -717,6 +912,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], @@ -727,7 +924,9 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -749,8 +948,16 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -769,6 +976,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -787,6 +996,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "turbo": ["turbo@2.10.9", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.9", "@turbo/darwin-arm64": "2.10.9", "@turbo/linux-64": "2.10.9", "@turbo/linux-arm64": "2.10.9", "@turbo/windows-64": "2.10.9", "@turbo/windows-arm64": "2.10.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Yl9+ukxH+UmPtKidpDkjn82tvPoEvFNb9UACd9vUomN1Ft0cwl3rx0P8yC1D93W9EOsWRMjllvIDG8y25sFOog=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -825,6 +1036,12 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], + + "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -835,6 +1052,12 @@ "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -847,28 +1070,134 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "@vercel/oidc/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "is-bun-module/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@next/eslint-plugin-next/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/design/concepts/creation-states.png b/design/concepts/creation-states.png new file mode 100644 index 0000000..eb98fb2 Binary files /dev/null and b/design/concepts/creation-states.png differ diff --git a/design/concepts/dashboard.png b/design/concepts/dashboard.png new file mode 100644 index 0000000..8386a8b Binary files /dev/null and b/design/concepts/dashboard.png differ diff --git a/design/qa/creation-render.png b/design/qa/creation-render.png new file mode 100644 index 0000000..9a1ce21 Binary files /dev/null and b/design/qa/creation-render.png differ diff --git a/design/qa/dashboard-mobile.png b/design/qa/dashboard-mobile.png new file mode 100644 index 0000000..7c074dd Binary files /dev/null and b/design/qa/dashboard-mobile.png differ diff --git a/design/qa/dashboard-render.png b/design/qa/dashboard-render.png new file mode 100644 index 0000000..c5497f8 Binary files /dev/null and b/design/qa/dashboard-render.png differ diff --git a/e2e/dashboard.e2e.ts b/e2e/dashboard.e2e.ts new file mode 100644 index 0000000..88c2e25 --- /dev/null +++ b/e2e/dashboard.e2e.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +test("creates a hook, captures an event, and creates a CLI key", async ({ + page, + request, +}) => { + const email = `developer-${Date.now()}-${Math.random()}@example.test`; + await page.goto("/sign-up"); + await page.getByLabel("Name").fill("Dak Engineering"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill("correct-horse-battery-staple"); + await page.getByRole("button", { name: "Create account" }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + await page.getByRole("button", { name: "New hook" }).click(); + await page.getByLabel("Hook name").fill("stripe-e2e"); + await page.getByRole("button", { name: "Create hook" }).click(); + await expect( + page.getByRole("heading", { name: "Webhook URL created" }), + ).toBeVisible(); + const ingressUrl = await page.locator(".secret-field code").textContent(); + expect(ingressUrl).toMatch(/^http:\/\/127\.0\.0\.1:3000\/e\/hk_/); + + const webhookResponse = await request.post(ingressUrl!, { + data: { order: "ord_e2e", amount: 4999, currency: "usd" }, + headers: { "stripe-signature": "e2e-signature" }, + }); + expect(webhookResponse.status()).toBe(202); + + await page.getByRole("button", { name: "Done" }).click(); + await page.reload(); + await expect( + page.getByRole("row", { name: /POST \/ now Pending 0/ }), + ).toBeVisible(); + await expect(page.locator(".payload-view")).toContainText("ord_e2e"); + await expect(page.locator(".payload-view")).toContainText("4999"); + + await page.getByRole("button", { name: "API keys" }).click(); + await page.getByLabel("Key name").fill("MacBook listener"); + await page.getByRole("button", { name: "Create API key" }).click(); + await expect( + page.getByRole("heading", { name: "API key created" }), + ).toBeVisible(); + await expect(page.locator(".secret-field code")).toContainText("hky_"); +}); diff --git a/e2e/home.e2e.ts b/e2e/home.e2e.ts index acfcfe1..f5cf87b 100644 --- a/e2e/home.e2e.ts +++ b/e2e/home.e2e.ts @@ -1,7 +1,12 @@ import { expect, test } from "@playwright/test"; test("introduces Hooky and its core promise", async ({ page }) => { - await page.goto("/"); + const response = await page.goto("/"); + + expect(response?.headers()["x-content-type-options"]).toBe("nosniff"); + expect(response?.headers()["referrer-policy"]).toBe( + "strict-origin-when-cross-origin", + ); await expect( page.getByRole("heading", { name: "Webhooks should wait for you." }), diff --git a/e2e/test-server.ts b/e2e/test-server.ts new file mode 100644 index 0000000..5d6e90e --- /dev/null +++ b/e2e/test-server.ts @@ -0,0 +1,39 @@ +import { spawn } from "node:child_process"; + +import { createTestDatabase } from "../packages/database/src/testing/test-database"; + +const database = await createTestDatabase(); +const serverCommand = process.env.HOOKY_E2E_PRODUCTION ? "start" : "dev"; +const webServer = spawn( + "bun", + ["run", "--cwd", "apps/web", serverCommand, "--hostname", "127.0.0.1"], + { + env: { + ...process.env, + DATABASE_URL: database.connectionString, + BETTER_AUTH_SECRET: "e2e-secret-that-is-at-least-thirty-two-characters", + BETTER_AUTH_URL: "http://127.0.0.1:3000", + }, + stdio: "inherit", + }, +); + +function stop() { + webServer.kill("SIGTERM"); +} + +process.once("SIGINT", stop); +process.once("SIGTERM", stop); + +await new Promise((resolve, reject) => { + webServer.once("error", reject); + webServer.once("exit", (code, signal) => { + if (code && code !== 0 && !signal) { + reject(new Error(`Next.js exited with code ${code}`)); + return; + } + resolve(); + }); +}); + +await database.close(); diff --git a/package.json b/package.json index 1611b94..7f44fb4 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/*", @@ -9,7 +12,10 @@ ], "scripts": { "build": "turbo run build", + "db:generate": "bun run --cwd packages/database generate", + "db:migrate": "bun run --cwd packages/database migrate", "dev": "turbo run dev --filter=@hooky/web", + "e2e:server": "bun e2e/test-server.ts", "lint": "turbo run lint", "prettier": "prettier --write .", "prettier:check": "prettier --check .", 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/drizzle.config.ts b/packages/database/drizzle.config.ts new file mode 100644 index 0000000..51123bf --- /dev/null +++ b/packages/database/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/schema.ts", + out: "./migrations", + dialect: "postgresql", + dbCredentials: { + url: + process.env.DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:5432/hooky", + }, + strict: true, + verbose: true, +}); diff --git a/packages/database/eslint.config.mjs b/packages/database/eslint.config.mjs new file mode 100644 index 0000000..9192a5a --- /dev/null +++ b/packages/database/eslint.config.mjs @@ -0,0 +1,13 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**", "migrations/**"], + }, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/consistent-type-imports": "error", + }, + }, +); diff --git a/packages/database/migrations/0000_colossal_cardiac.sql b/packages/database/migrations/0000_colossal_cardiac.sql new file mode 100644 index 0000000..989ad78 --- /dev/null +++ b/packages/database/migrations/0000_colossal_cardiac.sql @@ -0,0 +1,85 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto";--> statement-breakpoint +CREATE TYPE "public"."delivery_attempt_outcome" AS ENUM('delivered', 'failed', 'expired');--> statement-breakpoint +CREATE TYPE "public"."delivery_status" AS ENUM('pending', 'in_flight', 'delivered', 'dead');--> statement-breakpoint +CREATE TYPE "public"."hook_state" AS ENUM('active', 'disabled');--> statement-breakpoint +CREATE TABLE "accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "deliveries" ( + "id" uuid PRIMARY KEY NOT NULL, + "account_id" uuid NOT NULL, + "hook_id" uuid NOT NULL, + "event_id" uuid NOT NULL, + "status" "delivery_status" DEFAULT 'pending' NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "available_at" timestamp with time zone NOT NULL, + "leased_by" text, + "lease_token_hash" text, + "leased_until" timestamp with time zone, + "delivered_at" timestamp with time zone, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "deliveries_account_id_id_unique" UNIQUE("account_id","id"), + CONSTRAINT "deliveries_event_id_unique" UNIQUE("event_id") +); +--> statement-breakpoint +CREATE TABLE "delivery_attempts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "delivery_id" uuid NOT NULL, + "attempt_number" integer NOT NULL, + "listener_id" text NOT NULL, + "lease_token_hash" text NOT NULL, + "outcome" "delivery_attempt_outcome", + "error" text, + "started_at" timestamp with time zone NOT NULL, + "finished_at" timestamp with time zone, + CONSTRAINT "delivery_attempts_delivery_number_unique" UNIQUE("delivery_id","attempt_number") +); +--> statement-breakpoint +CREATE TABLE "hooks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "name" text NOT NULL, + "state" "hook_state" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hooks_account_id_id_unique" UNIQUE("account_id","id") +); +--> statement-breakpoint +CREATE TABLE "webhook_events" ( + "id" uuid PRIMARY KEY NOT NULL, + "account_id" uuid NOT NULL, + "hook_id" uuid NOT NULL, + "request_method" text NOT NULL, + "request_path" text NOT NULL, + "query" jsonb DEFAULT '{}'::jsonb NOT NULL, + "headers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "body" bytea NOT NULL, + "body_sha256" text NOT NULL, + "received_at" timestamp with time zone NOT NULL, + CONSTRAINT "webhook_events_account_id_id_unique" UNIQUE("account_id","id") +); +--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_account_event_fk" FOREIGN KEY ("account_id","event_id") REFERENCES "public"."webhook_events"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "delivery_attempts" ADD CONSTRAINT "delivery_attempts_account_delivery_fk" FOREIGN KEY ("account_id","delivery_id") REFERENCES "public"."deliveries"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hooks" ADD CONSTRAINT "hooks_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_events" ADD CONSTRAINT "webhook_events_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "deliveries_claim_index" ON "deliveries" USING btree ("account_id","hook_id","status","available_at","leased_until");--> statement-breakpoint +CREATE INDEX "delivery_attempts_account_delivery_index" ON "delivery_attempts" USING btree ("account_id","delivery_id");--> statement-breakpoint +CREATE UNIQUE INDEX "hooks_account_id_name_unique" ON "hooks" USING btree ("account_id","name");--> statement-breakpoint +CREATE INDEX "webhook_events_hook_received_at_index" ON "webhook_events" USING btree ("account_id","hook_id","received_at");--> statement-breakpoint +CREATE FUNCTION reject_webhook_event_update() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'webhook events are immutable'; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER webhook_events_immutable + BEFORE UPDATE ON webhook_events + FOR EACH ROW + EXECUTE FUNCTION reject_webhook_event_update(); diff --git a/packages/database/migrations/0001_friendly_wendell_rand.sql b/packages/database/migrations/0001_friendly_wendell_rand.sql new file mode 100644 index 0000000..85f9061 --- /dev/null +++ b/packages/database/migrations/0001_friendly_wendell_rand.sql @@ -0,0 +1,76 @@ +CREATE TYPE "public"."account_member_role" AS ENUM('owner', 'member');--> statement-breakpoint +CREATE TABLE "account_members" ( + "account_id" uuid NOT NULL, + "user_id" text NOT NULL, + "role" "account_member_role" DEFAULT 'member' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "account_members_account_id_user_id_pk" PRIMARY KEY("account_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "auth_accounts" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "auth_sessions_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "auth_users" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "auth_users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "auth_verifications" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "hook_secrets" ( + "hook_id" uuid PRIMARY KEY NOT NULL, + "account_id" uuid NOT NULL, + "ingress_secret_hash" text NOT NULL, + "rotated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hook_secrets_ingress_secret_hash_unique" UNIQUE("ingress_secret_hash") +); +--> statement-breakpoint +ALTER TABLE "account_members" ADD CONSTRAINT "account_members_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account_members" ADD CONSTRAINT "account_members_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_accounts" ADD CONSTRAINT "auth_accounts_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hook_secrets" ADD CONSTRAINT "hook_secrets_hook_id_hooks_id_fk" FOREIGN KEY ("hook_id") REFERENCES "public"."hooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hook_secrets" ADD CONSTRAINT "hook_secrets_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hook_secrets" ADD CONSTRAINT "hook_secrets_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "account_members_user_id_unique" ON "account_members" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "auth_accounts_user_id_index" ON "auth_accounts" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "auth_sessions_user_id_index" ON "auth_sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "auth_verifications_identifier_index" ON "auth_verifications" USING btree ("identifier"); \ No newline at end of file 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/0000_snapshot.json b/packages/database/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..4b7b8e0 --- /dev/null +++ b/packages/database/migrations/meta/0000_snapshot.json @@ -0,0 +1,564 @@ +{ + "id": "46766050-cbca-4a54-8644-cbfedaa5f60f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "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.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.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.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/0001_snapshot.json b/packages/database/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..440c456 --- /dev/null +++ b/packages/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,1066 @@ +{ + "id": "9e3a483a-506c-42bf-b738-4e58a21bdb78", + "prevId": "46766050-cbca-4a54-8644-cbfedaa5f60f", + "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.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/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 new file mode 100644 index 0000000..8d96c79 --- /dev/null +++ b/packages/database/migrations/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1786490390015, + "tag": "0000_colossal_cardiac", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "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/package.json b/packages/database/package.json new file mode 100644 index 0000000..14b6aa5 --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hooky/database", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts", + "./testing": "./src/testing/test-database.ts" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "generate": "drizzle-kit generate", + "lint": "eslint .", + "migrate": "drizzle-kit migrate", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "0.45.2", + "pg": "8.23.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/pg": "8.21.0", + "drizzle-kit": "0.31.10", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "8.67.0" + } +} diff --git a/packages/database/src/account-store.test.ts b/packages/database/src/account-store.test.ts new file mode 100644 index 0000000..faf8201 --- /dev/null +++ b/packages/database/src/account-store.test.ts @@ -0,0 +1,57 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { AccountStore } from "./account-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let store: AccountStore; + +beforeAll(async () => { + database = await createTestDatabase(); + store = new AccountStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("account store", () => { + test("creates one personal account for a new auth user", async () => { + const user = await database.seedAuthUser({ name: "Ada Lovelace" }); + + const first = await store.ensurePersonalAccount(user); + const second = await store.ensurePersonalAccount(user); + + expect(second).toEqual(first); + expect(first).toMatchObject({ name: "Ada Lovelace", role: "owner" }); + const count = await database.pool.query<{ count: number }>( + "select count(*)::int as count from accounts", + ); + expect(count.rows[0]?.count).toBe(1); + }); + + test("serializes concurrent account provisioning", async () => { + const user = await database.seedAuthUser({ name: "Grace Hopper" }); + + const accounts = await Promise.all( + Array.from({ length: 5 }, () => store.ensurePersonalAccount(user)), + ); + + expect(new Set(accounts.map((account) => account.accountId)).size).toBe(1); + }); + + test("returns null for an unknown user", async () => { + expect(await store.findAccountForUser("missing-user")).toBeNull(); + }); +}); diff --git a/packages/database/src/account-store.ts b/packages/database/src/account-store.ts new file mode 100644 index 0000000..e007434 --- /dev/null +++ b/packages/database/src/account-store.ts @@ -0,0 +1,83 @@ +import type { Pool, PoolClient } from "pg"; + +export type AccountMembership = { + accountId: string; + name: string; + role: "owner" | "member"; +}; + +async function findMembership(client: Pool | PoolClient, userId: string) { + const result = await client.query<{ + account_id: string; + name: string; + role: "owner" | "member"; + }>( + ` + select accounts.id as account_id, accounts.name, account_members.role + from account_members + inner join accounts on accounts.id = account_members.account_id + where account_members.user_id = $1 + `, + [userId], + ); + const membership = result.rows[0]; + return membership + ? { + accountId: membership.account_id, + name: membership.name, + role: membership.role, + } + : null; +} + +export class AccountStore { + constructor(private readonly pool: Pool) {} + + async findAccountForUser(userId: string) { + return findMembership(this.pool, userId); + } + + async ensurePersonalAccount({ + userId, + name, + }: { + userId: string; + name: string; + }): Promise { + const client = await this.pool.connect(); + + try { + await client.query("begin"); + await client.query( + "select pg_advisory_xact_lock(hashtextextended($1, 0))", + [userId], + ); + const existing = await findMembership(client, userId); + if (existing) { + await client.query("commit"); + return existing; + } + + const account = await client.query<{ id: string; name: string }>( + "insert into accounts (name) values ($1) returning id, name", + [name.trim() || "My workspace"], + ); + const created = account.rows[0]!; + await client.query( + ` + insert into account_members (account_id, user_id, role) + values ($1, $2, 'owner') + `, + [created.id, userId], + ); + await client.query("commit"); + + return { accountId: created.id, name: created.name, role: "owner" }; + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } + } +} 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/database.ts b/packages/database/src/database.ts new file mode 100644 index 0000000..df5277a --- /dev/null +++ b/packages/database/src/database.ts @@ -0,0 +1,21 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +import * as schema from "./schema"; + +export function createDatabasePool({ + connectionString, + maxConnections = 5, +}: { + connectionString: string; + maxConnections?: number; +}) { + return new Pool({ + connectionString, + max: maxConnections, + }); +} + +export function createDrizzleDatabase(pool: Pool) { + return drizzle(pool, { schema }); +} diff --git a/packages/database/src/delivery-store.test.ts b/packages/database/src/delivery-store.test.ts new file mode 100644 index 0000000..04b30be --- /dev/null +++ b/packages/database/src/delivery-store.test.ts @@ -0,0 +1,371 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { DeliveryStore, HookUnavailableError } from "./delivery-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let store: DeliveryStore; + +beforeAll(async () => { + database = await createTestDatabase(); + store = new DeliveryStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("delivery store", () => { + test("records and claims a webhook event for its account", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + const receivedAt = new Date("2026-08-11T20:00:00.000Z"); + + const recorded = await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/checkout", + query: { attempt: "1" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"order":"ord_123"}'), + receivedAt, + }); + const claimed = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 10, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + expect(recorded.eventId).toBe(claimed[0]?.eventId); + expect(recorded.deliveryId).toBe(claimed[0]?.deliveryId); + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ + attemptNumber: 1, + requestMethod: "POST", + requestPath: "/checkout", + query: { attempt: "1" }, + headers: { "content-type": "application/json" }, + receivedAt, + }); + expect(claimed[0]?.body).toEqual(Buffer.from('{"order":"ord_123"}')); + expect(claimed[0]?.leaseToken).toMatch(/^[a-f0-9]{64}$/); + }); + + test("never exposes a delivery across account boundaries", async () => { + const owner = await database.seedAccountAndHook(); + const otherAccount = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId: owner.accountId, + hookId: owner.hookId, + requestMethod: "POST", + requestPath: "/private", + query: {}, + headers: {}, + body: Buffer.from("secret"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const claimedByOtherAccount = await store.claimDeliveries({ + accountId: otherAccount.accountId, + hookId: owner.hookId, + listenerId: "wrong-account", + limit: 10, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + expect(claimedByOtherAccount).toEqual([]); + }); + + test("allows only one listener to claim an active lease", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/concurrent", + query: {}, + headers: {}, + body: Buffer.from("concurrent"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const claims = await Promise.all( + ["listener-one", "listener-two"].map((listenerId) => + store.claimDeliveries({ + accountId, + hookId, + listenerId, + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }), + ), + ); + + expect(claims.flat()).toHaveLength(1); + }); + + test("redelivers an event after its lease expires", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/recover", + query: {}, + headers: {}, + body: Buffer.from("recover"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const [firstClaim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + const beforeExpiry = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:30.000Z"), + }); + const [recoveredClaim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:32.000Z"), + }); + + expect(beforeExpiry).toEqual([]); + expect(recoveredClaim?.deliveryId).toBe(firstClaim?.deliveryId); + expect(recoveredClaim?.attemptNumber).toBe(2); + expect(recoveredClaim?.leaseToken).not.toBe(firstClaim?.leaseToken); + }); + + test("acknowledges only the current active lease", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/ack", + query: {}, + headers: {}, + body: Buffer.from("ack"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + const rejectedToken = await store.acknowledgeDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: "incorrect-token", + now: new Date("2026-08-11T20:00:02.000Z"), + }); + const acknowledged = await store.acknowledgeDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + now: new Date("2026-08-11T20:00:03.000Z"), + }); + const duplicateAcknowledgement = await store.acknowledgeDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + now: new Date("2026-08-11T20:00:04.000Z"), + }); + const redelivery = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-12T20:00:00.000Z"), + }); + + expect(rejectedToken).toBe(false); + expect(acknowledged).toBe(true); + expect(duplicateAcknowledgement).toBe(false); + expect(redelivery).toEqual([]); + }); + + test("releases a rejected delivery at its requested retry time", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/retry", + query: {}, + headers: {}, + body: Buffer.from("retry"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + const retryAt = new Date("2026-08-11T20:01:00.000Z"); + + const rejected = await store.rejectDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + error: "Local destination returned 503", + retryAt, + now: new Date("2026-08-11T20:00:02.000Z"), + }); + const beforeRetry = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:59.000Z"), + }); + const [retryClaim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: retryAt, + }); + + expect(rejected).toBe(true); + expect(beforeRetry).toEqual([]); + expect(retryClaim?.deliveryId).toBe(claim?.deliveryId); + expect(retryClaim?.attemptNumber).toBe(2); + }); + + test("extends an active lease from the heartbeat time", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/slow", + query: {}, + headers: {}, + body: Buffer.from("slow"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + const leasedUntil = await store.extendDeliveryLease({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:20.000Z"), + }); + + expect(leasedUntil).toEqual(new Date("2026-08-11T20:00:50.000Z")); + }); + + test("refuses to record against another account's hook", async () => { + const owner = await database.seedAccountAndHook(); + const otherAccount = await database.seedAccountAndHook(); + + const recording = store.recordWebhookEvent({ + accountId: otherAccount.accountId, + hookId: owner.hookId, + requestMethod: "POST", + requestPath: "/forbidden", + query: {}, + headers: {}, + body: Buffer.from("forbidden"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + await expect(recording).rejects.toBeInstanceOf(HookUnavailableError); + }); + + test("keeps captured webhook events immutable", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + const recorded = await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/original", + query: {}, + headers: {}, + body: Buffer.from("immutable"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const mutation = database.pool.query( + "update webhook_events set request_path = $1 where id = $2", + ["/changed", recorded.eventId], + ); + + await expect(mutation).rejects.toThrow("webhook events are immutable"); + }); + + test("refuses to record against a disabled hook", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + await database.pool.query( + "update hooks set state = 'disabled' where id = $1", + [hookId], + ); + + const recording = store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/disabled", + query: {}, + headers: {}, + body: Buffer.from("disabled"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + await expect(recording).rejects.toBeInstanceOf(HookUnavailableError); + }); +}); diff --git a/packages/database/src/delivery-store.ts b/packages/database/src/delivery-store.ts new file mode 100644 index 0000000..fbca112 --- /dev/null +++ b/packages/database/src/delivery-store.ts @@ -0,0 +1,396 @@ +import { createHash } from "node:crypto"; + +import type { Pool } from "pg"; + +export class HookUnavailableError extends Error { + constructor() { + super( + "The hook does not exist, is disabled, or belongs to another account", + ); + this.name = "HookUnavailableError"; + } +} + +export type ClaimedDelivery = { + deliveryId: string; + eventId: string; + attemptNumber: number; + leaseToken: string; + leasedUntil: Date; + requestMethod: string; + requestPath: string; + query: Record; + headers: Record; + body: Buffer; + receivedAt: Date; +}; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function clampInteger(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, Math.trunc(value))); +} + +export class DeliveryStore { + constructor(private readonly pool: Pool) {} + + async recordWebhookEvent({ + accountId, + hookId, + requestMethod, + requestPath, + query, + headers, + body, + receivedAt, + }: { + accountId: string; + hookId: string; + requestMethod: string; + requestPath: string; + query: Record; + headers: Record; + body: Buffer; + receivedAt: Date; + }) { + const eventId = crypto.randomUUID(); + const deliveryId = crypto.randomUUID(); + const bodySha256 = createHash("sha256").update(body).digest("hex"); + const result = await this.pool.query<{ + event_id: string; + delivery_id: string; + }>( + ` + with owned_hook as ( + select id + from hooks + where id = $1 + and account_id = $2 + and state = 'active' + ), inserted_event as ( + insert into webhook_events ( + id, + account_id, + hook_id, + request_method, + request_path, + query, + headers, + body, + body_sha256, + received_at + ) + select $3, $2, owned_hook.id, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11 + from owned_hook + returning id + ), inserted_delivery as ( + insert into deliveries ( + id, + account_id, + hook_id, + event_id, + available_at + ) + select $4, $2, $1, inserted_event.id, $11 + from inserted_event + returning id + ) + select inserted_event.id as event_id, inserted_delivery.id as delivery_id + from inserted_event + cross join inserted_delivery + `, + [ + hookId, + accountId, + eventId, + deliveryId, + requestMethod, + requestPath, + JSON.stringify(query), + JSON.stringify(headers), + body, + bodySha256, + receivedAt, + ], + ); + + const recorded = result.rows[0]; + if (!recorded) { + throw new HookUnavailableError(); + } + + return { + eventId: recorded.event_id, + deliveryId: recorded.delivery_id, + }; + } + + async claimDeliveries({ + accountId, + hookId, + listenerId, + limit, + leaseDurationSeconds, + now, + }: { + accountId: string; + hookId: string; + listenerId: string; + limit: number; + leaseDurationSeconds: number; + now: Date; + }): Promise { + const claimLimit = clampInteger(limit, 1, 10); + const leaseSeconds = clampInteger(leaseDurationSeconds, 1, 3600); + const result = await this.pool.query<{ + delivery_id: string; + event_id: string; + attempt_number: number; + lease_token: string; + leased_until: Date; + request_method: string; + request_path: string; + query: Record; + headers: Record; + body: Buffer; + received_at: Date; + }>( + ` + with candidates as materialized ( + select deliveries.id + from deliveries + inner join hooks + on hooks.id = deliveries.hook_id + and hooks.account_id = deliveries.account_id + where deliveries.account_id = $1 + and deliveries.hook_id = $2 + and hooks.state = 'active' + and ( + (deliveries.status = 'pending' and deliveries.available_at <= $3) + or ( + deliveries.status = 'in_flight' + and deliveries.leased_until <= $3 + ) + ) + order by deliveries.available_at, deliveries.created_at, deliveries.id + for update of deliveries skip locked + limit $4 + ), expired_attempts as ( + update delivery_attempts + set outcome = 'expired', finished_at = $3 + from deliveries + where delivery_attempts.delivery_id = deliveries.id + and deliveries.id in (select id from candidates) + and deliveries.status = 'in_flight' + and deliveries.leased_until <= $3 + and delivery_attempts.outcome is null + returning delivery_attempts.id + ), lease_values as ( + select + candidates.id, + replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '') as lease_token + from candidates + ), claimed as ( + update deliveries + set + status = 'in_flight', + attempt_count = deliveries.attempt_count + 1, + leased_by = $5, + lease_token_hash = encode(digest(lease_values.lease_token, 'sha256'), 'hex'), + leased_until = $3::timestamptz + make_interval(secs => $6::double precision), + updated_at = $3 + from lease_values + where deliveries.id = lease_values.id + returning + deliveries.id as delivery_id, + deliveries.account_id, + deliveries.event_id, + deliveries.attempt_count as attempt_number, + deliveries.lease_token_hash, + deliveries.leased_until, + lease_values.lease_token + ), inserted_attempts as ( + insert into delivery_attempts ( + account_id, + delivery_id, + attempt_number, + listener_id, + lease_token_hash, + started_at + ) + select + claimed.account_id, + claimed.delivery_id, + claimed.attempt_number, + $5, + claimed.lease_token_hash, + $3 + from claimed + returning delivery_id + ) + select + claimed.delivery_id, + claimed.event_id, + claimed.attempt_number, + claimed.lease_token, + claimed.leased_until, + webhook_events.request_method, + webhook_events.request_path, + webhook_events.query, + webhook_events.headers, + webhook_events.body, + webhook_events.received_at + from claimed + inner join webhook_events on webhook_events.id = claimed.event_id + order by webhook_events.received_at, claimed.delivery_id + `, + [accountId, hookId, now, claimLimit, listenerId, leaseSeconds], + ); + + return result.rows.map((row) => ({ + deliveryId: row.delivery_id, + eventId: row.event_id, + attemptNumber: row.attempt_number, + leaseToken: row.lease_token, + leasedUntil: row.leased_until, + requestMethod: row.request_method, + requestPath: row.request_path, + query: row.query, + headers: row.headers, + body: row.body, + receivedAt: row.received_at, + })); + } + + async acknowledgeDelivery({ + accountId, + deliveryId, + leaseToken, + now, + }: { + accountId: string; + deliveryId: string; + leaseToken: string; + now: Date; + }) { + const result = await this.pool.query<{ accepted: boolean }>( + ` + with accepted_delivery as ( + update deliveries + set + status = 'delivered', + delivered_at = $4, + leased_by = null, + lease_token_hash = null, + leased_until = null, + last_error = null, + updated_at = $4 + where account_id = $1 + and id = $2 + and status = 'in_flight' + and lease_token_hash = $3 + and leased_until > $4 + returning id, attempt_count + ), completed_attempt as ( + update delivery_attempts + set outcome = 'delivered', finished_at = $4 + from accepted_delivery + where delivery_attempts.delivery_id = accepted_delivery.id + and delivery_attempts.attempt_number = accepted_delivery.attempt_count + returning delivery_attempts.id + ) + select exists(select 1 from accepted_delivery) as accepted + `, + [accountId, deliveryId, hashToken(leaseToken), now], + ); + + return result.rows[0]?.accepted ?? false; + } + + async rejectDelivery({ + accountId, + deliveryId, + leaseToken, + error, + retryAt, + now, + }: { + accountId: string; + deliveryId: string; + leaseToken: string; + error: string; + retryAt: Date; + now: Date; + }) { + const result = await this.pool.query<{ accepted: boolean }>( + ` + with rejected_delivery as ( + update deliveries + set + status = 'pending', + available_at = $5, + leased_by = null, + lease_token_hash = null, + leased_until = null, + last_error = $4, + updated_at = $6 + where account_id = $1 + and id = $2 + and status = 'in_flight' + and lease_token_hash = $3 + and leased_until > $6 + returning id, attempt_count + ), failed_attempt as ( + update delivery_attempts + set outcome = 'failed', error = $4, finished_at = $6 + from rejected_delivery + where delivery_attempts.delivery_id = rejected_delivery.id + and delivery_attempts.attempt_number = rejected_delivery.attempt_count + returning delivery_attempts.id + ) + select exists(select 1 from rejected_delivery) as accepted + `, + [accountId, deliveryId, hashToken(leaseToken), error, retryAt, now], + ); + + return result.rows[0]?.accepted ?? false; + } + + async extendDeliveryLease({ + accountId, + deliveryId, + leaseToken, + leaseDurationSeconds, + now, + }: { + accountId: string; + deliveryId: string; + leaseToken: string; + leaseDurationSeconds: number; + now: Date; + }) { + const leaseSeconds = clampInteger(leaseDurationSeconds, 1, 3600); + const result = await this.pool.query<{ leased_until: Date }>( + ` + update deliveries + set + leased_until = greatest( + leased_until, + $4::timestamptz + make_interval(secs => $5::double precision) + ), + updated_at = $4 + where account_id = $1 + and id = $2 + and status = 'in_flight' + and lease_token_hash = $3 + and leased_until > $4 + returning leased_until + `, + [accountId, deliveryId, hashToken(leaseToken), now, leaseSeconds], + ); + + return result.rows[0]?.leased_until; + } +} diff --git a/packages/database/src/event-store.test.ts b/packages/database/src/event-store.test.ts new file mode 100644 index 0000000..0fc8dd7 --- /dev/null +++ b/packages/database/src/event-store.test.ts @@ -0,0 +1,129 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { DeliveryStore } from "./delivery-store"; +import { EventStore } from "./event-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let deliveryStore: DeliveryStore; +let eventStore: EventStore; + +beforeAll(async () => { + database = await createTestDatabase(); + deliveryStore = new DeliveryStore(database.pool); + eventStore = new EventStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("event store", () => { + test("lists recent event and delivery state for one tenant hook", async () => { + const owner = await database.seedAccountAndHook(); + const other = await database.seedAccountAndHook(); + const recorded = await deliveryStore.recordWebhookEvent({ + ...owner, + requestMethod: "POST", + requestPath: "/checkout", + query: { attempt: "1" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"order":"ord_123"}'), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + expect( + await eventStore.listRecentEvents({ + accountId: owner.accountId, + hookId: owner.hookId, + limit: 50, + }), + ).toEqual([ + { + eventId: recorded.eventId, + deliveryId: recorded.deliveryId, + requestMethod: "POST", + requestPath: "/checkout", + status: "pending", + attemptCount: 0, + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }, + ]); + expect( + await eventStore.listRecentEvents({ + accountId: other.accountId, + hookId: owner.hookId, + limit: 50, + }), + ).toEqual([]); + }); + + test("returns captured details and delivery history only to its tenant", async () => { + const owner = await database.seedAccountAndHook(); + const other = await database.seedAccountAndHook(); + const recorded = await deliveryStore.recordWebhookEvent({ + ...owner, + requestMethod: "POST", + requestPath: "/invoice.paid", + query: { source: "stripe" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"invoice":"in_123"}'), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await deliveryStore.claimDeliveries({ + ...owner, + listenerId: "cli-a8f2", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + await deliveryStore.acknowledgeDelivery({ + accountId: owner.accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + now: new Date("2026-08-11T20:00:02.000Z"), + }); + + const detail = await eventStore.getEvent({ + accountId: owner.accountId, + eventId: recorded.eventId, + }); + + expect(detail).toMatchObject({ + eventId: recorded.eventId, + hookId: owner.hookId, + requestMethod: "POST", + requestPath: "/invoice.paid", + query: { source: "stripe" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"invoice":"in_123"}'), + status: "delivered", + attempts: [ + { + attemptNumber: 1, + listenerId: "cli-a8f2", + outcome: "delivered", + startedAt: new Date("2026-08-11T20:00:01.000Z"), + finishedAt: new Date("2026-08-11T20:00:02.000Z"), + }, + ], + }); + expect( + await eventStore.getEvent({ + accountId: other.accountId, + eventId: recorded.eventId, + }), + ).toBeNull(); + }); +}); diff --git a/packages/database/src/event-store.ts b/packages/database/src/event-store.ts new file mode 100644 index 0000000..8632724 --- /dev/null +++ b/packages/database/src/event-store.ts @@ -0,0 +1,151 @@ +import type { Pool } from "pg"; + +export class EventStore { + constructor(private readonly pool: Pool) {} + + async listRecentEvents({ + accountId, + hookId, + limit, + }: { + accountId: string; + hookId: string; + limit: number; + }) { + const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit))); + const result = await this.pool.query<{ + event_id: string; + delivery_id: string; + request_method: string; + request_path: string; + status: "pending" | "in_flight" | "delivered" | "dead"; + attempt_count: number; + received_at: Date; + }>( + ` + select + webhook_events.id as event_id, + deliveries.id as delivery_id, + webhook_events.request_method, + webhook_events.request_path, + deliveries.status, + deliveries.attempt_count, + webhook_events.received_at + from webhook_events + inner join deliveries + on deliveries.event_id = webhook_events.id + and deliveries.account_id = webhook_events.account_id + where webhook_events.account_id = $1 + and webhook_events.hook_id = $2 + order by webhook_events.received_at desc, webhook_events.id desc + limit $3 + `, + [accountId, hookId, safeLimit], + ); + + return result.rows.map((row) => ({ + eventId: row.event_id, + deliveryId: row.delivery_id, + requestMethod: row.request_method, + requestPath: row.request_path, + status: row.status, + attemptCount: row.attempt_count, + receivedAt: row.received_at, + })); + } + + async getEvent({ + accountId, + eventId, + }: { + accountId: string; + eventId: string; + }) { + const eventResult = await this.pool.query<{ + event_id: string; + hook_id: string; + delivery_id: string; + request_method: string; + request_path: string; + query: Record; + headers: Record; + body: Buffer; + body_sha256: string; + received_at: Date; + status: "pending" | "in_flight" | "delivered" | "dead"; + attempt_count: number; + delivered_at: Date | null; + last_error: string | null; + }>( + ` + select + webhook_events.id as event_id, + webhook_events.hook_id, + deliveries.id as delivery_id, + webhook_events.request_method, + webhook_events.request_path, + webhook_events.query, + webhook_events.headers, + webhook_events.body, + webhook_events.body_sha256, + webhook_events.received_at, + deliveries.status, + deliveries.attempt_count, + deliveries.delivered_at, + deliveries.last_error + from webhook_events + inner join deliveries + on deliveries.event_id = webhook_events.id + and deliveries.account_id = webhook_events.account_id + where webhook_events.account_id = $1 and webhook_events.id = $2 + `, + [accountId, eventId], + ); + const event = eventResult.rows[0]; + if (!event) { + return null; + } + + const attemptsResult = await this.pool.query<{ + attempt_number: number; + listener_id: string; + outcome: "delivered" | "failed" | "expired" | null; + error: string | null; + started_at: Date; + finished_at: Date | null; + }>( + ` + select attempt_number, listener_id, outcome, error, started_at, finished_at + from delivery_attempts + where account_id = $1 and delivery_id = $2 + order by attempt_number + `, + [accountId, event.delivery_id], + ); + + return { + eventId: event.event_id, + hookId: event.hook_id, + deliveryId: event.delivery_id, + requestMethod: event.request_method, + requestPath: event.request_path, + query: event.query, + headers: event.headers, + body: event.body, + bodySha256: event.body_sha256, + receivedAt: event.received_at, + status: event.status, + attemptCount: event.attempt_count, + deliveredAt: event.delivered_at, + lastError: event.last_error, + attempts: attemptsResult.rows.map((attempt) => ({ + attemptNumber: attempt.attempt_number, + listenerId: attempt.listener_id, + outcome: attempt.outcome, + error: attempt.error, + startedAt: attempt.started_at, + finishedAt: attempt.finished_at, + })), + }; + } +} diff --git a/packages/database/src/hook-store.test.ts b/packages/database/src/hook-store.test.ts new file mode 100644 index 0000000..96f9e10 --- /dev/null +++ b/packages/database/src/hook-store.test.ts @@ -0,0 +1,99 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { HookStore } from "./hook-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let store: HookStore; + +beforeAll(async () => { + database = await createTestDatabase(); + store = new HookStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("hook store", () => { + test("creates a hook with a one-time ingress token and resolves it", async () => { + const { accountId } = await database.seedAccount(); + + const created = await store.createHook({ + accountId, + name: "stripe-dev", + }); + const resolved = await store.resolveIngressToken(created.ingressToken); + + expect(created.hookId).toBeString(); + expect(created.ingressToken).toMatch(/^hk_[a-f0-9]{32}_[A-Za-z0-9_-]{43}$/); + expect(resolved).toEqual({ accountId, hookId: created.hookId }); + }); + + test("never persists the plaintext ingress secret", async () => { + const { accountId } = await database.seedAccount(); + const created = await store.createHook({ + accountId, + name: "github-dev", + }); + const plaintextSecret = created.ingressToken.split("_").at(-1); + const result = await database.pool.query<{ + ingress_secret_hash: string; + }>("select ingress_secret_hash from hook_secrets where hook_id = $1", [ + created.hookId, + ]); + + expect(result.rows[0]?.ingress_secret_hash).not.toContain( + plaintextSecret ?? "", + ); + expect(result.rows[0]?.ingress_secret_hash).toMatch(/^[a-f0-9]{64}$/); + }); + + test("rotates a secret and immediately invalidates the previous token", async () => { + const { accountId } = await database.seedAccount(); + const created = await store.createHook({ + accountId, + name: "linear-dev", + }); + + const rotated = await store.rotateIngressSecret({ + accountId, + hookId: created.hookId, + }); + + expect(rotated.ingressToken).not.toBe(created.ingressToken); + expect(await store.resolveIngressToken(created.ingressToken)).toBeNull(); + expect(await store.resolveIngressToken(rotated.ingressToken)).toEqual({ + accountId, + hookId: created.hookId, + }); + }); + + test("keeps listing and rotation tenant scoped", async () => { + const owner = await database.seedAccount(); + const other = await database.seedAccount(); + const created = await store.createHook({ + accountId: owner.accountId, + name: "private-dev", + }); + + expect(await store.listHooks({ accountId: other.accountId })).toEqual([]); + await expect( + store.rotateIngressSecret({ + accountId: other.accountId, + hookId: created.hookId, + }), + ).rejects.toThrow("Hook not found"); + }); +}); diff --git a/packages/database/src/hook-store.ts b/packages/database/src/hook-store.ts new file mode 100644 index 0000000..eb081d8 --- /dev/null +++ b/packages/database/src/hook-store.ts @@ -0,0 +1,158 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import type { Pool } from "pg"; + +const ingressTokenPattern = /^hk_([a-f0-9]{32})_([A-Za-z0-9_-]{43})$/; + +function hashSecret(secret: string) { + return createHash("sha256").update(secret).digest("hex"); +} + +function toCompactUuid(id: string) { + return id.replaceAll("-", ""); +} + +function fromCompactUuid(id: string) { + return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`; +} + +function createIngressToken(hookId: string) { + const secret = randomBytes(32).toString("base64url"); + return { + ingressToken: `hk_${toCompactUuid(hookId)}_${secret}`, + secretHash: hashSecret(secret), + }; +} + +export class HookStore { + constructor(private readonly pool: Pool) {} + + async createHook({ accountId, name }: { accountId: string; name: string }) { + const normalizedName = name.trim(); + if (normalizedName.length < 1 || normalizedName.length > 80) { + throw new Error("Hook name must be between 1 and 80 characters"); + } + + const hookId = randomUUID(); + const { ingressToken, secretHash } = createIngressToken(hookId); + const client = await this.pool.connect(); + + try { + await client.query("begin"); + const result = await client.query<{ + created_at: Date; + name: string; + state: "active" | "disabled"; + }>( + ` + insert into hooks (id, account_id, name) + values ($1, $2, $3) + returning name, state, created_at + `, + [hookId, accountId, normalizedName], + ); + await client.query( + ` + insert into hook_secrets (hook_id, account_id, ingress_secret_hash) + values ($1, $2, $3) + `, + [hookId, accountId, secretHash], + ); + await client.query("commit"); + + return { + hookId, + name: result.rows[0]!.name, + state: result.rows[0]!.state, + createdAt: result.rows[0]!.created_at, + ingressToken, + }; + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } + } + + async listHooks({ accountId }: { accountId: string }) { + const result = await this.pool.query<{ + id: string; + name: string; + state: "active" | "disabled"; + created_at: Date; + updated_at: Date; + }>( + ` + select id, name, state, created_at, updated_at + from hooks + where account_id = $1 + order by created_at desc, id + `, + [accountId], + ); + + return result.rows.map((row) => ({ + hookId: row.id, + name: row.name, + state: row.state, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + async rotateIngressSecret({ + accountId, + hookId, + }: { + accountId: string; + hookId: string; + }) { + const { ingressToken, secretHash } = createIngressToken(hookId); + const result = await this.pool.query<{ hook_id: string }>( + ` + update hook_secrets + set ingress_secret_hash = $3, rotated_at = now() + where account_id = $1 and hook_id = $2 + returning hook_id + `, + [accountId, hookId, secretHash], + ); + + if (!result.rows[0]) { + throw new Error("Hook not found"); + } + + return { hookId, ingressToken }; + } + + async resolveIngressToken(token: string) { + const match = ingressTokenPattern.exec(token); + if (!match) { + return null; + } + + const [, compactHookId, secret] = match; + const result = await this.pool.query<{ + account_id: string; + hook_id: string; + }>( + ` + select hook_secrets.account_id, hook_secrets.hook_id + from hook_secrets + inner join hooks + on hooks.id = hook_secrets.hook_id + and hooks.account_id = hook_secrets.account_id + where hook_secrets.hook_id = $1 + and hook_secrets.ingress_secret_hash = $2 + and hooks.state = 'active' + `, + [fromCompactUuid(compactHookId!), hashSecret(secret!)], + ); + + const resolved = result.rows[0]; + return resolved + ? { accountId: resolved.account_id, hookId: resolved.hook_id } + : null; + } +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 0000000..1e50885 --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,12 @@ +export { createDatabasePool, createDrizzleDatabase } from "./database"; +export { AccountStore, type AccountMembership } from "./account-store"; +export { ApiTokenStore } from "./api-token-store"; +export { + DeliveryStore, + HookUnavailableError, + type ClaimedDelivery, +} from "./delivery-store"; +export { EventStore } from "./event-store"; +export { HookStore } from "./hook-store"; +export { RetentionStore } from "./retention-store"; +export * as schema from "./schema"; diff --git a/packages/database/src/retention-store.test.ts b/packages/database/src/retention-store.test.ts new file mode 100644 index 0000000..915576e --- /dev/null +++ b/packages/database/src/retention-store.test.ts @@ -0,0 +1,92 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { DeliveryStore } from "./delivery-store"; +import { RetentionStore } from "./retention-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let deliveryStore: DeliveryStore; +let retentionStore: RetentionStore; + +beforeAll(async () => { + database = await createTestDatabase(); + deliveryStore = new DeliveryStore(database.pool); + retentionStore = new RetentionStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("retention store", () => { + test("deletes old events and their delivery history in bounded batches", async () => { + const hook = await database.seedAccountAndHook(); + const oldEvent = await deliveryStore.recordWebhookEvent({ + ...hook, + requestMethod: "POST", + requestPath: "/old", + query: {}, + headers: {}, + body: Buffer.from("old"), + receivedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + const currentEvent = await deliveryStore.recordWebhookEvent({ + ...hook, + requestMethod: "POST", + requestPath: "/current", + query: {}, + headers: {}, + body: Buffer.from("current"), + receivedAt: new Date("2026-08-01T00:00:00.000Z"), + }); + + expect( + await retentionStore.deleteEventsReceivedBefore({ + before: new Date("2026-07-01T00:00:00.000Z"), + limit: 1, + }), + ).toEqual({ deleted: 1 }); + + const remaining = await database.pool.query<{ id: string }>( + "select id from webhook_events order by received_at", + ); + expect(remaining.rows).toEqual([{ id: currentEvent.eventId }]); + expect( + await database.pool.query( + "select id from deliveries where event_id = $1", + [oldEvent.eventId], + ), + ).toMatchObject({ rowCount: 0 }); + }); + + test("clamps cleanup work to a safe maximum", async () => { + const hook = await database.seedAccountAndHook(); + await deliveryStore.recordWebhookEvent({ + ...hook, + requestMethod: "POST", + requestPath: "/old", + query: {}, + headers: {}, + body: Buffer.from("old"), + receivedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + expect( + await retentionStore.deleteEventsReceivedBefore({ + before: new Date("2026-07-01T00:00:00.000Z"), + limit: 0, + }), + ).toEqual({ deleted: 1 }); + }); +}); diff --git a/packages/database/src/retention-store.ts b/packages/database/src/retention-store.ts new file mode 100644 index 0000000..68199c0 --- /dev/null +++ b/packages/database/src/retention-store.ts @@ -0,0 +1,36 @@ +import type { Pool } from "pg"; + +function clampLimit(value: number) { + return Math.min(10_000, Math.max(1, Math.trunc(value))); +} + +export class RetentionStore { + constructor(private readonly pool: Pool) {} + + async deleteEventsReceivedBefore({ + before, + limit, + }: { + before: Date; + limit: number; + }) { + const result = await this.pool.query<{ id: string }>( + ` + with candidates as materialized ( + select id + from webhook_events + where received_at < $1 + order by received_at, id + limit $2 + ) + delete from webhook_events + using candidates + where webhook_events.id = candidates.id + returning webhook_events.id + `, + [before, clampLimit(limit)], + ); + + return { deleted: result.rowCount ?? 0 }; + } +} diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts new file mode 100644 index 0000000..d9436fc --- /dev/null +++ b/packages/database/src/schema.ts @@ -0,0 +1,307 @@ +import { + boolean, + customType, + foreignKey, + index, + integer, + jsonb, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + unique, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return "bytea"; + }, +}); + +export const hookState = pgEnum("hook_state", ["active", "disabled"]); +export const deliveryStatus = pgEnum("delivery_status", [ + "pending", + "in_flight", + "delivered", + "dead", +]); +export const deliveryAttemptOutcome = pgEnum("delivery_attempt_outcome", [ + "delivered", + "failed", + "expired", +]); +export const accountMemberRole = pgEnum("account_member_role", [ + "owner", + "member", +]); + +export const authUsers = pgTable("auth_users", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +export const authSessions = pgTable( + "auth_sessions", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + }, + (table) => [index("auth_sessions_user_id_index").on(table.userId)], +); + +export const authAccounts = pgTable( + "auth_accounts", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [index("auth_accounts_user_id_index").on(table.userId)], +); + +export const authVerifications = pgTable( + "auth_verifications", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("auth_verifications_identifier_index").on(table.identifier), + ], +); + +export const accounts = pgTable("accounts", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export const accountMembers = pgTable( + "account_members", + { + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + role: accountMemberRole("role").default("member").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + primaryKey({ columns: [table.accountId, table.userId] }), + uniqueIndex("account_members_user_id_unique").on(table.userId), + ], +); + +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", + { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + name: text("name").notNull(), + state: hookState("state").default("active").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + unique("hooks_account_id_id_unique").on(table.accountId, table.id), + uniqueIndex("hooks_account_id_name_unique").on(table.accountId, table.name), + ], +); + +export const hookSecrets = pgTable( + "hook_secrets", + { + hookId: uuid("hook_id") + .primaryKey() + .references(() => hooks.id, { onDelete: "cascade" }), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + ingressSecretHash: text("ingress_secret_hash").notNull().unique(), + rotatedAt: timestamp("rotated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + foreignKey({ + columns: [table.accountId, table.hookId], + foreignColumns: [hooks.accountId, hooks.id], + name: "hook_secrets_account_hook_fk", + }).onDelete("cascade"), + ], +); + +export const webhookEvents = pgTable( + "webhook_events", + { + id: uuid("id").primaryKey(), + accountId: uuid("account_id").notNull(), + hookId: uuid("hook_id").notNull(), + requestMethod: text("request_method").notNull(), + requestPath: text("request_path").notNull(), + query: jsonb("query") + .$type>() + .default({}) + .notNull(), + headers: jsonb("headers") + .$type>() + .default({}) + .notNull(), + body: bytea("body").notNull(), + bodySha256: text("body_sha256").notNull(), + receivedAt: timestamp("received_at", { withTimezone: true }).notNull(), + }, + (table) => [ + unique("webhook_events_account_id_id_unique").on(table.accountId, table.id), + foreignKey({ + columns: [table.accountId, table.hookId], + foreignColumns: [hooks.accountId, hooks.id], + name: "webhook_events_account_hook_fk", + }).onDelete("cascade"), + index("webhook_events_hook_received_at_index").on( + table.accountId, + table.hookId, + table.receivedAt, + ), + ], +); + +export const deliveries = pgTable( + "deliveries", + { + id: uuid("id").primaryKey(), + accountId: uuid("account_id").notNull(), + hookId: uuid("hook_id").notNull(), + eventId: uuid("event_id").notNull(), + status: deliveryStatus("status").default("pending").notNull(), + attemptCount: integer("attempt_count").default(0).notNull(), + availableAt: timestamp("available_at", { withTimezone: true }).notNull(), + leasedBy: text("leased_by"), + leaseTokenHash: text("lease_token_hash"), + leasedUntil: timestamp("leased_until", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + unique("deliveries_account_id_id_unique").on(table.accountId, table.id), + unique("deliveries_event_id_unique").on(table.eventId), + foreignKey({ + columns: [table.accountId, table.hookId], + foreignColumns: [hooks.accountId, hooks.id], + name: "deliveries_account_hook_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.accountId, table.eventId], + foreignColumns: [webhookEvents.accountId, webhookEvents.id], + name: "deliveries_account_event_fk", + }).onDelete("cascade"), + index("deliveries_claim_index").on( + table.accountId, + table.hookId, + table.status, + table.availableAt, + table.leasedUntil, + ), + ], +); + +export const deliveryAttempts = pgTable( + "delivery_attempts", + { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull(), + deliveryId: uuid("delivery_id").notNull(), + attemptNumber: integer("attempt_number").notNull(), + listenerId: text("listener_id").notNull(), + leaseTokenHash: text("lease_token_hash").notNull(), + outcome: deliveryAttemptOutcome("outcome"), + error: text("error"), + startedAt: timestamp("started_at", { withTimezone: true }).notNull(), + finishedAt: timestamp("finished_at", { withTimezone: true }), + }, + (table) => [ + unique("delivery_attempts_delivery_number_unique").on( + table.deliveryId, + table.attemptNumber, + ), + foreignKey({ + columns: [table.accountId, table.deliveryId], + foreignColumns: [deliveries.accountId, deliveries.id], + name: "delivery_attempts_account_delivery_fk", + }).onDelete("cascade"), + index("delivery_attempts_account_delivery_index").on( + table.accountId, + table.deliveryId, + ), + ], +); diff --git a/packages/database/src/testing/test-database.ts b/packages/database/src/testing/test-database.ts new file mode 100644 index 0000000..a1f1781 --- /dev/null +++ b/packages/database/src/testing/test-database.ts @@ -0,0 +1,159 @@ +import { execFileSync, spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; + +async function reservePort() { + const server = createServer(); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Unable to reserve a PostgreSQL test port"); + } + + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }); + + return address.port; +} + +async function waitForPostgres(pool: Pool) { + let lastError: unknown; + + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + await pool.query("select 1"); + return; + } catch (error) { + lastError = error; + await Bun.sleep(50); + } + } + + throw new Error("Ephemeral PostgreSQL did not become ready", { + cause: lastError, + }); +} + +async function startEphemeralPostgres() { + const dataDirectory = await mkdtemp(join(tmpdir(), "hooky-postgres-")); + const port = await reservePort(); + + execFileSync( + "initdb", + [ + "-D", + dataDirectory, + "-A", + "trust", + "-U", + "postgres", + "--no-locale", + "-E", + "UTF8", + ], + { stdio: "ignore" }, + ); + + const process = spawn( + "postgres", + ["-D", dataDirectory, "-p", String(port), "-k", dataDirectory], + { stdio: "ignore" }, + ); + const connectionString = `postgresql://postgres@127.0.0.1:${port}/postgres`; + const pool = new Pool({ connectionString }); + + try { + await waitForPostgres(pool); + } catch (error) { + process.kill("SIGTERM"); + await rm(dataDirectory, { recursive: true, force: true }); + throw error; + } + + return { + connectionString, + async stop() { + await pool.end(); + if (process.exitCode === null && process.signalCode === null) { + execFileSync("pg_ctl", ["-D", dataDirectory, "stop", "-m", "fast"], { + stdio: "ignore", + }); + } + await rm(dataDirectory, { recursive: true, force: true }); + }, + }; +} + +export async function createTestDatabase() { + const configuredConnectionString = process.env.TEST_DATABASE_URL; + const ephemeral = configuredConnectionString + ? undefined + : await startEphemeralPostgres(); + const pool = new Pool({ + connectionString: + configuredConnectionString ?? ephemeral?.connectionString ?? "", + }); + + await migrate(drizzle(pool), { + migrationsFolder: resolve(import.meta.dir, "../../migrations"), + }); + + async function seedAccount() { + const accountId = crypto.randomUUID(); + await pool.query(`insert into accounts (id, name) values ($1, $2)`, [ + accountId, + "Test account", + ]); + return { accountId }; + } + + return { + connectionString: + configuredConnectionString ?? ephemeral?.connectionString ?? "", + pool, + async reset() { + await pool.query( + "truncate table auth_users, accounts restart identity cascade", + ); + }, + async seedAccount() { + return seedAccount(); + }, + async seedAuthUser({ name = "Test user" }: { name?: string } = {}) { + const userId = crypto.randomUUID(); + const email = `${userId}@example.test`; + await pool.query( + `insert into auth_users (id, name, email) values ($1, $2, $3)`, + [userId, name, email], + ); + return { userId, name, email }; + }, + async seedAccountAndHook() { + const { accountId } = await seedAccount(); + const hookId = crypto.randomUUID(); + await pool.query( + `insert into hooks (id, account_id, name) values ($1, $2, $3)`, + [hookId, accountId, "stripe-dev"], + ); + + return { accountId, hookId }; + }, + async close() { + await pool.end(); + await ephemeral?.stop(); + }, + }; +} diff --git a/packages/database/tsconfig.build.json b/packages/database/tsconfig.build.json new file mode 100644 index 0000000..338e994 --- /dev/null +++ b/packages/database/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/testing/**"] +} diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 0000000..b7d0e0d --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["bun"] + }, + "include": ["drizzle.config.ts", "src/**/*.ts"] +} diff --git a/playwright.config.ts b/playwright.config.ts index 5294e50..fb59474 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,8 +18,7 @@ export default defineConfig({ }, ], webServer: { - command: - "cd apps/web && exec ./node_modules/.bin/next dev --hostname 127.0.0.1", + command: "bun run e2e:server", url: "http://127.0.0.1:3000/api/health", reuseExistingServer: false, timeout: 120_000, diff --git a/turbo.json b/turbo.json index 8207f92..0cf138d 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,14 @@ "tasks": { "build": { "dependsOn": ["^build"], + "env": [ + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "CRON_SECRET", + "DATABASE_URL", + "RETENTION_DAYS", + "VERCEL_URL" + ], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "dev": {