diff --git a/apps/backend/.env.development b/apps/backend/.env.development index eb4b2dddac..ea0695a6d0 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -77,6 +77,7 @@ STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION +STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION # STACK_DOCS_INTERNAL_BASE_URL=http://localhost:8104 # Email monitor configuration for tests STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification diff --git a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts index 4fb5629781..9a8205dd90 100644 --- a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts +++ b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts @@ -5,6 +5,7 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { NextRequest } from "next/server"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api"; +const PRODUCTION_PROXY_BASE_URL = "https://api.stack-auth.com/api/latest/integrations/ai-proxy"; const OPENROUTER_DEFAULT_MODEL = "anthropic/claude-sonnet-4.6"; function sanitizeBody(raw: ArrayBuffer): Uint8Array { @@ -36,22 +37,43 @@ async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); const params = await options.params; const subpath = params.path?.join("/") ?? ""; - const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`; + const contentType = req.headers.get("Content-Type"); + const body = req.method !== "GET" && req.method !== "HEAD" + ? Buffer.from(sanitizeBody(await req.arrayBuffer())) + : undefined; + + if (apiKey === "FORWARD_TO_PRODUCTION") { + const targetUrl = `${PRODUCTION_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`; + const headers: Record = {}; + if (contentType) { + headers["Content-Type"] = contentType; + } + + const response = await fetch(targetUrl, { + method: req.method, + headers, + body, + }); + + return new Response(response.body, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + "Cache-Control": "no-cache", + }, + }); + } + + const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`; const headers: Record = { "Authorization": `Bearer ${apiKey}`, "anthropic-version": "2023-06-01", }; - - const contentType = req.headers.get("Content-Type"); if (contentType) { headers["Content-Type"] = contentType; } - const body = req.method !== "GET" && req.method !== "HEAD" - ? Buffer.from(sanitizeBody(await req.arrayBuffer())) - : undefined; - const response = await fetch(targetUrl, { method: req.method, headers, diff --git a/apps/backend/src/app/api/latest/internal/component-versions/route.ts b/apps/backend/src/app/api/latest/internal/component-versions/route.ts new file mode 100644 index 0000000000..3cedd6981b --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/component-versions/route.ts @@ -0,0 +1,31 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getLatestPageVersions } from "@stackframe/stack-shared/dist/interface/handler-urls"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + versions: yupRecord(yupString().defined(), yupObject({ + version: yupNumber().defined(), + changelogs: yupRecord(yupString().defined(), yupString().defined()).defined(), + }).defined()).defined(), + }).defined(), + }), + handler: async () => { + return { + statusCode: 200, + bodyType: "json" as const, + body: { + versions: getLatestPageVersions(), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/feedback/route.tsx b/apps/backend/src/app/api/latest/internal/feedback/route.tsx index a78048fd07..99274039b3 100644 --- a/apps/backend/src/app/api/latest/internal/feedback/route.tsx +++ b/apps/backend/src/app/api/latest/internal/feedback/route.tsx @@ -1,26 +1,37 @@ import { sendSupportFeedbackEmail } from "@/lib/internal-feedback-emails"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { adaptSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +/** + * Unified feedback endpoint used by both the dashboard and the dev tool. + * + * Auth is optional: when the user is signed in (dashboard), user info is + * included in the email. When unauthenticated (dev tool), feedback is sent + * without user context. + * + * In the local emulator, feedback is forwarded to production Stack Auth (same + * pattern as the AI query endpoint). Set STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION + * in .env.development to enable this. + */ export const POST = createSmartRouteHandler({ metadata: { summary: "Submit support feedback", - description: "Send a support feedback message to the internal Stack Auth inbox", + description: "Send a support feedback message to the internal Stack Auth inbox. Auth is optional — works from both the dashboard (authenticated) and the dev tool (unauthenticated).", tags: ["Internal"], }, request: yupObject({ auth: yupObject({ - type: clientOrHigherAuthTypeSchema, - tenancy: adaptSchema.defined(), - user: adaptSchema.defined(), - project: yupObject({ - id: yupString().oneOf(["internal"]).defined(), - }).defined(), - }).defined(), + tenancy: adaptSchema.optional(), + user: adaptSchema.optional(), + }).nullable().optional(), body: yupObject({ name: yupString().optional().max(100), email: emailSchema.defined().nonEmpty(), message: yupString().defined().nonEmpty().max(5000), + feedback_type: yupString().oneOf(["feedback", "bug"]).optional(), }).defined(), method: yupString().oneOf(["POST"]).defined(), }), @@ -32,12 +43,35 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth, body }) { + // Forward to production in local emulator (same pattern as AI query endpoint) + const feedbackMode = getEnvVariable("STACK_FEEDBACK_MODE", "email"); + if (feedbackMode === "FORWARD_TO_PRODUCTION") { + const prodResponse = await fetch("https://api.stack-auth.com/api/latest/internal/feedback", { + method: "POST", + headers: { "content-type": "application/json", "accept-encoding": "identity" }, + body: JSON.stringify(body), + }); + if (!prodResponse.ok) { + throw new StatusError(prodResponse.status, "Failed to forward feedback to production"); + } + return { + statusCode: 200, + bodyType: "json" as const, + body: { success: true as const }, + }; + } + + // Use the authenticated tenancy if available, otherwise fall back to the + // internal project tenancy (for unauthenticated dev tool submissions). + const tenancy = auth?.tenancy ?? await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID); + await sendSupportFeedbackEmail({ - tenancy: auth.tenancy, - user: auth.user, + tenancy, + user: auth?.user ?? null, name: body.name ?? null, email: body.email, message: body.message, + feedbackType: body.feedback_type, }); return { diff --git a/apps/backend/src/app/api/latest/internal/projects/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/crud.tsx index 8d23112663..c0bb47c61d 100644 --- a/apps/backend/src/app/api/latest/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/crud.tsx @@ -17,7 +17,7 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan }), onPrepare: async ({ auth }) => { if (!auth.user) { - throw new KnownErrors.UserAuthenticationRequired; + throw new KnownErrors.UserAuthenticationRequired(); } if (auth.project.id !== "internal") { throw new KnownErrors.ExpectedInternalProject(); diff --git a/apps/backend/src/lib/internal-feedback-emails.tsx b/apps/backend/src/lib/internal-feedback-emails.tsx index d8ebb253e2..30db8a0e54 100644 --- a/apps/backend/src/lib/internal-feedback-emails.tsx +++ b/apps/backend/src/lib/internal-feedback-emails.tsx @@ -1,5 +1,6 @@ import { createTemplateComponentFromHtml } from "@/lib/email-rendering"; import { normalizeEmail, sendEmailToMany } from "@/lib/emails"; +import { isLocalEmulatorEnabled } from "@/lib/local-emulator"; import { getNotificationCategoryByName } from "@/lib/notification-categories"; import { Tenancy } from "@/lib/tenancies"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; @@ -82,24 +83,41 @@ async function sendInternalOperationsEmail(options: { export async function sendSupportFeedbackEmail(options: { tenancy: Tenancy, - user: UsersCrud["Admin"]["Read"], + user: UsersCrud["Admin"]["Read"] | null, name: string | null, email: string, message: string, + feedbackType?: string, }) { - const displayName = options.name ?? options.user.display_name ?? "Not provided"; + const displayName = options.name ?? options.user?.display_name ?? "Not provided"; + const feedbackLabel = options.feedbackType === "bug" ? "Bug Report" : "Support"; + + const fields: Array<{ label: string, value: string }> = [ + { label: "Sender name", value: displayName }, + { label: "Sender email", value: options.email }, + ]; + + if (options.user) { + fields.push( + { label: "Stack Auth user ID", value: options.user.id }, + { label: "Stack Auth display name", value: options.user.display_name ?? "Not provided" }, + ); + } + + if (options.feedbackType) { + fields.push({ label: "Type", value: feedbackLabel }); + } + + if (isLocalEmulatorEnabled()) { + fields.push({ label: "Environment", value: "Local Emulator" }); + } await sendInternalOperationsEmail({ tenancy: options.tenancy, - subject: `[Support] ${options.email}`, + subject: `[${feedbackLabel}] ${options.email}`, htmlContent: buildInternalEmailHtml({ - heading: "Support feedback submission", - fields: [ - { label: "Sender name", value: displayName }, - { label: "Sender email", value: options.email }, - { label: "Stack Auth user ID", value: options.user.id }, - { label: "Stack Auth display name", value: options.user.display_name ?? "Not provided" }, - ], + heading: `${feedbackLabel} feedback submission`, + fields, contentLabel: "Message", contentBody: options.message, }), diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index a3591a66ae..7698286e34 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -91,6 +91,7 @@ const nextConfig = { }, async headers() { + const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true"; return [ { source: "/(.*)", @@ -118,7 +119,9 @@ const nextConfig = { }], { key: "Content-Security-Policy", - value: "", + // Note: *.localhost requires Chrome 117+ and may not work in Firefox + // without network.dns.localDomains configuration. Fine for dev tool purposes. + value: isLocalEmulator ? "frame-ancestors 'self' http://localhost:* https://localhost:* http://127.0.0.1:* https://127.0.0.1:* http://[::1]:* https://[::1]:* http://*.localhost https://*.localhost" : "", }, ], }, diff --git a/apps/dashboard/src/components/feedback-form.tsx b/apps/dashboard/src/components/feedback-form.tsx index 7025b536de..4b30f7e0f2 100644 --- a/apps/dashboard/src/components/feedback-form.tsx +++ b/apps/dashboard/src/components/feedback-form.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui"; +import { SelectField } from "@/components/form-fields"; import { getPublicEnvVar } from "@/lib/env"; -import { getInternalProjectHeaders } from "@/lib/internal-project-headers"; import { CheckCircleIcon, EnvelopeIcon, GithubLogoIcon, WarningCircleIcon } from "@phosphor-icons/react"; import { useUser } from "@stackframe/stack"; import { emailSchema } from "@stackframe/stack-shared/dist/schema-fields"; @@ -34,6 +34,22 @@ export function FeedbackForm() { .max(5000) .label("Message") .meta({ type: "textarea" }), + feedback_type: yup.string() + .oneOf(["feedback", "bug"] as const) + .defined() + .label("Type") + .default("feedback") + .meta({ + stackFormFieldRender: (props: any) => ( + + ), + }), }); const handleSubmit = async (values: yup.InferType) => { @@ -41,18 +57,21 @@ export function FeedbackForm() { setErrorMessage(''); try { - if (user == null) { - throw new Error("Please sign in again and retry sending feedback."); + // Auth headers are sent when available so the backend can include user + // context in the email, but the endpoint accepts unauthenticated requests. + const headers: Record = { "Content-Type": "application/json" }; + if (user) { + const authJson = await user.getAuthJson(); + headers["X-Stack-Access-Type"] = "client"; + headers["X-Stack-Project-Id"] = "internal"; + headers["X-Stack-Publishable-Client-Key"] = getPublicEnvVar("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY") ?? ""; + if (authJson.accessToken) { + headers["X-Stack-Access-Token"] = authJson.accessToken; + } } - const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feedback`, { method: "POST", - headers: { - ...getInternalProjectHeaders({ - accessToken: authJson.accessToken, - contentType: "application/json", - }), - }, + headers, body: JSON.stringify(values), }); @@ -139,7 +158,7 @@ export function FeedbackForm() { form="feedback-form" className="w-full" loading={submitting} - disabled={submitting || user == null} + disabled={submitting} > Send Feedback diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/component-versions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/component-versions.test.ts new file mode 100644 index 0000000000..d510b9ea9f --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/component-versions.test.ts @@ -0,0 +1,50 @@ +import { describe } from "vitest"; +import { it } from "../../../../../helpers"; +import { niceBackendFetch } from "../../../../backend-helpers"; + +describe("GET /api/v1/internal/component-versions", () => { + it("should return page versions and changelogs", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/internal/component-versions", { + method: "GET", + }); + + expect(response.status).toBe(200); + + const body = response.body; + expect(body).toHaveProperty("versions"); + expect(typeof body.versions).toBe("object"); + + const expectedPages = [ + "signIn", + "signUp", + "signOut", + "emailVerification", + "passwordReset", + "forgotPassword", + "oauthCallback", + "magicLinkCallback", + "accountSettings", + "teamInvitation", + "mfa", + "error", + "onboarding", + ]; + + for (const page of expectedPages) { + expect(body.versions).toHaveProperty(page); + expect(body.versions[page]).toHaveProperty("version"); + expect(typeof body.versions[page].version).toBe("number"); + expect(body.versions[page]).toHaveProperty("changelogs"); + expect(typeof body.versions[page].changelogs).toBe("object"); + } + }); + + it("should reject non-GET methods", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/internal/component-versions", { + method: "POST", + body: {}, + }); + + expect(response.status).toBe(405); + }); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts index b3d35f13ac..38b8ad090b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts @@ -1,59 +1,125 @@ -import { afterEach, describe } from "vitest"; +import { describe } from "vitest"; import { it } from "../../../../../helpers"; import { Auth, backendContext, createMailbox, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; -afterEach(() => { - delete process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS; -}); +/** + * Probe the backend to detect whether it's forwarding feedback to production. + * Cached so we only make one probe request per test run. + */ +let cachedIsForwarding: boolean | null = null; +async function isForwardingToProduction(): Promise { + if (cachedIsForwarding !== null) return cachedIsForwarding; + const probe = await niceBackendFetch("/api/v1/internal/feedback", { + method: "POST", + body: { + email: "probe@test.stack-auth.com", + message: "mode detection probe", + }, + }); + // When forwarding, production rejects and we get a non-200 with "forward" in the body + cachedIsForwarding = probe.status !== 200; + return cachedIsForwarding; +} describe("POST /api/v1/internal/feedback", () => { - it("should reject unauthenticated requests", async ({ expect }) => { + it("should send feedback from an authenticated user", async ({ expect }) => { + if (await isForwardingToProduction()) { + return; // forwarding mode — probe already verified endpoint is reachable + } + + const senderEmail = backendContext.value.mailbox.emailAddress; + const signInResult = await Auth.Otp.signIn(); + const recipientMailbox = createMailbox("team@stack-auth.com"); + const subject = `[Support] ${senderEmail}`; + const response = await niceBackendFetch("/api/v1/internal/feedback", { method: "POST", accessType: "client", body: { - email: "test@example.com", - message: "This should be rejected", + name: "Support Tester", + email: senderEmail, + message: "Authenticated feedback from the dashboard.", }, }); expect(response).toMatchInlineSnapshot(` NiceResponse { - "status": 400, - "body": { - "code": "SCHEMA_ERROR", - "details": { - "message": deindent\` - Request validation failed on POST /api/v1/internal/feedback: - - auth.user must be defined - \`, - }, - "error": deindent\` - Request validation failed on POST /api/v1/internal/feedback: - - auth.user must be defined - \`, - }, - "headers": Headers { - "x-stack-known-error": "SCHEMA_ERROR", -