diff --git a/README.md b/README.md index 434faf2..70dc57d 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,22 @@ scripts/verify_prd_site_rendering.sh scripts/verify_windows_app_manifest.sh cargo run -p ely_app ``` + +## Cloudflare Auth Configuration + +`/api/auth/*` is served by Better Auth in the Cloudflare Worker. Local `wrangler dev` +uses `ELY_AUTH_BASE_URL` from `cloudflare/wrangler.toml`; deployed environments should +set the matching public Worker origin. + +Use Wrangler secrets or an untracked `cloudflare/.dev.vars` file for the remaining auth +bindings: + +```bash +wrangler secret put ELY_AUTH_SECRET +wrangler secret put ELY_AUTH_GOOGLE_CLIENT_ID +wrangler secret put ELY_AUTH_GOOGLE_CLIENT_SECRET +wrangler secret put ELY_AUTH_GITHUB_CLIENT_ID +wrangler secret put ELY_AUTH_GITHUB_CLIENT_SECRET +wrangler secret put ELY_AUTH_EMAIL_OTP_ENDPOINT +wrangler secret put ELY_AUTH_EMAIL_OTP_TOKEN +``` diff --git a/cloudflare/package-lock.json b/cloudflare/package-lock.json index 744012b..29d593c 100644 --- a/cloudflare/package-lock.json +++ b/cloudflare/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "@elydora/cloudflare-worker", "version": "0.1.0", + "dependencies": { + "better-auth": "^1.6.9" + }, "devDependencies": { "@cloudflare/workers-types": "4.20260508.1", "@types/node": "24.12.3", @@ -14,6 +17,138 @@ "wrangler": "4.90.0" } }, + "node_modules/@better-auth/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.9.tgz", + "integrity": "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.9.tgz", + "integrity": "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.9", + "@better-auth/utils": "0.4.0", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.9.tgz", + "integrity": "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.9", + "@better-auth/utils": "0.4.0", + "kysely": "^0.28.14" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.9.tgz", + "integrity": "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.9", + "@better-auth/utils": "0.4.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.9.tgz", + "integrity": "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.9", + "@better-auth/utils": "0.4.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.9.tgz", + "integrity": "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.9", + "@better-auth/utils": "0.4.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.9.tgz", + "integrity": "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.9", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -129,7 +264,7 @@ "version": "4.20260508.1", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260508.1.tgz", "integrity": "sha512-0KNR+UkrYJYmtyQ5tOjUT/wt/U34FuE4Y8FLSbPMwFrGQQpmvR9wKghwRkL4d28y5t9jI9cvGqeAoo/cerTnCQ==", - "dev": true, + "devOptional": true, "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -1164,6 +1299,39 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1213,6 +1381,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.3.tgz", @@ -1223,6 +1397,131 @@ "undici-types": "~7.16.0" } }, + "node_modules/better-auth": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.9.tgz", + "integrity": "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.9", + "@better-auth/drizzle-adapter": "1.6.9", + "@better-auth/kysely-adapter": "1.6.9", + "@better-auth/memory-adapter": "1.6.9", + "@better-auth/mongo-adapter": "1.6.9", + "@better-auth/prisma-adapter": "1.6.9", + "@better-auth/telemetry": "1.6.9", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.5", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.14", + "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" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", + "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1244,6 +1543,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1321,6 +1626,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1331,6 +1645,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/miniflare": { "version": "4.20260507.1", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260507.1.tgz", @@ -1352,6 +1675,21 @@ "node": ">=22.0.0" } }, + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -1366,6 +1704,12 @@ "dev": true, "license": "MIT" }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1379,6 +1723,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -1588,6 +1938,15 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/cloudflare/package.json b/cloudflare/package.json index 98ab4bc..cc4fa70 100644 --- a/cloudflare/package.json +++ b/cloudflare/package.json @@ -12,5 +12,8 @@ "@types/node": "24.12.3", "typescript": "6.0.3", "wrangler": "4.90.0" + }, + "dependencies": { + "better-auth": "^1.6.9" } } diff --git a/cloudflare/src/better_auth.ts b/cloudflare/src/better_auth.ts new file mode 100644 index 0000000..578b15e --- /dev/null +++ b/cloudflare/src/better_auth.ts @@ -0,0 +1,137 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { emailOTP } from "better-auth/plugins/email-otp"; + +import type { Env } from "./bindings.js"; +import { jsonResponse } from "./responses.js"; + +const APP_NAME = "ELY Browser"; +const AUTH_BASE_PATH = "/api/auth"; +const AUTH_CALLBACK_URL = "ely://auth/callback"; +const OTP_REQUEST_TIMEOUT_MS = 10_000; + +type BetterAuthDatabase = NonNullable; +type BetterAuthSocialProviders = NonNullable; + +export async function handleBetterAuthRoute(request: Request, env: Env): Promise { + try { + return createElyAuth(env).handler(request); + } catch (error) { + if (error instanceof BetterAuthConfigError) { + return jsonResponse({ error: "auth_unconfigured" }, 500, { + "Cache-Control": "no-store", + }); + } + throw error; + } +} + +function createElyAuth(env: Env) { + return betterAuth({ + appName: APP_NAME, + basePath: AUTH_BASE_PATH, + baseURL: requiredBinding(env.ELY_AUTH_BASE_URL, "ELY_AUTH_BASE_URL"), + secret: requiredBinding(env.ELY_AUTH_SECRET, "ELY_AUTH_SECRET"), + database: env.ELY_DB as BetterAuthDatabase, + emailAndPassword: { + enabled: true, + minPasswordLength: 12, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + }, + socialProviders: socialProviders(env), + trustedOrigins: [requiredBinding(env.ELY_AUTH_BASE_URL, "ELY_AUTH_BASE_URL"), AUTH_CALLBACK_URL], + plugins: [ + emailOTP({ + expiresIn: 300, + allowedAttempts: 3, + storeOTP: "encrypted", + resendStrategy: "rotate", + sendVerificationOTP: (data) => sendVerificationOtp(env, data), + }), + ], + }); +} + +function socialProviders(env: Env): BetterAuthSocialProviders { + const providers: BetterAuthSocialProviders = {}; + const google = bindingPair(env.ELY_AUTH_GOOGLE_CLIENT_ID, env.ELY_AUTH_GOOGLE_CLIENT_SECRET); + if (google !== null) { + providers.google = { + clientId: google.clientId, + clientSecret: google.clientSecret, + }; + } + const github = bindingPair(env.ELY_AUTH_GITHUB_CLIENT_ID, env.ELY_AUTH_GITHUB_CLIENT_SECRET); + if (github !== null) { + providers.github = { + clientId: github.clientId, + clientSecret: github.clientSecret, + }; + } + return providers; +} + +function bindingPair( + clientId: string | undefined, + clientSecret: string | undefined, +): { clientId: string; clientSecret: string } | null { + if (!isPresent(clientId) || !isPresent(clientSecret)) { + return null; + } + return { clientId: clientId.trim(), clientSecret: clientSecret.trim() }; +} + +async function sendVerificationOtp( + env: Env, + data: { + email: string; + otp: string; + type: "sign-in" | "email-verification" | "forget-password" | "change-email"; + }, +): Promise { + const endpoint = requiredBinding(env.ELY_AUTH_EMAIL_OTP_ENDPOINT, "ELY_AUTH_EMAIL_OTP_ENDPOINT"); + const token = requiredBinding(env.ELY_AUTH_EMAIL_OTP_TOKEN, "ELY_AUTH_EMAIL_OTP_TOKEN"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OTP_REQUEST_TIMEOUT_MS); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + signal: controller.signal, + }); + if (!response.ok) { + throw new BetterAuthEmailDeliveryError(response.status); + } + } finally { + clearTimeout(timeout); + } +} + +function requiredBinding(value: string | undefined, name: string): string { + if (isPresent(value)) { + return value.trim(); + } + throw new BetterAuthConfigError(name); +} + +function isPresent(value: string | undefined): value is string { + return value !== undefined && value.trim() !== ""; +} + +class BetterAuthConfigError extends Error { + constructor(readonly binding: string) { + super(`missing auth binding: ${binding}`); + this.name = "BetterAuthConfigError"; + } +} + +class BetterAuthEmailDeliveryError extends Error { + constructor(readonly status: number) { + super(`auth otp delivery failed with status ${status}`); + this.name = "BetterAuthEmailDeliveryError"; + } +} diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 238720f..43e4416 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -52,4 +52,12 @@ export interface Env { ELY_RATE_LIMITER: ElyRateLimit; ELY_API_AUDIT: ElyAnalyticsDataset; ELY_ENVIRONMENT: string; + ELY_AUTH_BASE_URL: string; + ELY_AUTH_SECRET: string; + ELY_AUTH_GOOGLE_CLIENT_ID?: string; + ELY_AUTH_GOOGLE_CLIENT_SECRET?: string; + ELY_AUTH_GITHUB_CLIENT_ID?: string; + ELY_AUTH_GITHUB_CLIENT_SECRET?: string; + ELY_AUTH_EMAIL_OTP_ENDPOINT?: string; + ELY_AUTH_EMAIL_OTP_TOKEN?: string; } diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 60c255a..801bb10 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,5 +1,6 @@ import type { Env } from "./bindings.js"; import { withAuthenticatedApiControls, withPublicApiControls } from "./api_controls.js"; +import { handleBetterAuthRoute } from "./better_auth.js"; import { DevicePermissionError, DevicePersistenceError, @@ -42,6 +43,9 @@ export default { export async function handleRequest(request: Request, env: Env): Promise { const url = new URL(request.url); + if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) { + return handleBetterAuthRoute(request, env); + } if (url.pathname === "/api/devices") { return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => { try { diff --git a/cloudflare/src/runtime_type_shims.d.ts b/cloudflare/src/runtime_type_shims.d.ts new file mode 100644 index 0000000..02cd319 --- /dev/null +++ b/cloudflare/src/runtime_type_shims.d.ts @@ -0,0 +1,3 @@ +declare module "bun:sqlite" { + export class Database {} +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 8ef3adf..00354b4 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -244,6 +244,8 @@ function testEnv(options: TestEnvOptions = {}): Env { return { ELY_ENVIRONMENT: "local", + ELY_AUTH_BASE_URL: "https://elydora.test", + ELY_AUTH_SECRET: "test-auth-secret-for-api-controls", ELY_DB: testD1Database(), ELY_KV: { get(key: string): Promise { diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index 2f91946..1453553 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -41,6 +41,8 @@ export function testEnv(options: TestEnvOptions): Env { const values = new Map(options.kvEntries ?? []); return { ELY_ENVIRONMENT: "local", + ELY_AUTH_BASE_URL: "https://elydora.test", + ELY_AUTH_SECRET: "test-auth-secret-for-worker-routes", ELY_DB: options.d1 ?? testD1Database([]), ELY_KV: { get(key: string): Promise { diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index 1aaba4e..3f367b0 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -82,6 +82,17 @@ describe("worker routes", () => { assert.deepEqual(await response.json(), { error: "not_found" }); }); + it("routes Better Auth session requests under api auth", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/auth/get-session"), + testEnv(null), + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "application/json"); + assert.equal(await response.text(), "null"); + }); + it("returns public plugin catalog from KV", async () => { const response = await handleRequest( new Request("https://elydora.test/api/plugins"), @@ -341,6 +352,8 @@ function testEnv( return { ELY_ENVIRONMENT: "local", + ELY_AUTH_BASE_URL: "https://elydora.test", + ELY_AUTH_SECRET: "test-auth-secret-for-worker-routes", ELY_DB: testD1Database(), ELY_KV: { get(key: string): Promise { diff --git a/cloudflare/tsconfig.json b/cloudflare/tsconfig.json index 2aff336..4c6b576 100644 --- a/cloudflare/tsconfig.json +++ b/cloudflare/tsconfig.json @@ -4,12 +4,12 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], + "types": ["@cloudflare/workers-types", "node"], "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": false, + "skipLibCheck": true, "verbatimModuleSyntax": true, "rootDir": "." }, diff --git a/cloudflare/tsconfig.test.json b/cloudflare/tsconfig.test.json index 6d8e3ed..b836ea7 100644 --- a/cloudflare/tsconfig.test.json +++ b/cloudflare/tsconfig.test.json @@ -9,7 +9,7 @@ "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": false, + "skipLibCheck": true, "verbatimModuleSyntax": true, "rootDir": "." }, diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index 33ab615..9363ae6 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -1,6 +1,7 @@ name = "ely-browser-cloud" main = "src/index.ts" compatibility_date = "2026-05-08" +compatibility_flags = ["nodejs_als"] [[d1_databases]] binding = "ELY_DB" @@ -18,6 +19,7 @@ id = "5eff92ba31c94fcf83e1b6d5e79ce070" [vars] ELY_ENVIRONMENT = "local" +ELY_AUTH_BASE_URL = "http://localhost:8787" [[analytics_engine_datasets]] binding = "ELY_API_AUDIT"