diff --git a/apps/web/.env.example b/apps/web/.env.example index 80e15a1..44b8c9d 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,2 +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..9a2c1ce 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, + transpilePackages: ["@hooky/database"], }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 83480e2..4cadc2b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,13 +10,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@hooky/database": "workspace:*", + "better-auth": "1.6.27", + "drizzle-orm": "0.45.2", "next": "16.3.0", + "pg": "8.23.0", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "zod": "4.4.3" }, "devDependencies": { "@types/bun": "1.3.14", "@types/node": "^20.19.32", + "@types/pg": "8.21.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", 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/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..ea92431 --- /dev/null +++ b/apps/web/src/app/api/v1/hooks/[hook-id]/rotate-ingress-secret/route.ts @@ -0,0 +1,18 @@ +import { authenticateAccount } from "@/lib/authenticated-account"; +import { createRotateIngressSecretHandler } from "@/lib/hooks-api"; +import { hookStore } from "@/lib/server-database"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const rotate = createRotateIngressSecretHandler({ + authenticate: authenticateAccount, + rotateIngressSecret: (input) => hookStore.rotateIngressSecret(input), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ "hook-id": string }> }, +) { + return rotate(request, (await params)["hook-id"]); +} 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..17d982c --- /dev/null +++ b/apps/web/src/app/api/v1/hooks/route.ts @@ -0,0 +1,14 @@ +import { authenticateAccount } from "@/lib/authenticated-account"; +import { createHooksCollectionHandlers } from "@/lib/hooks-api"; +import { hookStore } from "@/lib/server-database"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const handlers = createHooksCollectionHandlers({ + authenticate: authenticateAccount, + createHook: (input) => hookStore.createHook(input), + listHooks: (input) => hookStore.listHooks(input), +}); + +export const { GET, POST } = handlers; 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/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..7d22b13 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,71 @@ +import { createDrizzleDatabase, schema } from "@hooky/database"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { nextCookies } from "better-auth/next-js"; + +import { database } from "./server-database"; + +const fallbackDevelopmentSecret = + "hooky-development-only-secret-change-before-deploying"; + +if (process.env.VERCEL && !process.env.BETTER_AUTH_SECRET) { + throw new Error("BETTER_AUTH_SECRET is required on Vercel"); +} + +if (process.env.VERCEL && !process.env.BETTER_AUTH_URL) { + throw new Error("BETTER_AUTH_URL is required on Vercel"); +} + +const configuredOrigin = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; + +export function createHookyAuth({ + database, + baseURL, + secret, + secureCookies, +}: { + database: ReturnType; + 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.ts b/apps/web/src/lib/authenticated-account.ts new file mode 100644 index 0000000..dbf40d7 --- /dev/null +++ b/apps/web/src/lib/authenticated-account.ts @@ -0,0 +1,14 @@ +import { auth } from "./auth"; +import { accountStore } from "./server-database"; + +export async function authenticateAccount(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return null; + } + + return accountStore.ensurePersonalAccount({ + userId: session.user.id, + name: session.user.name, + }); +} 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..77442c9 --- /dev/null +++ b/apps/web/src/lib/hooks-api.ts @@ -0,0 +1,123 @@ +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}`, 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..2a98cc7 --- /dev/null +++ b/apps/web/src/lib/ingress-handler.test.ts @@ -0,0 +1,118 @@ +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(await response.json()).toEqual({ + deliveryId: "delivery-one", + eventId: "event-one", + }); + expect(calls).toEqual([ + "resolve:hk_token", + "record:start", + "record:committed", + ]); + }); + + 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..fcaabac --- /dev/null +++ b/apps/web/src/lib/ingress-handler.ts @@ -0,0 +1,88 @@ +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, +}: { + 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; +}) { + return async function handleIngress( + request: Request, + { token, path }: { token: string; path: string[] }, + ) { + const contentLength = Number(request.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { + return Response.json({ error: "Payload too large" }, { status: 413 }); + } + + const resolved = await resolveIngressToken(token); + if (!resolved) { + return Response.json( + { error: "Webhook endpoint not found" }, + { status: 404 }, + ); + } + + const body = Buffer.from(await request.arrayBuffer()); + if (body.byteLength > maxBodyBytes) { + return Response.json({ error: "Payload too large" }, { status: 413 }); + } + + 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(), + }); + + return Response.json(recorded, { + status: 202, + headers: { "cache-control": "no-store" }, + }); + } catch { + return Response.json( + { error: "Webhook could not be durably accepted" }, + { status: 503 }, + ); + } + }; +} diff --git a/apps/web/src/lib/server-database.ts b/apps/web/src/lib/server-database.ts new file mode 100644 index 0000000..ecec305 --- /dev/null +++ b/apps/web/src/lib/server-database.ts @@ -0,0 +1,28 @@ +import { + AccountStore, + createDatabasePool, + createDrizzleDatabase, + DeliveryStore, + HookStore, +} from "@hooky/database"; + +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 }); + +if (process.env.NODE_ENV !== "production") { + globalDatabase.hookyPool = databasePool; +} + +export const database = createDrizzleDatabase(databasePool); +export const accountStore = new AccountStore(databasePool); +export const hookStore = new HookStore(databasePool); +export const deliveryStore = new DeliveryStore(databasePool); diff --git a/bun.lock b/bun.lock index 849ab2d..66646e0 100644 --- a/bun.lock +++ b/bun.lock @@ -15,13 +15,19 @@ "name": "@hooky/web", "version": "0.0.0", "dependencies": { + "@hooky/database": "workspace:*", + "better-auth": "1.6.27", + "drizzle-orm": "0.45.2", "next": "16.3.0", + "pg": "8.23.0", "react": "19.2.8", "react-dom": "19.2.8", + "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", @@ -83,6 +89,24 @@ "@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=="], @@ -267,6 +291,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=="], @@ -275,10 +303,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=="], @@ -417,6 +449,10 @@ "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=="], @@ -469,6 +505,8 @@ "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=="], @@ -677,6 +715,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=="], @@ -695,6 +735,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=="], @@ -723,6 +765,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=="], @@ -829,6 +873,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=="], @@ -841,6 +887,8 @@ "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=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -977,6 +1025,8 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "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=="], 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/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/_journal.json b/packages/database/migrations/meta/_journal.json index 3df057a..812de0f 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1786490390015, "tag": "0000_colossal_cardiac", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1786491433034, + "tag": "0001_friendly_wendell_rand", + "breakpoints": true } ] } diff --git a/packages/database/package.json b/packages/database/package.json index b1cf2d8..14b6aa5 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./schema": "./src/schema.ts" + "./schema": "./src/schema.ts", + "./testing": "./src/testing/test-database.ts" }, "scripts": { "build": "tsc -p tsconfig.build.json", 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/database.ts b/packages/database/src/database.ts index d0d30f6..df5277a 100644 --- a/packages/database/src/database.ts +++ b/packages/database/src/database.ts @@ -1,5 +1,8 @@ +import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; +import * as schema from "./schema"; + export function createDatabasePool({ connectionString, maxConnections = 5, @@ -12,3 +15,7 @@ export function createDatabasePool({ max: maxConnections, }); } + +export function createDrizzleDatabase(pool: Pool) { + return drizzle(pool, { schema }); +} 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 index c87cd8a..ce8a263 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,7 +1,9 @@ -export { createDatabasePool } from "./database"; +export { createDatabasePool, createDrizzleDatabase } from "./database"; +export { AccountStore, type AccountMembership } from "./account-store"; export { DeliveryStore, HookUnavailableError, type ClaimedDelivery, } from "./delivery-store"; +export { HookStore } from "./hook-store"; export * as schema from "./schema"; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index ea59f62..15127a3 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -1,4 +1,5 @@ import { + boolean, customType, foreignKey, index, @@ -6,6 +7,7 @@ import { jsonb, pgEnum, pgTable, + primaryKey, text, timestamp, unique, @@ -31,6 +33,74 @@ export const deliveryAttemptOutcome = pgEnum("delivery_attempt_outcome", [ "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(), @@ -40,6 +110,26 @@ export const accounts = pgTable("accounts", { .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 hooks = pgTable( "hooks", { @@ -62,6 +152,29 @@ export const hooks = pgTable( ], ); +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", { diff --git a/packages/database/src/testing/test-database.ts b/packages/database/src/testing/test-database.ts index da6d53a..e9ef9a1 100644 --- a/packages/database/src/testing/test-database.ts +++ b/packages/database/src/testing/test-database.ts @@ -109,19 +109,37 @@ export async function createTestDatabase() { 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 { pool, async reset() { - await pool.query("truncate table accounts restart identity cascade"); + 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 = crypto.randomUUID(); + const { accountId } = await seedAccount(); const hookId = crypto.randomUUID(); - - await pool.query(`insert into accounts (id, name) values ($1, $2)`, [ - accountId, - "Test account", - ]); await pool.query( `insert into hooks (id, account_id, name) values ($1, $2, $3)`, [hookId, accountId, "stripe-dev"],