diff --git a/apps/web/app/(app)/account/authorize/page.tsx b/apps/web/app/(app)/account/authorize/page.tsx index 2933ee393622..7d0ba29a1d70 100644 --- a/apps/web/app/(app)/account/authorize/page.tsx +++ b/apps/web/app/(app)/account/authorize/page.tsx @@ -1,4 +1,5 @@ import { headers } from "next/headers"; +import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslate } from "@/lingodotdev/server"; import { auth } from "@/modules/auth/lib/auth"; @@ -10,6 +11,7 @@ import { } from "@/modules/auth/lib/oauth-client-metadata"; import { getSession } from "@/modules/auth/lib/session"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; +import { Logo } from "@/modules/ui/components/logo"; import { OAuthConsentActions } from "./components/OAuthConsentActions"; type TSearchParams = Record; @@ -57,6 +59,18 @@ const getPublicOAuthClient = async (clientId: string): Promise) => ( +
+ +
+); + const Page = async ({ searchParams }: Readonly<{ searchParams: Promise }>) => { const resolvedSearchParams = await searchParams; const session = await getSession(); @@ -76,6 +90,7 @@ const Page = async ({ searchParams }: Readonly<{ searchParams: Promise
+ {t("auth.oauth.invalid_oauth_request")} {t("auth.oauth.invalid_oauth_request_description")} @@ -91,6 +106,7 @@ const Page = async ({ searchParams }: Readonly<{ searchParams: Promise
+

{t("auth.oauth.authorization_request")}

diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 886ad51eec06..171473c5c9b4 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -68,6 +68,7 @@ checksums: auth/oauth/consent_failed: d6c92762fac20817ff0ec8d9cbc0412c auth/oauth/consent_revoked: f1c243aaacd7c466b9986e484b93247b auth/oauth/deny_access: ba3ac7dc12f362da277df5c9ba21e752 + auth/oauth/formbricks_website: 95c2f69c6b6428e1aa6c26f4dd427e22 auth/oauth/invalid_oauth_request: 02936ced8cd4df963282fe5d413f6900 auth/oauth/invalid_oauth_request_description: 39322e962c0c20c766ae82d0b8917ebb auth/oauth/localhost_redirect_warning: e0368262d25dc98ea02158eebd0066c1 diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 36e34bef5b6a..80aee0c6aa2e 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -184,7 +184,7 @@ export const DEBUG = env.DEBUG === "1"; export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY; export const ENTERPRISE_LICENSE_REQUEST_FORM_URL = - "https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce"; + "https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce&type=licenseRequest"; export const REDIS_URL = env.REDIS_URL; export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 1ff2a5b9072c..e621f8490e48 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index f4c1f01fc1ee..98974c44d83f 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 833071275e62..ece1166811fa 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 15f225bb8708..9e551412d141 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index cac88fa2b6ea..55b36878fcf2 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index b1ed215d3ed8..c1f46101283a 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index eaa0c8ae672e..8b3cf6f6a5c7 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index d0c1103e7c57..a2c4c1efe319 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 1961bebbebfd..e98d3bcdb436 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index a88d7817e1b9..420146c6315d 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index f5eb038d38a6..d07deca8dc8d 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index c8dd40cf1133..16d19a55e695 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 9d9fd3291c84..06016dff6526 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 229cbdefec65..8c4231332ba3 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 4ad4794c1b68..03214d473e9e 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -82,6 +82,7 @@ "consent_failed": "OAuth consent failed. Please try again.", "consent_revoked": "OAuth app access revoked.", "deny_access": "Deny access", + "formbricks_website": "Formbricks", "invalid_oauth_request": "Invalid OAuth request", "invalid_oauth_request_description": "The OAuth client could not be found or the authorization request is missing required details.", "localhost_redirect_warning": "Local redirect URI", diff --git a/apps/web/modules/api/v2/management/responses/route.test.ts b/apps/web/modules/api/v2/management/responses/route.test.ts new file mode 100644 index 000000000000..35824d4dcb81 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/route.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; + +const { mockAuthenticatedApiClient, mockGetResponses, mockHandleApiError, mockSuccessResponse } = vi.hoisted( + () => ({ + mockAuthenticatedApiClient: vi.fn(), + mockGetResponses: vi.fn(), + mockHandleApiError: vi.fn(), + mockSuccessResponse: vi.fn(), + }) +); + +vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({ + authenticatedApiClient: mockAuthenticatedApiClient, +})); + +vi.mock("@/modules/api/v2/lib/response", () => ({ + responses: { + createdResponse: mockSuccessResponse, + successResponse: mockSuccessResponse, + }, +})); + +vi.mock("@/modules/api/v2/lib/utils", () => ({ + handleApiError: mockHandleApiError, +})); + +vi.mock("./lib/response", () => ({ + createResponseWithQuotaEvaluation: vi.fn(), + getResponses: mockGetResponses, +})); + +vi.mock("@/app/lib/pipelines", () => ({ + sendToPipeline: vi.fn(), +})); + +// Not an identity stub: the success case asserts the *rewritten* value, so the test fails if the +// route stops piping each row's data through this. +vi.mock("@/modules/storage/utils", () => ({ + resolveStorageUrlsInObject: (data: Record) => + Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key, + value === "storage://private/file.png" ? "https://cdn.example.com/file.png" : value, + ]) + ), + validateClientFileUploads: vi.fn(), +})); + +const query = { limit: 2, skip: 10, sortBy: "createdAt", order: "desc" } as const; + +const buildRequest = () => new Request("http://localhost/api/v2/management/responses?limit=2&skip=10"); + +describe("GET /management/responses", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockAuthenticatedApiClient.mockImplementation( + async ({ handler }: Parameters[0]) => + await handler({ + request: buildRequest(), + auditLog: undefined, + authentication: { + type: "apiKey", + apiKeyId: "apiKey123", + organizationId: "org123", + workspacePermissions: [ + { workspaceId: "ws123", workspaceName: "Test Workspace", permission: "read" }, + ], + organizationAccess: { accessControl: { read: true, write: true } }, + }, + parsedInput: { query }, + }) + ); + mockHandleApiError.mockImplementation((_request, error) => Response.json({ error }, { status: 400 })); + mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body, { status: 200 })); + }); + + test("returns the pagination meta the service computed alongside the data", async () => { + mockGetResponses.mockResolvedValue({ + ok: true, + data: { + data: [{ id: "res1", data: { q1: "a", upload: "storage://private/file.png" } }], + meta: { total: 137, limit: 2, offset: 10 }, + }, + }); + + const { GET } = await import("./route"); + const response = await GET(buildRequest() as any); + const body = await response.json(); + + expect(mockGetResponses).toHaveBeenCalledWith(["ws123"], query); + expect(response.status).toBe(200); + expect(body).toEqual({ + data: [{ id: "res1", data: { q1: "a", upload: "https://cdn.example.com/file.png" } }], + meta: { total: 137, limit: 2, offset: 10 }, + }); + }); + + test("surfaces the service error instead of an envelope", async () => { + mockGetResponses.mockResolvedValue({ + ok: false, + error: { type: "internal_server_error", details: [{ field: "responses", issue: "boom" }] }, + }); + + const { GET } = await import("./route"); + const response = await GET(buildRequest() as any); + + expect(mockSuccessResponse).not.toHaveBeenCalled(); + expect(mockHandleApiError).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "internal_server_error" }) + ); + expect(response.status).toBe(400); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index 6c81b79ec6db..b44113c74737 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -1,5 +1,4 @@ import { NextRequest } from "next/server"; -import { Response } from "@formbricks/database/prisma"; import { sendToPipeline } from "@/app/lib/pipelines"; import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service"; import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation"; @@ -36,17 +35,15 @@ export const GET = async (request: NextRequest) => ...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)), ]; - const workspaceResponses: Response[] = []; const res = await getResponses(workspaceIds, query); if (!res.ok) { return handleApiError(request, res.error); } - workspaceResponses.push(...res.data.data); - return responses.successResponse({ - data: workspaceResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })), + data: res.data.data.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })), + meta: res.data.meta, }); }, }); diff --git a/apps/web/modules/auth/lib/better-auth-observability.test.ts b/apps/web/modules/auth/lib/better-auth-observability.test.ts index dd251f3d81f6..f0b82440266f 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.test.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.test.ts @@ -414,6 +414,25 @@ describe("betterAuthLogger (Sentry capture gating, ENG-2037)", () => { expect(contextLoggerMock.warn).toHaveBeenCalledWith("account isn't linked"); }); + test("preserves only an allowlisted warn-level error summary in application logs", () => { + const dbError = Object.assign(new Error("Connection for admin@example.com included token-secret"), { + code: "P1001", + databaseUrl: "postgres://user:password-secret@db.example.com/formbricks", + }); + dbError.stack = "Error: stack-secret"; + + log("warn", "OAuth resource seed for admin@example.com failed", dbError); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(contextLoggerMock.warn).toHaveBeenCalledWith( + { errorType: "Error", errorCode: "P1001" }, + "OAuth resource seed for [redacted]@example.com failed" + ); + expect(JSON.stringify(contextLoggerMock.warn.mock.calls)).not.toMatch( + /token-secret|password-secret|stack-secret/ + ); + }); + test("info/debug-level logs go to info and are never captured", () => { log("info", "some info"); diff --git a/apps/web/modules/auth/lib/better-auth-observability.ts b/apps/web/modules/auth/lib/better-auth-observability.ts index 7c91096bb7bc..c791a6967bee 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.ts @@ -134,6 +134,30 @@ const EMAIL_IN_MESSAGE = /[^\s@]{1,64}@([\w-]{1,63}(?:\.[\w-]{1,63}){1,8})/g; export const redactEmailsInLogMessage = (message: unknown): unknown => typeof message === "string" ? message.replace(EMAIL_IN_MESSAGE, "[redacted]@$1") : message; +const SAFE_WARNING_ERROR_NAMES = new Set([ + "Error", + "TypeError", + "PrismaClientInitializationError", + "PrismaClientKnownRequestError", + "PrismaClientUnknownRequestError", +]); +const PRISMA_ERROR_CODE = /^P\d{4}$/; + +/** + * Warning causes stay in application logs only as a small allowlisted summary. Passing the Error to + * Pino's `err` serializer would also emit its message, stack, and enumerable properties, any of which + * can contain credentials supplied by an upstream provider. + */ +const getSafeWarningErrorContext = (cause: Error): { errorType: string; errorCode?: string } => { + const errorType = SAFE_WARNING_ERROR_NAMES.has(cause.name) ? cause.name : "Error"; + const code = (cause as Error & { code?: unknown }).code; + + return { + errorType, + ...(typeof code === "string" && PRISMA_ERROR_CODE.test(code) && { errorCode: code }), + }; +}; + /** * `StateError` codes whose events are client- or timing-caused, and so are not actionable in Sentry * (ENG-2471). `StateError extends BetterAuthError` and carries a stable `code`, which is what we match @@ -312,7 +336,14 @@ export const betterAuthLogger: NonNullable = { } } } else if (level === "warn") { - contextLogger.warn(safeMessage); + if (cause) { + contextLogger.warn( + getSafeWarningErrorContext(cause), + typeof safeMessage === "string" ? safeMessage : "Better Auth warning" + ); + } else { + contextLogger.warn(safeMessage); + } } else { contextLogger.info(safeMessage); } diff --git a/apps/web/modules/auth/lib/mcp-oauth-resource-init.integration.test.ts b/apps/web/modules/auth/lib/mcp-oauth-resource-init.integration.test.ts new file mode 100644 index 000000000000..4cdf5176ba5a --- /dev/null +++ b/apps/web/modules/auth/lib/mcp-oauth-resource-init.integration.test.ts @@ -0,0 +1,98 @@ +import { oauthProvider } from "@better-auth/oauth-provider"; +import { type BetterAuthOptions, betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import { jwt } from "better-auth/plugins"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { resetDb } from "@/integration/reset-db"; +import { getMcpOauthProviderOptions } from "./mcp-oauth-provider-options"; +import { getAuthIssuerUrl, getMcpResourceUrl } from "./oauth-urls"; + +const BASE_URL = "http://localhost:3000"; + +const createAuthInstance = ( + database = prismaAdapter(prisma, { provider: "postgresql" }), + logger?: BetterAuthOptions["logger"] +) => + betterAuth({ + baseURL: BASE_URL, + secret: "mcp-oauth-resource-init-test-secret-0123456789abcdef", + database, + logger, + plugins: [ + jwt({ + disableSettingJwtHeader: true, + jwt: { issuer: getAuthIssuerUrl(), audience: getMcpResourceUrl() }, + }), + oauthProvider(getMcpOauthProviderOptions()), + ], + }); + +const registerMcpClient = (auth: ReturnType) => + auth.handler( + new Request(`${BASE_URL}/api/auth/oauth2/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "MCP OAuth resource init test", + redirect_uris: ["https://client.example/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + scope: "surveys:read", + }), + }) + ); + +beforeEach(async () => { + await resetDb(); + await prisma.oauthResource.deleteMany(); +}); + +describe("MCP OAuth resource initialization (real Postgres)", () => { + test("does not poison auth after an eager seed failure and retries on first resource access", async () => { + const createAdapter = prismaAdapter(prisma, { provider: "postgresql" }); + const seedError = Object.assign(new Error("simulated transient database timeout"), { code: "P1001" }); + const log = vi.fn(); + let rejectNextResourceLookup = true; + const database: typeof createAdapter = (options) => { + const adapter = createAdapter(options); + return new Proxy(adapter, { + get(target, property, receiver) { + if (property !== "findOne") return Reflect.get(target, property, receiver); + + return async (...args: Parameters) => { + const [query] = args; + if (rejectNextResourceLookup && query.model === "oauthResource") { + rejectNextResourceLookup = false; + throw seedError; + } + return target.findOne(...args); + }; + }, + }); + }; + const auth = createAuthInstance(database, { level: "warn", log }); + + await expect(auth.$context).resolves.toBeDefined(); + expect(await prisma.oauthResource.count()).toBe(0); + expect(log).toHaveBeenCalledWith( + "warn", + "oauth-provider: resource seed failed during init; deferring to first resource access.", + seedError + ); + + const response = await registerMcpClient(auth); + + expect(response.status).toBeLessThan(300); + expect(await prisma.oauthResource.count()).toBe(1); + }); + + test("initializes concurrent instances against an empty resource table", async () => { + const instances = Array.from({ length: 6 }, () => createAuthInstance()); + + await expect(Promise.all(instances.map((instance) => instance.$context))).resolves.toHaveLength(6); + expect(await prisma.oauthResource.count()).toBe(1); + }); +}); diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx index a1298985beb4..17715679ec86 100644 --- a/apps/web/modules/ee/billing/components/pricing-table.tsx +++ b/apps/web/modules/ee/billing/components/pricing-table.tsx @@ -1567,7 +1567,7 @@ export const PricingTable = ({