diff --git a/apps/server/.env.example b/apps/server/.env.example index 2a19173..b20a620 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,4 +1,4 @@ -# Database (Neon — runtime until D1 cutover) +# Database DATABASE_URL= # D1 tooling (`drizzle-kit push` / `studio` against the Worker D1 binding) diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts index f20c442..4be8ef3 100644 --- a/apps/server/drizzle.config.ts +++ b/apps/server/drizzle.config.ts @@ -1,26 +1,25 @@ import { defineConfig } from "drizzle-kit"; +import { env } from "./src/db/env"; -// D1 tooling config. Application repositories use the Worker D1 binding (#109); -// better-auth remains on Neon until cutover (#110 / #112). -// `generate` needs only the sqlite dialect. `push`/`studio` use the D1 HTTP driver -// when Cloudflare credentials are set; otherwise a local sqlite file so the -// commands still work without a Cloudflare account. -const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; -const databaseId = process.env.CLOUDFLARE_DATABASE_ID; -const token = process.env.CLOUDFLARE_D1_TOKEN; - -export default defineConfig({ - schema: "./src/db/models/index.ts", - out: "./src/db/migrations", - dialect: "sqlite", - ...(accountId && databaseId && token +const dbConfig = + env.DB_TYPE === "remote" ? { driver: "d1-http" as const, - dbCredentials: { accountId, databaseId, token }, + dbCredentials: { + accountId: env.CLOUDFLARE_ACCOUNT_ID, + databaseId: env.CLOUDFLARE_DATABASE_ID, + token: env.CLOUDFLARE_D1_TOKEN, + }, } : { dbCredentials: { - url: process.env.D1_LOCAL_DB ?? "file:./.local/d1/cyrus.sqlite", + url: env.D1_LOCAL_DB, }, - }), + }; + +export default defineConfig({ + schema: "./src/db/models/index.ts", + out: "./src/db/migrations", + dialect: "sqlite", + ...dbConfig, }); diff --git a/apps/server/package.json b/apps/server/package.json index 5c53cd7..091277f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,9 +10,10 @@ "wrangler:types": "wrangler types --cwd src", "db:studio": "dotenvx run -- drizzle-kit studio", "db:migrate": "dotenvx run -- drizzle-kit migrate", - "auth:generate": "dotenvx run -- bun x @better-auth/cli generate -y --config src/auth/index.ts --output src/db/neon/schema.ts" + "auth:generate": "dotenvx run -- bun x @better-auth/cli generate -y --config src/auth/cli.ts --output src/db/models/auth.ts" }, "dependencies": { + "@better-auth/drizzle-adapter": "1.6.21", "@better-auth/expo": "catalog:auth", "@cyrus/connections": "workspace:*", "@cyrus/schemas": "workspace:*", @@ -23,6 +24,7 @@ "@soorya-u/better-auth-ws-ticket": "catalog:auth", "@t3-oss/env-core": "catalog:env", "better-auth": "catalog:auth", + "better-auth-cloudflare": "0.3.1", "better-result": "catalog:core", "drizzle-orm": "catalog:database", "evlog": "catalog:observability", diff --git a/apps/server/src/auth/cli.ts b/apps/server/src/auth/cli.ts new file mode 100644 index 0000000..07408a3 --- /dev/null +++ b/apps/server/src/auth/cli.ts @@ -0,0 +1,14 @@ +import { betterAuth } from "better-auth"; +import { withCloudflare } from "better-auth-cloudflare"; +import { authOptions } from "./options"; + +// This if for CLI schema generation (`auth:generate`) +export const auth = betterAuth({ + ...withCloudflare( + { + autoDetectIpAddress: false, + geolocationTracking: false, + }, + authOptions + ), +}); diff --git a/apps/server/src/auth/index.test.ts b/apps/server/src/auth/index.test.ts new file mode 100644 index 0000000..70dc45a --- /dev/null +++ b/apps/server/src/auth/index.test.ts @@ -0,0 +1,154 @@ +import { exports } from "cloudflare:workers"; +import { describe, expect, test } from "vitest"; + +const worker = exports.default; +const ORIGIN = "https://cyrus.soorya-u.dev"; +const CLIENT_ID = "cyrusd"; +const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const SESSION_COOKIE_PATTERN = + /(?:__Secure-)?better-auth\.session_token=([^;]+)/; + +function authHeaders( + extra: Record = {} +): Record { + return { + origin: ORIGIN, + referer: `${ORIGIN}/`, + ...extra, + }; +} + +function sessionCookieFromResponse(response: Response): string { + const setCookies = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie() + : [response.headers.get("set-cookie")].filter((value): value is string => + Boolean(value) + ); + + for (const header of setCookies) { + const match = header.match(SESSION_COOKIE_PATTERN); + if (match?.[0] && match[1]) { + // Preserve __Secure- prefix when present — better-auth sets it on HTTPS. + const nameAndValue = match[0]; + return nameAndValue; + } + } + + throw new Error( + `Missing session cookie. set-cookie headers: ${JSON.stringify(setCookies)}` + ); +} + +async function signUpAndSignIn(email: string, password: string) { + const signUp = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/sign-up/email", + { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ email, name: "D1 Auth User", password }), + } + ); + expect(signUp.ok || signUp.status === 422).toBe(true); + + const signIn = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/sign-in/email", + { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ email, password }), + } + ); + expect(signIn.ok).toBe(true); + + const body = (await signIn.json()) as { user?: { id: string } }; + const userId = body.user?.id; + expect(userId).toBeTruthy(); + if (!userId) { + throw new Error("sign-in response missing user id"); + } + + const sessionCookie = sessionCookieFromResponse(signIn); + + const sessionCheck = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/get-session", + { headers: authHeaders({ cookie: sessionCookie }) } + ); + const sessionBody = (await sessionCheck.json()) as { + user?: { id: string }; + } | null; + expect(sessionBody?.user?.id).toBe(userId); + + return { + sessionCookie, + userId, + }; +} + +describe("device authorization against D1", () => { + test("completes request → approve → token against D1-backed auth data", async () => { + const email = `d1-auth-${crypto.randomUUID()}@cyrus.test`; + const password = "d1-auth-test-password-32chars-min"; + const session = await signUpAndSignIn(email, password); + + const codeResponse = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/device/code", + { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ + client_id: CLIENT_ID, + scope: "openid profile email", + }), + } + ); + expect(codeResponse.ok).toBe(true); + + const codeBody = (await codeResponse.json()) as { + device_code: string; + user_code: string; + }; + expect(codeBody.device_code).toBeTruthy(); + expect(codeBody.user_code).toBeTruthy(); + + const formattedUserCode = codeBody.user_code.replace(/-/g, ""); + + const claim = await worker.fetch( + `https://cyrus.soorya-u.dev/api/auth/device?user_code=${encodeURIComponent(formattedUserCode)}`, + { headers: authHeaders({ cookie: session.sessionCookie }) } + ); + expect(claim.ok).toBe(true); + + const approve = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/device/approve", + { + method: "POST", + headers: authHeaders({ + "content-type": "application/json", + cookie: session.sessionCookie, + }), + body: JSON.stringify({ userCode: formattedUserCode }), + } + ); + expect(approve.ok).toBe(true); + + const tokenResponse = await worker.fetch( + "https://cyrus.soorya-u.dev/api/auth/device/token", + { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ + grant_type: GRANT_TYPE, + device_code: codeBody.device_code, + client_id: CLIENT_ID, + }), + } + ); + expect(tokenResponse.ok).toBe(true); + + const tokenBody = (await tokenResponse.json()) as { + access_token?: string; + }; + expect(tokenBody.access_token).toBeTruthy(); + }); +}); diff --git a/apps/server/src/auth/index.ts b/apps/server/src/auth/index.ts index f20561d..63e72af 100644 --- a/apps/server/src/auth/index.ts +++ b/apps/server/src/auth/index.ts @@ -1,63 +1,23 @@ -import { expo } from "@better-auth/expo"; -import { betterAuthDesktop } from "@soorya-u/better-auth-desktop/server"; -import { wsTicketPlugin } from "@soorya-u/better-auth-ws-ticket/server"; +import { env } from "cloudflare:workers"; import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins"; -import { log } from "evlog"; -import { env } from "../config/env"; -// Neon path (expand): keep Postgres until auth D1 cutover (#110 / #112). -import { db } from "../db/neon"; -// biome-ignore lint/performance/noNamespaceImport: drizzle adapter requires schema as namespace -import * as schema from "../db/neon/schema"; +import { withCloudflare } from "better-auth-cloudflare"; +import { drizzle } from "drizzle-orm/d1"; +import { models as schema } from "../db/models"; +import { authOptions } from "./options"; -const emailAndPassword = - env.NODE_ENV === "production" - ? {} - : { - emailAndPassword: { - enabled: true, - autoSignIn: true, - }, - }; +const db = drizzle(env.DB); export const auth = betterAuth({ - appName: "Cyrus", - basePath: "/api/auth", - database: drizzleAdapter(db, { provider: "pg", schema }), - ...emailAndPassword, - trustedOrigins: [...env.ALLOWED_ORIGINS, env.PRODUCTION_URL], - socialProviders: { - github: { - clientId: env.OAUTH_GITHUB_CLIENT_ID, - clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET, + ...withCloudflare( + { + autoDetectIpAddress: true, + geolocationTracking: false, + cf: {}, + d1: { + db: db as never, + options: { schema }, + }, }, - }, - secret: env.BETTER_AUTH_SECRET, - baseURL: env.WEB_APP_URL, - advanced: { - defaultCookieAttributes: { - sameSite: "lax", - httpOnly: true, - secure: env.NODE_ENV === "production", - }, - }, - logger: { - log: (level, message, ...args) => log[level]({ message, ...args }), - level: env.LOG_LEVEL, - }, - plugins: [ - expo(), - betterAuthDesktop({ - clientID: "cyrus-desktop", - webCallbackUrl: `${env.WEB_APP_URL}/auth/callback`, - }), - oAuthProxy({ - productionURL: env.PRODUCTION_URL, - secret: env.OAUTH_PROXY_SECRET, - }), - deviceAuthorization({ verificationUri: `${env.WEB_APP_URL}/auth/device` }), - bearer(), - wsTicketPlugin(), - ], + authOptions + ), }); diff --git a/apps/server/src/auth/options.ts b/apps/server/src/auth/options.ts new file mode 100644 index 0000000..79cb919 --- /dev/null +++ b/apps/server/src/auth/options.ts @@ -0,0 +1,61 @@ +import { expo } from "@better-auth/expo"; +import { betterAuthDesktop } from "@soorya-u/better-auth-desktop/server"; +import { wsTicketPlugin } from "@soorya-u/better-auth-ws-ticket/server"; +import type { BetterAuthOptions } from "better-auth"; +import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins"; +import { log } from "evlog"; +import { env } from "../config/env"; + +const emailAndPassword = + env.NODE_ENV === "production" + ? {} + : { + emailAndPassword: { + enabled: true, + autoSignIn: true, + }, + }; + +export const authOptions = { + appName: "Cyrus", + basePath: "/api/auth", + ...emailAndPassword, + trustedOrigins: [...env.ALLOWED_ORIGINS, env.PRODUCTION_URL], + socialProviders: { + github: { + clientId: env.OAUTH_GITHUB_CLIENT_ID, + clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET, + }, + }, + secret: env.BETTER_AUTH_SECRET, + baseURL: env.WEB_APP_URL, + advanced: { + defaultCookieAttributes: { + sameSite: "lax" as const, + httpOnly: true, + secure: env.NODE_ENV === "production", + }, + }, + logger: { + log: ( + level: "debug" | "info" | "warn" | "error", + message: string, + ...args: unknown[] + ) => log[level]({ message, args }), + level: env.LOG_LEVEL, + }, + plugins: [ + expo(), + betterAuthDesktop({ + clientID: "cyrus-desktop", + webCallbackUrl: `${env.WEB_APP_URL}/auth/callback`, + }), + oAuthProxy({ + productionURL: env.PRODUCTION_URL, + secret: env.OAUTH_PROXY_SECRET, + }), + deviceAuthorization({ verificationUri: `${env.WEB_APP_URL}/auth/device` }), + bearer(), + wsTicketPlugin(), + ], +} satisfies BetterAuthOptions; diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts index 549b06c..d05859b 100644 --- a/apps/server/src/config/env.ts +++ b/apps/server/src/config/env.ts @@ -1,5 +1,6 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; +import { env as dbEnv } from "../db/env"; export const env = createEnv({ server: { @@ -26,6 +27,7 @@ export const env = createEnv({ : [] ), }, + extends: [dbEnv], runtimeEnv: process.env, emptyStringAsUndefined: true, }); diff --git a/apps/server/src/db/env.ts b/apps/server/src/db/env.ts new file mode 100644 index 0000000..f55bf19 --- /dev/null +++ b/apps/server/src/db/env.ts @@ -0,0 +1,42 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + CLOUDFLARE_ACCOUNT_ID: z.string().optional(), + CLOUDFLARE_DATABASE_ID: z.string().optional(), + CLOUDFLARE_D1_TOKEN: z.string().optional(), + D1_LOCAL_DB: z.string().default("file:./.local/d1/cyrus.sqlite"), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, + createFinalSchema: (shape) => + z.object(shape).transform((raw, ctx) => { + const accountId = raw.CLOUDFLARE_ACCOUNT_ID; + const databaseId = raw.CLOUDFLARE_DATABASE_ID; + const token = raw.CLOUDFLARE_D1_TOKEN; + + if (accountId && databaseId && token) { + return { + DB_TYPE: "remote" as const, + CLOUDFLARE_ACCOUNT_ID: accountId, + CLOUDFLARE_DATABASE_ID: databaseId, + CLOUDFLARE_D1_TOKEN: token, + }; + } + + if (accountId || databaseId || token) { + ctx.addIssue({ + code: "custom", + message: + "Set all of CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_DATABASE_ID, and CLOUDFLARE_D1_TOKEN for remote D1, or none of them to use D1_LOCAL_DB", + }); + return z.NEVER; + } + + return { + DB_TYPE: "local" as const, + D1_LOCAL_DB: raw.D1_LOCAL_DB, + }; + }), +}); diff --git a/apps/server/src/db/migrations/apply.ts b/apps/server/src/db/migrations/apply.ts new file mode 100644 index 0000000..ff7a515 --- /dev/null +++ b/apps/server/src/db/migrations/apply.ts @@ -0,0 +1,46 @@ +import { applyD1Migrations } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import type { D1Migration } from "@cloudflare/vitest-pool-workers"; + +const MIGRATION_PATH = /^\.\/([^/]+)\/migration\.sql$/; + +function readDrizzleD1Migrations(): D1Migration[] { + const modules = import.meta.glob("./*/migration.sql", { + query: "?raw", + import: "default", + eager: true, + }); + + return Object.entries(modules) + .flatMap(([modulePath, sql]) => { + const name = MIGRATION_PATH.exec(modulePath)?.[1]; + if (!name) return []; + + return [ + { + name, + queries: sql + .split("--> statement-breakpoint") + .map((query) => query.trim()) + .filter(Boolean), + }, + ]; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +await applyD1Migrations(env.DB, readDrizzleD1Migrations()); + +declare global { + // biome-ignore lint/style/useConsistentTypeDefinitions: must interface-merge ImportMeta + interface ImportMeta { + glob( + pattern: string, + options: { + query: string; + import: string; + eager: true; + } + ): Record; + } +} diff --git a/apps/server/src/db/models/index.ts b/apps/server/src/db/models/index.ts index 9920431..2c68cad 100644 --- a/apps/server/src/db/models/index.ts +++ b/apps/server/src/db/models/index.ts @@ -1,5 +1,4 @@ -// biome-ignore lint/performance/noBarrelFile: Drizzle ORM schema re-exports are intentional -export { +import { account, accountRelations, deviceCode, @@ -9,3 +8,14 @@ export { userRelations, verification, } from "./auth"; + +export const models = { + account, + accountRelations, + deviceCode, + session, + sessionRelations, + user, + userRelations, + verification, +}; diff --git a/apps/server/src/db/neon/index.ts b/apps/server/src/db/neon/index.ts deleted file mode 100644 index b19c3d0..0000000 --- a/apps/server/src/db/neon/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { drizzle } from "drizzle-orm/neon-http"; -import { log } from "evlog"; -import { env } from "../../config/env"; - -// Neon client kept for better-auth until cutover (#110 / #112). -export const db = drizzle(env.DATABASE_URL, { - logger: { - logQuery: (query, params) => - log.debug({ - action: "db-query", - query, - params, - }), - }, -}); diff --git a/apps/server/src/db/neon/schema.ts b/apps/server/src/db/neon/schema.ts deleted file mode 100644 index 7b6fed9..0000000 --- a/apps/server/src/db/neon/schema.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Neon/Postgres schema kept for better-auth until cutover (#110 / #112). -// Canonical schema is models/auth.ts (SQLite/D1). -import { relations } from "drizzle-orm/_relations"; -import { - boolean, - index, - integer, - pgTable, - text, - timestamp, -} from "drizzle-orm/pg-core"; - -export const user = pgTable("user", { - 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() - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), -}); - -export const session = pgTable( - "session", - { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - }, - (table) => [index("session_userId_idx").on(table.userId)] -); - -export const account = pgTable( - "account", - { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.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") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - }, - (table) => [index("account_userId_idx").on(table.userId)] -); - -export const verification = pgTable( - "verification", - { - 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() - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - }, - (table) => [index("verification_identifier_idx").on(table.identifier)] -); - -export const deviceCode = pgTable("device_code", { - id: text("id").primaryKey(), - deviceCode: text("device_code").notNull(), - userCode: text("user_code").notNull(), - userId: text("user_id"), - expiresAt: timestamp("expires_at").notNull(), - status: text("status").notNull(), - lastPolledAt: timestamp("last_polled_at"), - pollingInterval: integer("polling_interval"), - clientId: text("client_id"), - scope: text("scope"), -}); - -export const userRelations = relations(user, ({ many }) => ({ - sessions: many(session), - accounts: many(account), -})); - -export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { - fields: [session.userId], - references: [user.id], - }), -})); - -export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { - fields: [account.userId], - references: [user.id], - }), -})); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 638d966..b2ebdf5 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "strictNullChecks": true + "strictNullChecks": true, + "types": ["@cloudflare/vitest-pool-workers/types"] }, "include": ["src"] } diff --git a/bun.lock b/bun.lock index 496a003..9d31e02 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,7 @@ "apps/server": { "name": "@cyrus/server", "dependencies": { + "@better-auth/drizzle-adapter": "1.6.21", "@better-auth/expo": "catalog:auth", "@cyrus/connections": "workspace:*", "@cyrus/schemas": "workspace:*", @@ -143,6 +144,7 @@ "@soorya-u/better-auth-ws-ticket": "catalog:auth", "@t3-oss/env-core": "catalog:env", "better-auth": "catalog:auth", + "better-auth-cloudflare": "0.3.1", "better-result": "catalog:core", "drizzle-orm": "catalog:database", "evlog": "catalog:observability", @@ -446,10 +448,22 @@ "@soorya-u/better-auth-ws-ticket": "v0.1.0", "better-auth": "1.6.21", }, + "core": { + "@types/bun": "^1.3.4", + "better-result": "^2.9.2", + "typescript": "^7", + }, "database": { "drizzle-kit": "1.0.0-rc.4", "drizzle-orm": "1.0.0-rc.4", }, + "env": { + "@dotenvx/dotenvx": "^1.71.3", + "@t3-oss/env-core": "^0.13.1", + }, + "observability": { + "evlog": "^2.19.2", + }, "react": { "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", @@ -467,18 +481,6 @@ "jsdom": "^29.1.1", "vitest": "^4.1.10", }, - "env": { - "@dotenvx/dotenvx": "^1.71.3", - "@t3-oss/env-core": "^0.13.1", - }, - "observability": { - "evlog": "^2.19.2", - }, - "core": { - "@types/bun": "^1.3.4", - "better-result": "^2.9.2", - "typescript": "^7", - }, }, "packages": { "@acp-kit/core": ["@acp-kit/core@0.10.2", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.18.0" } }, "sha512-+xYl9Fb42lwsAkjA1E2EQcTWOFYEpMU1UrehAXXwRy5zchHV6616KiMuZzh3CfossQNwKVX/vcaYHokxx2qgMQ=="], @@ -1937,6 +1939,8 @@ "better-auth": ["better-auth@1.6.21", "", { "dependencies": { "@better-auth/core": "1.6.21", "@better-auth/drizzle-adapter": "1.6.21", "@better-auth/kysely-adapter": "1.6.21", "@better-auth/memory-adapter": "1.6.21", "@better-auth/mongo-adapter": "1.6.21", "@better-auth/prisma-adapter": "1.6.21", "@better-auth/telemetry": "1.6.21", "@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.3.7", "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-qAX4pSjjl4pzg22vsunafm32zbIhn5K+blE5rz/xqevt7tahSLwPH73FPGmzEJ1xkX4nNNZ2THijNn675X7xAw=="], + "better-auth-cloudflare": ["better-auth-cloudflare@0.3.1", "", { "dependencies": { "drizzle-orm": "^0.45.0", "mime": "^4.1.0", "zod": "^4.3.0" }, "peerDependencies": { "@better-auth/drizzle-adapter": "^1.5.0", "@cloudflare/workers-types": ">=4", "better-auth": "^1.5.0" } }, "sha512-kvGYe1++fH4ODkb2o/WNkGWQtPYuWXjNPaKDAvPz8ChbaROW9TmZY0AIan9K52p+p+Pe53ZnQ7kKOWWZ2x0KrQ=="], + "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], "better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="], @@ -2817,7 +2821,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -3619,8 +3623,6 @@ "@better-auth/api-key/better-call": ["better-call@1.3.6", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA=="], - "@better-auth/drizzle-adapter/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=="], - "@better-auth/passkey/better-call": ["better-call@1.3.6", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA=="], "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -3725,7 +3727,7 @@ "better-auth/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=="], - "better-auth/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=="], + "better-auth-cloudflare/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=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -3943,6 +3945,8 @@ "@expo/cli/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "@expo/cli/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], @@ -4251,6 +4255,8 @@ "@react-native/dev-middleware/serve-static/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "@react-native/dev-middleware/serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], diff --git a/knip.json b/knip.json index 238c524..7905abf 100644 --- a/knip.json +++ b/knip.json @@ -8,7 +8,8 @@ }, "workspaces": { ".": { - "entry": ["scripts/**/*.{ts,sh}", "wrangler.json"] + "entry": ["scripts/**/*.{ts,sh}", "wrangler.json"], + "ignoreUnresolved": ["./src/db/migrations/apply.ts"] }, "apps/cli": { "entry": ["src/**/*.test.ts", "__tests__/**/*.ts"] @@ -22,7 +23,12 @@ "ignoreDependencies": ["expo-updates", "expo-system-ui"] }, "apps/server": { - "entry": ["src/index.ts", "src/cloudflare/**/*.test.ts"], + "entry": [ + "src/index.ts", + "src/**/*.test.ts", + "src/db/migrations/apply.ts", + "src/auth/cli.ts" + ], "ignoreDependencies": ["cloudflare"] }, "apps/web": { diff --git a/vitest.config.ts b/vitest.config.ts index 768abbb..37042c8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ wrangler: { configPath: packageRoot("wrangler.json") }, miniflare: { bindings: { - ALLOWED_ORIGINS: "https://example.com", + ALLOWED_ORIGINS: "https://cyrus.soorya-u.dev", BETTER_AUTH_SECRET: "test-secret-that-is-at-least-thirty-two-characters", DATABASE_URL: "postgresql://test:test@localhost:5432/test", @@ -39,8 +39,8 @@ export default defineConfig({ OAUTH_GITHUB_CLIENT_ID: "test-client-id", OAUTH_GITHUB_CLIENT_SECRET: "test-client-secret", OAUTH_PROXY_SECRET: "test-oauth-proxy-secret", - PRODUCTION_URL: "https://example.com", - WEB_APP_URL: "https://example.com", + PRODUCTION_URL: "https://cyrus.soorya-u.dev", + WEB_APP_URL: "https://cyrus.soorya-u.dev", }, }, }), @@ -48,6 +48,7 @@ export default defineConfig({ test: { name: "@cyrus/server", include: ["src/**/*.test.ts"], + setupFiles: ["./src/db/migrations/apply.ts"], testTimeout: 15_000, }, }, @@ -78,7 +79,6 @@ export default defineConfig({ { root: packageRoot("shared/database"), test: { - // Outside the `@cyrus/*` unit glob so root test:unit / test:unit:ui skip it. name: "database-integration", environment: "node", include: ["__tests__/integration/**/*.test.ts"], @@ -121,19 +121,14 @@ export default defineConfig({ { root: packageRoot("tests/e2e"), test: { - // Outside the `@cyrus/*` unit glob so root test:unit / test:unit:ui skip it. name: "e2e", environment: "node", include: ["scenarios/**/*.test.ts"], testTimeout: 180_000, - // Scenarios spin up real dev servers on fixed ports (8787, 5173); - // running files in parallel would clash on those ports. fileParallelism: false, }, }, ], - coverage: { - provider: "istanbul", - }, + coverage: { provider: "istanbul" }, }, });