Skip to content

Commit 95e13e3

Browse files
committed
Merge branch 'dev' into payment-subscription-handling-rework
2 parents f62f87e + d2f2fb0 commit 95e13e3

142 files changed

Lines changed: 10334 additions & 5047 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE-KNOWLEDGE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,12 @@ Then restart the dev server. This rebuilds all packages and generates the necess
355355

356356
## Q: How is backwards compatibility for the offer→product rename handled in the payments purchase APIs?
357357
A: API v1 requests are routed through the `v2beta1` migration. The migration wraps the latest handlers, accepts legacy `offer_id`/`offer_inline` request fields, translates product-related errors back to the old offer error codes/messages, and augments responses (like `validate-code`) with `offer`/`conflicting_group_offers` aliases alongside the new `product` fields. Newer API versions keep the product-only contract.
358+
359+
## Q: How does `/api/v1/ai/query/generate` reject invalid AI tool names?
360+
A: Invalid `tools` entries are rejected by `requestBodySchema` in `apps/backend/src/lib/ai/schema.ts` via `yupString().oneOf(TOOL_NAMES)`, so the endpoint returns a structured `SCHEMA_ERROR` object mentioning `body.tools[n]` rather than a custom `"Invalid tool names"` string from handler logic.
361+
362+
## Q: Why did the internal metrics E2E snapshots need to change in April 2026?
363+
A: The `/api/v1/internal/metrics` response now intentionally includes `analytics_overview.daily_anonymous_visitors_fallback`, `analytics_overview.anonymous_visitors_fallback`, and `active_users_by_country`. Those additions are reflected in `packages/stack-shared/src/interface/admin-metrics.ts` and the backend route, so the E2E snapshots must include them instead of treating them as regressions.
364+
365+
## Q: Why can environment config override writes fail with a product/product-line customer type warning after creating a preview project?
366+
A: The environment override endpoint validates the new environment override against the rendered branch config. Preview dummy payments data must therefore be internally coherent: products assigned to a product line need the same `customerType` as that product line, otherwise unrelated environment patches can fail with warnings like `Product "growth" has customer type "user" but its product line "workspace" has customer type "team"`.

apps/backend/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token
117117
STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id
118118

119119
# Docs AI tool bundle
120-
STACK_DOCS_INTERNAL_BASE_URL=# override the docs origin used by the backend's AI tool bundle to call the docs app's `/api/internal/docs-tools` endpoint. Defaults to http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04 in dev, https://mcp.stack-auth.com in prod
120+
STACK_MINTLIFY_MCP_URL=# override the Mintlify MCP server used by the backend's AI docs tool bundle. Defaults to https://stackauth-e0affa27.mintlify.app/mcp
121121

122122
# MCP review tool (SpacetimeDB)
123123
STACK_SPACETIMEDB_URI=# SpacetimeDB host URI; default empty (logging disabled)

apps/backend/.env.development

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
7878
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
7979
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
8080
STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION
81-
# STACK_DOCS_INTERNAL_BASE_URL=http://localhost:8104
81+
STACK_MINTLIFY_MCP_URL=https://stackauth-e0affa27.mintlify.app/mcp
8282
# Email monitor configuration for tests
8383
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification
8484
STACK_EMAIL_MONITOR_PROJECT_ID=internal

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"@stackframe/stack-shared": "workspace:*",
8888
"@upstash/qstash": "^2.8.2",
8989
"@vercel/functions": "^2.0.0",
90+
"@vercel/mcp-adapter": "^1.0.0",
9091
"@vercel/otel": "^1.10.4",
9192
"@vercel/sandbox": "^1.2.0",
9293
"ai": "^6.0.0",

docs/src/app/api/internal/[transport]/route.ts renamed to apps/backend/src/app/api/internal/[transport]/route.ts

Lines changed: 21 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
12
import { createMcpHandler } from "@vercel/mcp-adapter";
2-
import { PostHog } from "posthog-node";
33
import { z } from "zod";
44

5-
const nodeClient = process.env.NEXT_PUBLIC_POSTHOG_KEY
6-
? new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY)
7-
: null;
5+
import withPostHog from "@/analytics";
6+
7+
function getBackendApiBaseUrl(): string {
8+
return (
9+
getEnvVariable("NEXT_PUBLIC_SERVER_STACK_API_URL", "") ||
10+
getEnvVariable("NEXT_PUBLIC_STACK_API_URL")
11+
).replace(/\/$/, "");
12+
}
813

914
const handler = createMcpHandler(
1015
async (server) => {
@@ -29,26 +34,19 @@ const handler = createMcpHandler(
2934
.string()
3035
.optional()
3136
.describe(
32-
"Pass the conversationId from a previous response to group related calls into the same conversation. Omit on the first call the server will generate one and return it.",
37+
"Pass the conversationId from a previous response to group related calls into the same conversation. Omit on the first call - the server will generate one and return it.",
3338
),
3439
},
3540
async ({ question, reason, userPrompt, conversationId }) => {
36-
nodeClient?.capture({
37-
event: "ask_stack_auth_mcp",
38-
properties: { question, reason },
39-
distinctId: "mcp-handler",
41+
await withPostHog(async (posthog) => {
42+
posthog.capture({
43+
event: "ask_stack_auth_mcp",
44+
properties: { question, reason },
45+
distinctId: "mcp-handler",
46+
});
4047
});
4148

42-
const apiBase = process.env.NEXT_PUBLIC_STACK_API_URL;
43-
if (apiBase == null || apiBase === "") {
44-
return {
45-
content: [{ type: "text", text: "NEXT_PUBLIC_STACK_API_URL is not configured on the docs server." }],
46-
isError: true,
47-
};
48-
}
49-
50-
const url = `${apiBase.replace(/\/$/, "")}/api/latest/ai/query/generate`;
51-
const res = await fetch(url, {
49+
const res = await fetch(`${getBackendApiBaseUrl()}/api/latest/ai/query/generate`, {
5250
method: "POST",
5351
headers: { "Content-Type": "application/json" },
5452
body: JSON.stringify({
@@ -86,44 +84,15 @@ const handler = createMcpHandler(
8684
const responseConversationId = body.conversationId ?? conversationId ?? "";
8785

8886
return {
89-
content: [{ type: "text", text: `${text.length > 0 ? text : "(empty response)"}\n\n[conversationId: ${responseConversationId} pass this value as the conversationId parameter in your next ask_stack_auth call to continue this conversation]` }],
87+
content: [{ type: "text", text: `${text.length > 0 ? text : "(empty response)"}\n\n[conversationId: ${responseConversationId} - pass this value as the conversationId parameter in your next ask_stack_auth call to continue this conversation]` }],
9088
};
9189
},
9290
);
9391
},
9492
{
95-
capabilities: {
96-
tools: {
97-
ask_stack_auth: {
98-
description:
99-
"Ask the Stack Auth documentation assistant any question about Stack Auth (setup, APIs, SDKs, configuration, troubleshooting).",
100-
parameters: {
101-
type: "object",
102-
properties: {
103-
question: {
104-
type: "string",
105-
description: "The full question to ask about Stack Auth.",
106-
},
107-
reason: {
108-
type: "string",
109-
description:
110-
"Why the agent invoked this tool (for analytics and debugging). Not sent to the documentation model.",
111-
},
112-
userPrompt: {
113-
type: "string",
114-
description:
115-
"The original user message/prompt that triggered this tool call. Copy the user's exact words.",
116-
},
117-
conversationId: {
118-
type: "string",
119-
description:
120-
"Pass the conversationId from a previous response to group related calls. Omit on first call.",
121-
},
122-
},
123-
required: ["question", "reason", "userPrompt"],
124-
},
125-
},
126-
},
93+
serverInfo: {
94+
name: "stack-auth-mcp",
95+
version: "0.1.0",
12796
},
12897
},
12998
{

apps/backend/src/app/api/latest/ai/query/[mode]/route.ts

Lines changed: 26 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@ import { selectModel } from "@/lib/ai/models";
33
import { getFullSystemPrompt } from "@/lib/ai/prompts";
44
import { reviewMcpCall } from "@/lib/ai/qa-reviewer";
55
import { requestBodySchema } from "@/lib/ai/schema";
6-
import { getTools, validateToolNames } from "@/lib/ai/tools";
6+
import { getTools } from "@/lib/ai/tools";
77
import { getVerifiedQaContext } from "@/lib/ai/verified-qa";
88
import { listManagedProjectIds } from "@/lib/projects";
99
import { SmartResponse } from "@/route-handlers/smart-response";
1010
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
1111
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
1212
import { validateImageAttachments } from "@stackframe/stack-shared/dist/ai/image-limits";
13+
import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface";
1314
import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
1415
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
1516
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1617
import { Json } from "@stackframe/stack-shared/dist/utils/json";
17-
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
18+
import { generateText, stepCountIs, streamText, type ModelMessage } from "ai";
1819

1920
export const POST = createSmartRouteHandler({
2021
metadata: {
@@ -29,11 +30,6 @@ export const POST = createSmartRouteHandler({
2930
response: yupMixed<SmartResponse>().defined(),
3031
async handler({ params, body }, fullReq) {
3132
const { mode } = params;
32-
33-
if (!validateToolNames(body.tools)) {
34-
throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`);
35-
}
36-
3733
const isAuthenticated = fullReq.auth != null;
3834
const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages, projectId } = body;
3935

@@ -79,11 +75,17 @@ export const POST = createSmartRouteHandler({
7975
? 5
8076
: 5;
8177

78+
// Cast: the schema narrows role and leaves content as unknown, but the
79+
// AI SDK accepts a superset (role: "system" etc.). We've intentionally
80+
// excluded `system` at the schema layer to prevent prompt-injection via
81+
// client-supplied system messages — see schema.ts.
82+
const modelMessages = messages as unknown as ModelMessage[];
83+
8284
if (mode === "stream") {
8385
const result = streamText({
8486
model,
8587
system: systemPrompt,
86-
messages: messages as ModelMessage[],
88+
messages: modelMessages,
8789
tools: toolsArg,
8890
stopWhen: stepCountIs(stepLimit),
8991
});
@@ -99,47 +101,29 @@ export const POST = createSmartRouteHandler({
99101
const result = await generateText({
100102
model,
101103
system: systemPrompt,
102-
messages: messages as ModelMessage[],
104+
messages: modelMessages,
103105
tools: toolsArg,
104106
abortSignal: controller.signal,
105107
stopWhen: stepCountIs(stepLimit),
106108
}).finally(() => clearTimeout(timeoutId));
107109

108-
const contentBlocks: Array<
109-
| { type: "text", text: string }
110-
| {
111-
type: "tool-call",
112-
toolName: string,
113-
toolCallId: string,
114-
args: Json,
115-
argsText: string,
116-
result: Json,
117-
}
118-
> = [];
119-
120-
result.steps.forEach((step) => {
110+
const content: ChatContent = result.steps.flatMap((step) => {
111+
const blocks: ChatContent = [];
121112
if (step.text) {
122-
contentBlocks.push({
123-
type: "text",
124-
text: step.text,
125-
});
113+
blocks.push({ type: "text", text: step.text });
126114
}
127-
128-
const toolResultsByCallId = new Map(
129-
step.toolResults.map((r) => [r.toolCallId, r])
130-
);
131-
132-
step.toolCalls.forEach((toolCall) => {
133-
const toolResult = toolResultsByCallId.get(toolCall.toolCallId);
134-
contentBlocks.push({
115+
const outById = new Map(step.toolResults.map((r) => [r.toolCallId, r.output as Json]));
116+
for (const call of step.toolCalls) {
117+
blocks.push({
135118
type: "tool-call",
136-
toolName: toolCall.toolName,
137-
toolCallId: toolCall.toolCallId,
138-
args: toolCall.input,
139-
argsText: JSON.stringify(toolCall.input),
140-
result: (toolResult?.output ?? null) as Json,
119+
toolName: call.toolName,
120+
toolCallId: call.toolCallId,
121+
args: call.input as Json,
122+
argsText: JSON.stringify(call.input),
123+
result: outById.get(call.toolCallId) ?? null,
141124
});
142-
});
125+
}
126+
return blocks;
143127
});
144128

145129
let responseConversationId: string | undefined;
@@ -152,7 +136,7 @@ export const POST = createSmartRouteHandler({
152136
? firstUserMessage.content
153137
: JSON.stringify(firstUserMessage?.content ?? "");
154138

155-
const innerToolCallsJson = JSON.stringify(contentBlocks.filter(b => b.type === "tool-call"));
139+
const innerToolCallsJson = JSON.stringify(content.filter(b => b.type === "tool-call"));
156140

157141
const logPromise = logMcpCall({
158142
correlationId,
@@ -183,7 +167,7 @@ export const POST = createSmartRouteHandler({
183167
statusCode: 200,
184168
bodyType: "json" as const,
185169
body: {
186-
content: contentBlocks,
170+
content,
187171
finalText: result.text,
188172
conversationId: responseConversationId ?? null,
189173
},

0 commit comments

Comments
 (0)