diff --git a/.env.example b/.env.example index 68b824b..ce71a7e 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,10 @@ SITE_RUM= INTEGRATIONS_ENCRYPTION_KEY= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Cloudflare Turnstile. The secret alone turns on CAPTCHA for public forms; +# add the site key to also protect the sign-in page. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= AUTH_COOKIE_DOMAIN= # Optional documentation GitHub App. See /self-hosting/github-app in the docs. diff --git a/apps/api/.env.example b/apps/api/.env.example index 6f87bea..2c1036c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -133,6 +133,10 @@ CUSTOM_DOMAIN_TXT_RECORD_NAME=_frontlit-verify # honeypot-only even with a secret configured. CAPTCHA_PROVIDER= TURNSTILE_SECRET_KEY= +# Also set the site key to put Turnstile on the hosted sign-in page (it checks +# the request that emails a sign-in code). The widget's hostname in Cloudflare +# must include API_PUBLIC_URL's host; Cloudflare's testing keys skip that check. +TURNSTILE_SITE_KEY= # Shared HMAC secret for the ISR revalidation webhook apps/api pings on # publish/domain/settings changes (architecture.md ยง12.2). Must match diff --git a/apps/api/package.json b/apps/api/package.json index 9f29a3c..66ea544 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,10 +40,10 @@ "@aws-sdk/client-s3": "^3.1111.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/mcp-server-kit": "0.4.2", - "@codelitdev/oauth-server-kit": "0.4.2", - "@codelitdev/observability": "0.4.2", - "@codelitdev/platform": "0.4.2", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@frontlit/api-contract": "workspace:*", "@frontlit/page-builder": "workspace:*", "@frontlit/text-editor": "workspace:*", @@ -73,8 +73,8 @@ "zod-to-json-schema": "^3.25.2" }, "devDependencies": { - "@codelitdev/billing": "0.4.2", - "@codelitdev/platform-conformance": "0.4.2", + "@codelitdev/billing": "0.8.0", + "@codelitdev/platform-conformance": "0.8.0", "@electric-sql/pglite": "^0.2.17", "@modelcontextprotocol/client": "^2.2.0", "@types/cors": "^2.8.12", diff --git a/apps/api/src/auth/authz-integration.test.ts b/apps/api/src/auth/authz-integration.test.ts index 7471d53..05ee4ee 100644 --- a/apps/api/src/auth/authz-integration.test.ts +++ b/apps/api/src/auth/authz-integration.test.ts @@ -185,7 +185,6 @@ describe("authorization integration", () => { kind: "oauth", accountId: owner.id, email: owner.email, - claimedTeamId: team.id, scopes: ["teams:write"], audiences: ["http://localhost:4000"], }), diff --git a/apps/api/src/auth/better-auth.ts b/apps/api/src/auth/better-auth.ts index dda9a16..d1c59f9 100644 --- a/apps/api/src/auth/better-auth.ts +++ b/apps/api/src/auth/better-auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from "better-auth"; import { APIError, createAuthMiddleware } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { captcha } from "better-auth/plugins"; import { emailOTP } from "better-auth/plugins/email-otp"; import { jwt } from "better-auth/plugins/jwt"; import { oauthProvider } from "@better-auth/oauth-provider"; @@ -16,6 +17,12 @@ import { ensureFrontLitAccountForUser } from "../account/queries"; import { MCP_SCOPES_SUPPORTED } from "../mcp/policy"; import { fetchClientMetadataResource } from "./cimd-fetch"; import { AUTH_COOKIE_PREFIX } from "./cookie-prefix"; +import { + CAPTCHA_PROTECTED_ENDPOINTS, + TURNSTILE_TESTING_SECRET, + loginCaptchaConfig, + toHostedLoginCaptcha, +} from "./login-captcha"; export const webClientUrl = process.env.WEB_CLIENT || "http://localhost:3000"; const apiUrl = process.env.API_PUBLIC_URL || process.env.BETTER_AUTH_URL; @@ -65,6 +72,9 @@ export const mcpProtectedResourceMetadataUrl = `${authBaseUrl}/.well-known/oauth const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; +const loginCaptcha = loginCaptchaConfig(); +export const hostedLoginCaptcha = toHostedLoginCaptcha(loginCaptcha); + async function sendOtpEmail(email: string, otp: string) { try { await sendMail({ @@ -218,6 +228,20 @@ export const auth = betterAuth({ max: 3, }, }), + ...(loginCaptcha + ? [ + captcha({ + provider: "cloudflare-turnstile", + secretKey: loginCaptcha.secretKey, + endpoints: CAPTCHA_PROTECTED_ENDPOINTS, + // The widget only renders on the API's login page. Cloudflare's + // testing secrets always report example.com, so they skip this. + ...(TURNSTILE_TESTING_SECRET.test(loginCaptcha.secretKey) + ? {} + : { allowedHostnames: [new URL(authBaseUrl).hostname] }), + }), + ] + : []), jwt(), oauthProvider({ ...createOAuthProviderOptions({ diff --git a/apps/api/src/auth/login-captcha.test.ts b/apps/api/src/auth/login-captcha.test.ts new file mode 100644 index 0000000..7af6eb1 --- /dev/null +++ b/apps/api/src/auth/login-captcha.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { makeTestDb } from "../test/db"; +import { listenApp } from "../test/auth-app"; + +const siteKey = vi.hoisted(() => { + // Cloudflare's always-passes testing keys. Read when better-auth.ts loads. + process.env.TURNSTILE_SITE_KEY = "1x00000000000000000000AA"; + process.env.TURNSTILE_SECRET_KEY = "1x0000000000000000000000000000000AA"; + return process.env.TURNSTILE_SITE_KEY; +}); + +vi.mock("../db/client", async () => ({ db: await makeTestDb() })); + +import { + TURNSTILE_TESTING_SECRET, + loginCaptchaConfig, + toHostedLoginCaptcha, +} from "./login-captcha"; + +describe("loginCaptchaConfig", () => { + it("is on only when both keys are set, trimmed", () => { + expect(loginCaptchaConfig({})).toBeUndefined(); + expect(loginCaptchaConfig({ TURNSTILE_SITE_KEY: "site" })).toBeUndefined(); + // A secret alone is valid: it still enables CAPTCHA on public writes. + expect(loginCaptchaConfig({ TURNSTILE_SECRET_KEY: "secret" })).toBeUndefined(); + expect( + loginCaptchaConfig({ + TURNSTILE_SITE_KEY: " site ", + TURNSTILE_SECRET_KEY: "secret", + }), + ).toEqual({ siteKey: "site", secretKey: "secret" }); + }); + + it("only hands the site key to the login page", () => { + expect(toHostedLoginCaptcha(undefined)).toBeUndefined(); + expect(toHostedLoginCaptcha({ siteKey: "site", secretKey: "secret" })).toEqual({ + provider: "cloudflare-turnstile", + siteKey: "site", + }); + }); + + it("recognises Cloudflare's testing secrets", () => { + for (const secret of [ + "1x0000000000000000000000000000000AA", + "2x0000000000000000000000000000000AA", + "3x0000000000000000000000000000000AA", + ]) { + expect(TURNSTILE_TESTING_SECRET.test(secret)).toBe(true); + } + expect(TURNSTILE_TESTING_SECRET.test("0x4AAAAAAAreal")).toBe(false); + }); +}); + +describe("login with Turnstile configured", () => { + it("renders the widget on the hosted login page", async () => { + const listening = await listenApp(); + try { + const response = await fetch(`${listening.origin}/login`); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(`data-sitekey="${siteKey}"`); + expect(html).toContain("challenges.cloudflare.com/turnstile/v0/api.js"); + } finally { + await listening.close(); + } + }); + + it("refuses to email a sign-in code without a Turnstile token", async () => { + const listening = await listenApp(); + try { + const response = await fetch( + `${listening.origin}/api/auth/email-otp/send-verification-otp`, + { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ email: "someone@example.com", type: "sign-in" }), + }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "MISSING_RESPONSE", + }); + } finally { + await listening.close(); + } + }); +}); diff --git a/apps/api/src/auth/login-captcha.ts b/apps/api/src/auth/login-captcha.ts new file mode 100644 index 0000000..e49670c --- /dev/null +++ b/apps/api/src/auth/login-captcha.ts @@ -0,0 +1,35 @@ +import type { HostedLoginCaptcha } from "@codelitdev/oauth-server-kit/express"; + +/** The hosted login page sends the email-OTP request here. The code itself is + * only checked after the person proves they got the email, so this is the + * request bots abuse to send mail. */ +export const CAPTCHA_PROTECTED_ENDPOINTS = ["/email-otp/send-verification-otp"]; + +// https://developers.cloudflare.com/turnstile/troubleshooting/testing/ +export const TURNSTILE_TESTING_SECRET = /^[123]x0{31}AA$/; + +export type LoginCaptchaConfig = { + siteKey: string; + secretKey: string; +}; + +/** Cloudflare Turnstile on the hosted sign-in form. On only when both keys + * are set: the page sends the widget's token and Better Auth's `captcha` + * plugin verifies it, so one without the other would refuse every email + * sign-in. `TURNSTILE_SECRET_KEY` alone is valid โ€” it still turns on CAPTCHA + * for public writes (`services/captcha.ts`). */ +export function loginCaptchaConfig( + env: Record = process.env, +): LoginCaptchaConfig | undefined { + const siteKey = env.TURNSTILE_SITE_KEY?.trim(); + const secretKey = env.TURNSTILE_SECRET_KEY?.trim(); + return siteKey && secretKey ? { siteKey, secretKey } : undefined; +} + +export function toHostedLoginCaptcha( + config: LoginCaptchaConfig | undefined, +): HostedLoginCaptcha | undefined { + return config + ? { provider: "cloudflare-turnstile", siteKey: config.siteKey } + : undefined; +} diff --git a/apps/api/src/auth/oauth-pages.test.ts b/apps/api/src/auth/oauth-pages.test.ts index 38fcbb8..33a0de0 100644 --- a/apps/api/src/auth/oauth-pages.test.ts +++ b/apps/api/src/auth/oauth-pages.test.ts @@ -6,6 +6,10 @@ vi.mock("./better-auth", () => ({ webClientUrl: "http://localhost:3000", authBasePath: "/api/auth", hostedLoginMethods: [{ type: "email-otp" }], + hostedLoginCaptcha: { + provider: "cloudflare-turnstile", + siteKey: "0x4AAAAAAAtest", + }, })); vi.mock("better-auth/node", () => ({ fromNodeHeaders: (headers: unknown) => headers, @@ -63,6 +67,17 @@ describe("FrontLit OAuth pages", () => { expect(response.headers.get("x-frame-options")).toBe("DENY"); }); + it.each(["/login", "/oauth/login"])( + "renders the Turnstile widget on %s when it is configured", + async (path) => { + const response = await request(path); + expect(response.body).toContain( + 'class="cf-turnstile" data-sitekey="0x4AAAAAAAtest"', + ); + expect(response.body).toContain("var captcha=true"); + }, + ); + it("brands the hosted login with the FrontLit logo, favicon, and color", async () => { const response = await request("/login?redirect=http://localhost:3000/pages/foo"); expect(response.body).toContain('src="http://localhost:3000/icon.svg"'); diff --git a/apps/api/src/auth/oauth-pages.ts b/apps/api/src/auth/oauth-pages.ts index 23abda9..c4dc723 100644 --- a/apps/api/src/auth/oauth-pages.ts +++ b/apps/api/src/auth/oauth-pages.ts @@ -1,6 +1,11 @@ import { Router } from "express"; import { createOAuthPagesRouter } from "@codelitdev/oauth-server-kit/express"; -import { authBasePath, hostedLoginMethods, webClientUrl } from "./better-auth"; +import { + authBasePath, + hostedLoginCaptcha, + hostedLoginMethods, + webClientUrl, +} from "./better-auth"; const router = Router(); @@ -14,6 +19,7 @@ router.use( faviconUrl: new URL("/icon.svg", webClientUrl).toString(), primaryColor: "oklch(0.47 0.14 230)", loginMethods: hostedLoginMethods, + captcha: hostedLoginCaptcha, legacyHostOnlySessionCookieNames: process.env.AUTH_COOKIE_DOMAIN ? [ "__Secure-better-auth.session_token", diff --git a/apps/api/src/auth/session-freshness.test.ts b/apps/api/src/auth/session-freshness.test.ts index a97d79b..7faca24 100644 --- a/apps/api/src/auth/session-freshness.test.ts +++ b/apps/api/src/auth/session-freshness.test.ts @@ -180,7 +180,6 @@ describe("resolveAuth sessionAuthenticatedAt on the production path", () => { createdAt, updatedAt: createdAt, }), - getTeamMembership: async () => null, getSessionAuthenticatedAt, }, ); diff --git a/apps/api/src/conformance-adapter.ts b/apps/api/src/conformance-adapter.ts index 476ed72..6012bd6 100644 --- a/apps/api/src/conformance-adapter.ts +++ b/apps/api/src/conformance-adapter.ts @@ -178,7 +178,7 @@ export function createFrontLitConformanceAdapter( } return { status: 200, - body: { result: { content: [{ json: { items: [] } }] } }, + body: { result: { structuredContent: { items: [] } } }, }; }, async readAuditEvents() { diff --git a/apps/docs/package.json b/apps/docs/package.json index f5e875f..fe420e6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,7 +11,7 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "@codelitdev/design-system": "0.4.2", + "@codelitdev/design-system": "0.8.0", "@frontlit/api-contract": "workspace:*", "@frontlit/page-builder": "workspace:*", "@frontlit/text-editor": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 54540a1..99ef0c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "test": "vitest run" }, "dependencies": { - "@codelitdev/design-system": "0.4.2", + "@codelitdev/design-system": "0.8.0", "@frontlit/api-contract": "workspace:*", "@frontlit/media-uploader": "workspace:*", "@frontlit/page-builder": "workspace:*", diff --git a/bun.lock b/bun.lock index ad1c19e..8bca675 100644 --- a/bun.lock +++ b/bun.lock @@ -32,10 +32,10 @@ "@aws-sdk/client-s3": "^3.1111.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/mcp-server-kit": "0.4.2", - "@codelitdev/oauth-server-kit": "0.4.2", - "@codelitdev/observability": "0.4.2", - "@codelitdev/platform": "0.4.2", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@frontlit/api-contract": "workspace:*", "@frontlit/page-builder": "workspace:*", "@frontlit/text-editor": "workspace:*", @@ -65,8 +65,8 @@ "zod-to-json-schema": "^3.25.2", }, "devDependencies": { - "@codelitdev/billing": "0.4.2", - "@codelitdev/platform-conformance": "0.4.2", + "@codelitdev/billing": "0.8.0", + "@codelitdev/platform-conformance": "0.8.0", "@electric-sql/pglite": "^0.2.17", "@modelcontextprotocol/client": "^2.2.0", "@types/cors": "^2.8.12", @@ -86,7 +86,7 @@ "name": "@frontlit/docs", "version": "0.1.0", "dependencies": { - "@codelitdev/design-system": "0.4.2", + "@codelitdev/design-system": "0.8.0", "@frontlit/api-contract": "workspace:*", "@frontlit/page-builder": "workspace:*", "@frontlit/text-editor": "workspace:*", @@ -143,7 +143,7 @@ "name": "@frontlit/web", "version": "0.1.0", "dependencies": { - "@codelitdev/design-system": "0.4.2", + "@codelitdev/design-system": "0.8.0", "@frontlit/api-contract": "workspace:*", "@frontlit/media-uploader": "workspace:*", "@frontlit/page-builder": "workspace:*", @@ -236,7 +236,7 @@ }, "packages/page-builder": { "name": "@frontlit/page-builder", - "version": "0.1.0-alpha.7", + "version": "0.1.0-alpha.8", "dependencies": { "@frontlit/media-uploader": "workspace:*", "@frontlit/text-editor": "workspace:*", @@ -282,7 +282,7 @@ }, "packages/preview": { "name": "@frontlit/preview", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "bin": { "frontlit-preview": "cli.mjs", }, @@ -295,7 +295,7 @@ }, "packages/text-editor": { "name": "@frontlit/text-editor", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "dependencies": { "@floating-ui/dom": "^1.6.0", "@tiptap/core": "^3.0.0", @@ -555,19 +555,19 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@codelitdev/billing": ["@codelitdev/billing@0.4.2", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-tjFaDsnTMh0hj3yYhhsBNOai7pzkv5xo0lslLh/TAqIXw3YzC3kUjQX7xryPkMGhDEnvZm5igtGVob6UEdofCw=="], + "@codelitdev/billing": ["@codelitdev/billing@0.8.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-xq6AzksmRG2oVyzgwqxRUSxU8QaZcj4WDv9rOHXfebQhqFjkMV9X46QGDTP9s0LxSTgriLY03JPQMT9+aOsqZg=="], - "@codelitdev/design-system": ["@codelitdev/design-system@0.4.2", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-P8XpUW6/MDoyZ8cvVExGjhz7LtTEVFKMrBcjXcjwbPYGmDKeNj3PQkVP3V5sfYno5NQY/XFVcWR1f/vVsIIb7w=="], + "@codelitdev/design-system": ["@codelitdev/design-system@0.8.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-WBVWlqKK88dbg5BeME/GLETJbzsOLhDdkhv41TIrO0VkWVXqOhCZ7wrQvB5+sNx+jezItysmhF9L+99zYhXHsA=="], - "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.4.2", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" }, "peerDependencies": { "@codelitdev/platform": ">=0.4.2" } }, "sha512-1Wdd4ELa4zARXfa3Mpam7ZizHpfPC1XBjBHc5ny0Xi7uiojHLpU0pT7R94D0cCqeEnZvJntiBMzABgAyPS2esg=="], + "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.8.0", "", { "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.8.0" } }, "sha512-k3c/lP2njh4HxxYgLLWcpPf41N1QU7w5eddcf6ZP5vRPxVQskZDrU8XKclTXbNcEuONvuq96hMrwoQsWW2tgFw=="], - "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.4.2", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-3KD3J2etQqDrqkzyvew/aCqlYaA1ivXW/34aMLLlEMwUlf0a4FrzCfmalSqv2IIGeogz9kqZ/mnqrvqduBak1w=="], + "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.8.0", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-hEVG5G0tAacDHv44evzKPM+AJQmu2bND2AtspAJFlRuE9QLJteu/1N7aLgGcPlDdA1+YI5+s0J0fbXpBT8rPsQ=="], - "@codelitdev/observability": ["@codelitdev/observability@0.4.2", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-cPcXOkrp3G1uAC4Y8rt0GkmJ2vKw8ydL6j4sxL3FeSZBn083tiLVbL4FtHxICZqZMWA3OODom6xKA/L0EIYNxw=="], + "@codelitdev/observability": ["@codelitdev/observability@0.8.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-BX7STE1SqwPvuTBSyIpADkw7Ef3haWgTGoKFuOMxO5t+0Ow3bp/8hmk/dcpjNKK+oV2q2sDKbcVDEgWrmdUVCg=="], - "@codelitdev/platform": ["@codelitdev/platform@0.4.2", "", { "peerDependencies": { "@codelitdev/billing": ">=0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-eZd2UJ+aQ/AOCwRBhE5btI9gXEMOnuvash8GgWU4zSbO1Pu0Hk4cr4FhbL/VpqwASvgvzvlDnYm0es+LRo38HQ=="], + "@codelitdev/platform": ["@codelitdev/platform@0.8.0", "", { "peerDependencies": { "@codelitdev/billing": ">=0.8.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-RqPgDtZRvJ81IbVyeZFDG8Y+qcNTKuCt3O7mq6gvMoln/og0Ma/P9MCJ6zx09KLLXjFYhzUADP/evThwF62h7g=="], - "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.4.2", "", { "peerDependencies": { "@codelitdev/platform": ">=0.4.2" } }, "sha512-dvVvOrhP5cFe86FaI1Qf1sGAdS0NIeZasNrN9OBoTAIAeo+zM4WseRsXdhs4PGjYf5aD11ycZNGbgzrzg4KXdg=="], + "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.8.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.8.0" } }, "sha512-gmbwoPs23W/Zm2WuRKWewQ7rm1dB7dMyY3F2pNxnMi8vhBKprNuQNK3qb9++rwiawTjhsDlpuyLD6BPTKgseAw=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], @@ -2659,7 +2659,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], @@ -3123,6 +3123,8 @@ "@better-auth/oauth-provider/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@codelitdev/mcp-server-kit/@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], + "@codelitdev/observability/pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -3189,6 +3191,8 @@ "@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "@modelcontextprotocol/sdk/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "@modelcontextprotocol/server/@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], @@ -3271,8 +3275,6 @@ "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], - "bun-types/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -3379,6 +3381,8 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "read-yaml-file/js-yaml": ["js-yaml@3.15.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -3433,6 +3437,10 @@ "@ai-sdk/deepseek/@ai-sdk/provider-utils/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], + "@codelitdev/mcp-server-kit/@modelcontextprotocol/server/@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], + + "@codelitdev/mcp-server-kit/@modelcontextprotocol/server/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@codelitdev/observability/pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], "@codelitdev/observability/pino/thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], diff --git a/docker-compose.yml b/docker-compose.yml index 58f0fb7..9a33f95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,6 +141,8 @@ services: MAIL_DOMAIN: ${MAIL_DOMAIN:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} + TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-} depends_on: postgres: condition: service_healthy diff --git a/docs/architecture.md b/docs/architecture.md index a68dd01..503b391 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1688,6 +1688,12 @@ replies) gets three stacked defenses: server-side (`TURNSTILE_SECRET_KEY`) before accepting. Site key is FrontLit-global, not per-team โ€” zero tenant setup. Self-hosters can set `CAPTCHA_PROVIDER=none` to fall back to honeypot-only. + +The hosted sign-in page is guarded the same way when `TURNSTILE_SITE_KEY` +is also set: it renders the widget, and Better Auth's `captcha` plugin +verifies the token on the request that emails a sign-in code +(`/email-otp/send-verification-otp`). Entering the code is not gated โ€” it +already proves control of the inbox. Tokens must come from the API's host. 3. **Escalation**: when the rate limiter flags an IP (or the team-global daily cap nears), the endpoints flip from invisible to interactive challenge for that key.