added logs and cleaned up some slack flows
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
@@ -35,7 +36,10 @@
|
||||
"app_mention",
|
||||
"assistant_thread_started",
|
||||
"assistant_thread_context_changed",
|
||||
"message.im"
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
"message.mpim"
|
||||
]
|
||||
},
|
||||
"interactivity": {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
@@ -35,7 +36,10 @@
|
||||
"app_mention",
|
||||
"assistant_thread_started",
|
||||
"assistant_thread_context_changed",
|
||||
"message.im"
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
"message.mpim"
|
||||
]
|
||||
},
|
||||
"interactivity": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import type { MessageListInput } from "@mastra/core/agent/message-list";
|
||||
import { z } from "zod";
|
||||
import { env as chatEnv } from "../lib/env.js";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
@@ -8,13 +9,20 @@ import type { ChatContextMessage } from "../types.js";
|
||||
import { createFirecrawlTools } from "./firecrawl.js";
|
||||
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
|
||||
|
||||
const docs = [
|
||||
export const agentDocUris = [
|
||||
"autumn://docs/tool-composition",
|
||||
"autumn://docs/feature-catalog",
|
||||
"autumn://docs/querying-plans",
|
||||
"autumn://docs/querying-customers",
|
||||
"autumn://docs/schedules",
|
||||
"autumn://docs/balances",
|
||||
"autumn://docs/billing-safety",
|
||||
"autumn://docs/request-logs",
|
||||
"autumn://docs/request-log-customers",
|
||||
"autumn://docs/request-log-balances",
|
||||
"autumn://docs/request-log-billing",
|
||||
"autumn://docs/request-log-stripe-webhooks",
|
||||
"autumn://docs/request-log-analytics",
|
||||
];
|
||||
|
||||
const instructions = `You are Autumn Chat.
|
||||
@@ -22,13 +30,19 @@ Use Autumn MCP tools for customer, plan, balance, schedule, and billing work.
|
||||
Use web search only for current or external web context. Never use web search for Autumn customer, plan, billing, balance, or schedule state.
|
||||
When web content influences the answer, cite the source URLs.
|
||||
Prefer searchWeb first, then scrapeUrl only for the most relevant result.
|
||||
Use listFeatures only when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids/types are not already known; never invent feature ids.
|
||||
Preview billing-impacting changes first, summarize the preview in short Slack-friendly bullets, then call the matching write tool with the same request args.
|
||||
When Autumn responses include epoch millisecond timestamps, use epochMillisecondsToDate before explaining those timestamps to a user.
|
||||
Treat Slack PDFs and images attached to the latest message as part of the user's request. If an attachment was skipped or unavailable, say so briefly instead of pretending to have read it.
|
||||
The runtime pauses destructive tools for approval before execution, so do not ask for confirmation in plain text.`;
|
||||
|
||||
const envSelectionSchema = z.strictObject({
|
||||
env: z.nativeEnum(AppEnv),
|
||||
});
|
||||
|
||||
export const getDefaultChatEnv = () =>
|
||||
process.env.NODE_ENV === "production" ? AppEnv.Live : AppEnv.Sandbox;
|
||||
|
||||
const recentMessageContext = (messages: ChatContextMessage[] = []) =>
|
||||
messages.map((message) => ({
|
||||
role: message.isBot === true ? ("assistant" as const) : ("user" as const),
|
||||
@@ -59,16 +73,14 @@ export const selectChatEnv = async ({
|
||||
const agent = new Agent({
|
||||
id: "autumn-chat-env",
|
||||
name: "Autumn Chat Env",
|
||||
instructions:
|
||||
"Choose the Autumn environment for the latest user request. Default to live. Use sandbox only when the user clearly intends sandbox or test-mode usage.",
|
||||
instructions: `Choose the Autumn environment for the latest user request. Default to ${getDefaultChatEnv()}. Use the other environment only when the user clearly asks for it.`,
|
||||
model: chatEnv.CHAT_MODEL,
|
||||
});
|
||||
const output = await agent.generate(message, {
|
||||
maxSteps: 1,
|
||||
structuredOutput: {
|
||||
schema: envSelectionSchema,
|
||||
instructions:
|
||||
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
|
||||
instructions: `Return ${getDefaultChatEnv()} unless the latest user request clearly asks to use the other environment.`,
|
||||
},
|
||||
context: [...recentMessageContext(recentMessages)],
|
||||
});
|
||||
@@ -82,7 +94,7 @@ export const selectChatEnv = async ({
|
||||
|
||||
const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
|
||||
const resources = await Promise.allSettled(
|
||||
docs.map((uri) => mcp.resources.read("autumn", uri)),
|
||||
agentDocUris.map((uri) => mcp.resources.read("autumn", uri)),
|
||||
);
|
||||
return resources
|
||||
.flatMap((result) =>
|
||||
@@ -109,7 +121,7 @@ export const runChatAgent = async ({
|
||||
token: string;
|
||||
env: AppEnv;
|
||||
logger?: AutumnLogger;
|
||||
message: string;
|
||||
message: MessageListInput;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
|
||||
139
apps/leaf/src/agent/attachments.ts
Normal file
139
apps/leaf/src/agent/attachments.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import type { MessageListInput } from "@mastra/core/agent/message-list";
|
||||
import type { Attachment } from "chat";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
|
||||
const MAX_ATTACHMENTS = 4;
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
const SUPPORTED_MIME_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
type AttachmentFetchFallback = ({
|
||||
attachment,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
}) => Promise<Buffer | null>;
|
||||
|
||||
const getAttachmentLabel = (attachment: Attachment) =>
|
||||
attachment.name ?? attachment.mimeType ?? "unnamed attachment";
|
||||
|
||||
const isSupportedAttachment = (attachment: Attachment) =>
|
||||
attachment.mimeType ? SUPPORTED_MIME_TYPES.has(attachment.mimeType) : false;
|
||||
|
||||
const fetchAttachmentData = async ({
|
||||
attachment,
|
||||
fetchFallback,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
fetchFallback?: AttachmentFetchFallback;
|
||||
}) => {
|
||||
if (attachment.data) {
|
||||
return attachment.data instanceof Blob
|
||||
? Buffer.from(await attachment.data.arrayBuffer())
|
||||
: Buffer.from(attachment.data);
|
||||
}
|
||||
if (attachment.fetchData) return attachment.fetchData();
|
||||
return fetchFallback?.({ attachment }) ?? null;
|
||||
};
|
||||
|
||||
export const prepareAttachmentMessage = async ({
|
||||
attachments = [],
|
||||
fetchFallback,
|
||||
logger = rootLogger,
|
||||
text,
|
||||
}: {
|
||||
attachments?: Attachment[];
|
||||
fetchFallback?: AttachmentFetchFallback;
|
||||
logger?: AutumnLogger;
|
||||
text: string;
|
||||
}) => {
|
||||
const notes: string[] = [];
|
||||
const parts: Array<{
|
||||
data: Buffer;
|
||||
filename?: string;
|
||||
mediaType: string;
|
||||
type: "file";
|
||||
}> = [];
|
||||
|
||||
for (const attachment of attachments.slice(0, MAX_ATTACHMENTS)) {
|
||||
const label = getAttachmentLabel(attachment);
|
||||
if (!isSupportedAttachment(attachment)) {
|
||||
notes.push(`Skipped ${label}: unsupported file type.`);
|
||||
continue;
|
||||
}
|
||||
if (attachment.size && attachment.size > MAX_ATTACHMENT_BYTES) {
|
||||
notes.push(`Skipped ${label}: file is too large.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAttachmentData({ attachment, fetchFallback });
|
||||
if (!data) {
|
||||
notes.push(`Skipped ${label}: file could not be downloaded.`);
|
||||
continue;
|
||||
}
|
||||
if (data.byteLength > MAX_ATTACHMENT_BYTES) {
|
||||
notes.push(`Skipped ${label}: downloaded file is too large.`);
|
||||
continue;
|
||||
}
|
||||
parts.push({
|
||||
type: "file",
|
||||
data,
|
||||
filename: attachment.name,
|
||||
mediaType: attachment.mimeType as string,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Could not prepare Slack attachment", {
|
||||
event: "leaf.slack_attachment_prepare_failed",
|
||||
data: {
|
||||
name: attachment.name,
|
||||
mime_type: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
},
|
||||
error,
|
||||
});
|
||||
notes.push(`Skipped ${label}: file could not be processed.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length > MAX_ATTACHMENTS) {
|
||||
notes.push(
|
||||
`Skipped ${attachments.length - MAX_ATTACHMENTS} extra attachment(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
const userText = [
|
||||
text.trim() || "Please answer using the attached Slack file(s).",
|
||||
notes.length ? `Attachment processing notes:\n${notes.join("\n")}` : null,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n\n");
|
||||
const message = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [...parts, { type: "text" as const, text: userText }],
|
||||
},
|
||||
] satisfies MessageListInput;
|
||||
|
||||
return {
|
||||
attachmentCount: parts.length,
|
||||
envSelectionText: [
|
||||
text,
|
||||
attachments.length
|
||||
? `Slack attachments: ${attachments
|
||||
.map((attachment) => getAttachmentLabel(attachment))
|
||||
.join(", ")}`
|
||||
: null,
|
||||
notes.length ? `Attachment notes: ${notes.join(" ")}` : null,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n\n"),
|
||||
message,
|
||||
notes,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { getInstallationOAuthAccessToken } from "../internal/installations/actio
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
import { agentOutputSchema, type BotMessage } from "../types.js";
|
||||
import { runChatAgent, selectChatEnv } from "./agent.js";
|
||||
import { prepareAttachmentMessage } from "./attachments.js";
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
@@ -13,6 +14,8 @@ const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
||||
});
|
||||
|
||||
export const runMessage = async ({
|
||||
attachmentFetchFallback,
|
||||
attachments,
|
||||
installation,
|
||||
logger = rootLogger,
|
||||
onAction,
|
||||
@@ -22,8 +25,14 @@ export const runMessage = async ({
|
||||
}: BotMessage) =>
|
||||
withTimeout(
|
||||
(async () => {
|
||||
const preparedMessage = await prepareAttachmentMessage({
|
||||
attachments,
|
||||
fetchFallback: attachmentFetchFallback,
|
||||
logger,
|
||||
text,
|
||||
});
|
||||
const env = await selectChatEnv({
|
||||
message: text,
|
||||
message: preparedMessage.envSelectionText,
|
||||
recentMessages,
|
||||
logger,
|
||||
});
|
||||
@@ -44,7 +53,7 @@ export const runMessage = async ({
|
||||
token,
|
||||
env,
|
||||
logger,
|
||||
message: text,
|
||||
message: preparedMessage.message,
|
||||
onAction,
|
||||
threadId,
|
||||
resourceId: installation.org_id,
|
||||
|
||||
89
apps/leaf/src/approvals/errors.ts
Normal file
89
apps/leaf/src/approvals/errors.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 700;
|
||||
|
||||
const cleanMessage = (message: string) =>
|
||||
message
|
||||
.replace(/^Error:\s*/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const truncateMessage = (message: string) =>
|
||||
message.length > MAX_ERROR_MESSAGE_LENGTH
|
||||
? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 1)}…`
|
||||
: message;
|
||||
|
||||
const parseAutumnApiErrorMessage = (message: string) => {
|
||||
const match = message.match(/Autumn API request failed \(\d+\):\s*(.+)$/s);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[1] ?? "");
|
||||
return typeof parsed?.message === "string" ? parsed.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMcpContentText = (value: Record<string, unknown>): string | null => {
|
||||
if (!Array.isArray(value.content)) return null;
|
||||
const item = value.content.find((entry): entry is { text: string } =>
|
||||
Boolean(
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"text" in entry &&
|
||||
typeof entry.text === "string",
|
||||
),
|
||||
);
|
||||
return item?.text ?? null;
|
||||
};
|
||||
|
||||
const getObjectMessage = (value: Record<string, unknown>): string | null => {
|
||||
if (typeof value.message === "string") return value.message;
|
||||
if (typeof value.error === "string") return value.error;
|
||||
if (
|
||||
value.error &&
|
||||
typeof value.error === "object" &&
|
||||
typeof (value.error as { message?: unknown }).message === "string"
|
||||
) {
|
||||
return (value.error as { message: string }).message;
|
||||
}
|
||||
if (
|
||||
value.details &&
|
||||
typeof value.details === "object" &&
|
||||
typeof (value.details as { errorMessage?: unknown }).errorMessage ===
|
||||
"string"
|
||||
) {
|
||||
return (value.details as { errorMessage: string }).errorMessage;
|
||||
}
|
||||
const contentText = getMcpContentText(value);
|
||||
if (!contentText) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(contentText);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return getObjectMessage(parsed as Record<string, unknown>) ?? contentText;
|
||||
}
|
||||
} catch {
|
||||
return contentText;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const approvalErrorResult = (error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: error && typeof error === "object"
|
||||
? (getObjectMessage(error as Record<string, unknown>) ??
|
||||
"The action failed.")
|
||||
: "The action failed.";
|
||||
const cleanedRawMessage = cleanMessage(rawMessage);
|
||||
const message = cleanMessage(
|
||||
parseAutumnApiErrorMessage(cleanedRawMessage) ?? cleanedRawMessage,
|
||||
);
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: truncateMessage(message || "The action failed."),
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type LoadingState,
|
||||
type ReplyTarget,
|
||||
} from "../ui/progress.js";
|
||||
import { approvalErrorResult } from "./errors.js";
|
||||
import { approvalRequestFromOutput } from "./request.js";
|
||||
import {
|
||||
approveAndRun,
|
||||
@@ -83,9 +84,6 @@ const detailsFromApproval = (approval?: ChatApproval) => ({
|
||||
env: approval?.env,
|
||||
});
|
||||
|
||||
const approvalDetails = async (id: string) =>
|
||||
detailsFromApproval(await getApproval(id));
|
||||
|
||||
const editActionMessage = async (
|
||||
event: ActionEvent,
|
||||
content: Parameters<NonNullable<ActionEvent["adapter"]["editMessage"]>>[2],
|
||||
@@ -93,6 +91,22 @@ const editActionMessage = async (
|
||||
await event.adapter.editMessage?.(event.threadId, event.messageId, content);
|
||||
};
|
||||
|
||||
type ApprovalActionDeps = {
|
||||
approveAndRun: typeof approveAndRun;
|
||||
cancelApproval: typeof cancelApproval;
|
||||
editActionMessage: typeof editActionMessage;
|
||||
getApproval: typeof getApproval;
|
||||
logger: Pick<AutumnLogger, "error" | "info" | "warn">;
|
||||
};
|
||||
|
||||
const defaultApprovalActionDeps = {
|
||||
approveAndRun,
|
||||
cancelApproval,
|
||||
editActionMessage,
|
||||
getApproval,
|
||||
logger: rootLogger,
|
||||
} satisfies ApprovalActionDeps;
|
||||
|
||||
const cardStatusForApproval = (
|
||||
status?: string,
|
||||
): "approved" | "cancelled" | "failed" | "running" => {
|
||||
@@ -101,11 +115,14 @@ const cardStatusForApproval = (
|
||||
return "failed";
|
||||
};
|
||||
|
||||
export const handleApprovalAction = async (event: ActionEvent) => {
|
||||
export const handleApprovalActionWithDeps = async (
|
||||
event: ActionEvent,
|
||||
deps: ApprovalActionDeps = defaultApprovalActionDeps,
|
||||
) => {
|
||||
if (!event.value) return;
|
||||
|
||||
try {
|
||||
rootLogger.info("Received approval action", {
|
||||
deps.logger.info("Received approval action", {
|
||||
event: "leaf.approval_action_received",
|
||||
approval_id: event.value,
|
||||
action: event.actionId,
|
||||
@@ -113,16 +130,19 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
||||
provider_user_id: event.user.userId,
|
||||
},
|
||||
});
|
||||
const details = await approvalDetails(event.value);
|
||||
const details = detailsFromApproval(await deps.getApproval(event.value));
|
||||
if (event.actionId === "cancel_billing_action") {
|
||||
const cancelled = await cancelApproval(event.value, event.user.userId);
|
||||
const cancelled = await deps.cancelApproval(
|
||||
event.value,
|
||||
event.user.userId,
|
||||
);
|
||||
if (!cancelled) {
|
||||
rootLogger.warn("Approval cancellation ignored", {
|
||||
deps.logger.warn("Approval cancellation ignored", {
|
||||
event: "leaf.approval_cancel_ignored",
|
||||
approval_id: event.value,
|
||||
});
|
||||
const current = await getApproval(event.value);
|
||||
await editActionMessage(
|
||||
const current = await deps.getApproval(event.value);
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({
|
||||
status: cardStatusForApproval(current?.status),
|
||||
@@ -131,11 +151,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await editActionMessage(
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({ status: "cancelled", ...details }),
|
||||
);
|
||||
rootLogger.info("Cancelled approval", {
|
||||
deps.logger.info("Cancelled approval", {
|
||||
event: "leaf.approval_cancelled",
|
||||
approval_id: event.value,
|
||||
tool: details.toolName,
|
||||
@@ -143,18 +163,18 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await editActionMessage(
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({ status: "running", ...details }),
|
||||
);
|
||||
const result = await approveAndRun(event.value, event.user.userId);
|
||||
rootLogger.info("Completed approval action", {
|
||||
const result = await deps.approveAndRun(event.value, event.user.userId);
|
||||
deps.logger.info("Completed approval action", {
|
||||
event: "leaf.approval_completed",
|
||||
approval_id: event.value,
|
||||
status: isErrorResult(result) ? "failed" : "approved",
|
||||
tool: details.toolName,
|
||||
});
|
||||
await editActionMessage(
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({
|
||||
status: isErrorResult(result) ? "failed" : "approved",
|
||||
@@ -163,18 +183,22 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rootLogger.error("[chat] Approval action failed", error, {
|
||||
deps.logger.error("[chat] Approval action failed", error, {
|
||||
event: "leaf.approval_failed",
|
||||
approval_id: event.value,
|
||||
action: event.actionId,
|
||||
});
|
||||
const current = await getApproval(event.value);
|
||||
await editActionMessage(
|
||||
const current = await deps.getApproval(event.value);
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({
|
||||
status: cardStatusForApproval(current?.status),
|
||||
...detailsFromApproval(current),
|
||||
result: approvalErrorResult(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleApprovalAction = async (event: ActionEvent) =>
|
||||
handleApprovalActionWithDeps(event);
|
||||
|
||||
@@ -10,12 +10,19 @@ import { and, eq, gt } from "drizzle-orm";
|
||||
import { executeAutumnMcpTool } from "../agent/mcp.js";
|
||||
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
|
||||
import { db } from "../lib/db.js";
|
||||
import { approvalErrorResult } from "./errors.js";
|
||||
|
||||
export const normalizeToolName = (toolName: string) =>
|
||||
toolName.replace(/^autumn_/, "");
|
||||
|
||||
export const isErrorResult = (result: unknown): boolean =>
|
||||
typeof result === "object" && result !== null && "error" in result;
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
("error" in result ||
|
||||
(result as { isError?: unknown }).isError === true ||
|
||||
(result as { id?: unknown }).id === "TOOL_EXECUTION_FAILED" ||
|
||||
(typeof (result as { code?: unknown }).code === "string" &&
|
||||
typeof (result as { message?: unknown }).message === "string"));
|
||||
|
||||
export const createApproval = async ({
|
||||
orgId,
|
||||
@@ -125,12 +132,15 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
|
||||
env: claimed.env,
|
||||
});
|
||||
|
||||
const result = await executeAutumnMcpTool({
|
||||
const rawResult = await executeAutumnMcpTool({
|
||||
token,
|
||||
env: claimed.env,
|
||||
toolName: claimed.tool_name,
|
||||
args: claimed.tool_args,
|
||||
});
|
||||
const result = isErrorResult(rawResult)
|
||||
? approvalErrorResult(rawResult)
|
||||
: rawResult;
|
||||
await db
|
||||
.update(chatApprovals)
|
||||
.set({
|
||||
@@ -141,6 +151,7 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
|
||||
.where(eq(chatApprovals.id, id));
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = approvalErrorResult(error);
|
||||
await db
|
||||
.update(chatApprovals)
|
||||
.set({
|
||||
@@ -149,6 +160,6 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
|
||||
decided_by_provider_user_id: providerUserId,
|
||||
})
|
||||
.where(eq(chatApprovals.id, id));
|
||||
throw error;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlackAdapter } from "@chat-adapter/slack";
|
||||
import { createPostgresState } from "@chat-adapter/state-pg";
|
||||
import type { Message, Thread } from "chat";
|
||||
import type { Attachment, Message, Thread } from "chat";
|
||||
import { Chat } from "chat";
|
||||
import { runMessage } from "./agent/messages.js";
|
||||
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
logger as rootLogger,
|
||||
} from "./lib/logger.js";
|
||||
import { getSlackWorkspaceId } from "./providers/slack/context.js";
|
||||
import {
|
||||
fetchSlackAttachmentFallback,
|
||||
getSlackFilesFromRaw,
|
||||
} from "./providers/slack/files.js";
|
||||
import { findInstallation } from "./providers/slack/installations.js";
|
||||
import { getRecentMessages } from "./providers/slack/threadContext.js";
|
||||
import type { ChatContextMessage } from "./types.js";
|
||||
@@ -71,6 +75,7 @@ export const bot = new Chat({
|
||||
|
||||
const runAndReply = async ({
|
||||
channelId,
|
||||
attachments,
|
||||
providerUserId,
|
||||
raw,
|
||||
recentMessages,
|
||||
@@ -78,6 +83,7 @@ const runAndReply = async ({
|
||||
text,
|
||||
threadId,
|
||||
}: {
|
||||
attachments?: Attachment[];
|
||||
channelId: string;
|
||||
providerUserId: string;
|
||||
raw: unknown;
|
||||
@@ -114,10 +120,11 @@ const runAndReply = async ({
|
||||
logger.info("Received Slack message", {
|
||||
event: "leaf.slack_message_received",
|
||||
data: {
|
||||
attachment_count: attachments?.length ?? 0,
|
||||
text_length: text.length,
|
||||
},
|
||||
});
|
||||
if (!text.trim()) {
|
||||
if (!text.trim() && !attachments?.length) {
|
||||
logger.info("Skipping empty Slack message", {
|
||||
event: "leaf.slack_message_skipped",
|
||||
data: { reason: "empty" },
|
||||
@@ -127,7 +134,16 @@ const runAndReply = async ({
|
||||
|
||||
loading = await startLoading(target);
|
||||
const logAction = createActionLogger(loading);
|
||||
const rawFiles = getSlackFilesFromRaw({ raw });
|
||||
const botToken = decrypt(installation.bot_access_token);
|
||||
const output = await runMessage({
|
||||
attachmentFetchFallback: ({ attachment }) =>
|
||||
fetchSlackAttachmentFallback({
|
||||
attachment,
|
||||
botToken,
|
||||
rawFiles,
|
||||
}),
|
||||
attachments,
|
||||
installation,
|
||||
logger,
|
||||
onAction: logAction,
|
||||
@@ -170,6 +186,7 @@ const runAndReply = async ({
|
||||
const handleMessage = async (thread: Thread, message: Message) => {
|
||||
await runAndReply({
|
||||
target: thread,
|
||||
attachments: message.attachments,
|
||||
raw: message.raw,
|
||||
text: message.text,
|
||||
channelId: thread.channelId,
|
||||
|
||||
105
apps/leaf/src/providers/slack/files.ts
Normal file
105
apps/leaf/src/providers/slack/files.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Attachment } from "chat";
|
||||
|
||||
const SLACK_FILES_INFO_URL = "https://slack.com/api/files.info";
|
||||
|
||||
type SlackRawFile = {
|
||||
id?: string;
|
||||
mimetype?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
url_private?: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const parseSlackFile = (value: unknown): SlackRawFile | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
id: typeof value.id === "string" ? value.id : undefined,
|
||||
mimetype: typeof value.mimetype === "string" ? value.mimetype : undefined,
|
||||
name: typeof value.name === "string" ? value.name : undefined,
|
||||
size: typeof value.size === "number" ? value.size : undefined,
|
||||
url_private:
|
||||
typeof value.url_private === "string" ? value.url_private : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSlackFilesFromRaw = ({ raw }: { raw: unknown }) => {
|
||||
if (!isRecord(raw) || !Array.isArray(raw.files)) return [];
|
||||
return raw.files.flatMap((file) => {
|
||||
const parsed = parseSlackFile(file);
|
||||
return parsed ? [parsed] : [];
|
||||
});
|
||||
};
|
||||
|
||||
const findRawFileForAttachment = ({
|
||||
attachment,
|
||||
files,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
files: SlackRawFile[];
|
||||
}) =>
|
||||
files.find(
|
||||
(file) =>
|
||||
file.name === attachment.name &&
|
||||
file.mimetype === attachment.mimeType &&
|
||||
file.size === attachment.size,
|
||||
) ?? files.find((file) => file.name === attachment.name);
|
||||
|
||||
const fetchSlackPrivateUrl = async ({
|
||||
botToken,
|
||||
url,
|
||||
}: {
|
||||
botToken: string;
|
||||
url: string;
|
||||
}) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack file download failed: ${response.status}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
};
|
||||
|
||||
const fetchSlackFileInfoUrl = async ({
|
||||
botToken,
|
||||
fileId,
|
||||
}: {
|
||||
botToken: string;
|
||||
fileId: string;
|
||||
}) => {
|
||||
const url = new URL(SLACK_FILES_INFO_URL);
|
||||
url.searchParams.set("file", fileId);
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Slack files.info failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (!isRecord(data) || data.ok !== true || !isRecord(data.file)) return null;
|
||||
return typeof data.file.url_private === "string"
|
||||
? data.file.url_private
|
||||
: null;
|
||||
};
|
||||
|
||||
export const fetchSlackAttachmentFallback = async ({
|
||||
attachment,
|
||||
botToken,
|
||||
rawFiles,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
botToken: string;
|
||||
rawFiles: SlackRawFile[];
|
||||
}) => {
|
||||
const rawFile = findRawFileForAttachment({ attachment, files: rawFiles });
|
||||
if (!rawFile) return null;
|
||||
const url =
|
||||
rawFile.url_private ??
|
||||
(rawFile.id
|
||||
? await fetchSlackFileInfoUrl({ botToken, fileId: rawFile.id })
|
||||
: null);
|
||||
if (!url) return null;
|
||||
return fetchSlackPrivateUrl({ botToken, url });
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppEnv, type ChatInstallation } from "@autumn/shared";
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import { AppEnv, type ChatInstallation } from "@autumn/shared";
|
||||
import type { Attachment } from "chat";
|
||||
import { z } from "zod";
|
||||
|
||||
export const agentOutputSchema = z.preprocess(
|
||||
@@ -62,6 +63,10 @@ export type SignatureArgs = {
|
||||
};
|
||||
|
||||
export type BotMessage = {
|
||||
attachmentFetchFallback?: (params: {
|
||||
attachment: Attachment;
|
||||
}) => Promise<Buffer | null>;
|
||||
attachments?: Attachment[];
|
||||
installation: ChatInstallation;
|
||||
logger?: AutumnLogger;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/postgres";
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://postgres:postgres@localhost:5432/postgres";
|
||||
process.env.ENCRYPTION_PASSWORD ??= "test";
|
||||
process.env.SLACK_CLIENT_ID ??= "test";
|
||||
process.env.SLACK_CLIENT_SECRET ??= "test";
|
||||
process.env.SLACK_SIGNING_SECRET ??= "test";
|
||||
process.env.FIRECRAWL_API_KEY ??= "fc_test";
|
||||
|
||||
const { selectChatEnv } = await import("../../../src/agent/agent.js");
|
||||
const { createFirecrawlTools } = await import("../../../src/agent/firecrawl.js");
|
||||
const { agentDocUris, getDefaultChatEnv, selectChatEnv } = await import(
|
||||
"../../../src/agent/agent.js"
|
||||
);
|
||||
const { createFirecrawlTools } = await import(
|
||||
"../../../src/agent/firecrawl.js"
|
||||
);
|
||||
|
||||
const execute = async (
|
||||
tool: { execute?: (...args: never[]) => Promise<unknown> } | undefined,
|
||||
@@ -19,7 +24,43 @@ const execute = async (
|
||||
return tool.execute(input as never, {} as never);
|
||||
};
|
||||
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
describe("chat environment selection", () => {
|
||||
test("loads feature catalog MCP guidance", () => {
|
||||
expect(agentDocUris).toContain("autumn://docs/feature-catalog");
|
||||
});
|
||||
|
||||
test("loads request-log MCP guidance", () => {
|
||||
expect(agentDocUris).toContain("autumn://docs/request-logs");
|
||||
expect(agentDocUris).toContain("autumn://docs/request-log-customers");
|
||||
expect(agentDocUris).toContain("autumn://docs/request-log-balances");
|
||||
expect(agentDocUris).toContain("autumn://docs/request-log-billing");
|
||||
expect(agentDocUris).toContain("autumn://docs/request-log-stripe-webhooks");
|
||||
expect(agentDocUris).toContain("autumn://docs/request-log-analytics");
|
||||
});
|
||||
|
||||
test("defaults to sandbox outside production", () => {
|
||||
delete process.env.NODE_ENV;
|
||||
expect(getDefaultChatEnv()).toBe(AppEnv.Sandbox);
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
expect(getDefaultChatEnv()).toBe(AppEnv.Sandbox);
|
||||
});
|
||||
|
||||
test("defaults to live in production", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
expect(getDefaultChatEnv()).toBe(AppEnv.Live);
|
||||
});
|
||||
|
||||
test("uses live from structured model output", async () => {
|
||||
await expect(
|
||||
selectChatEnv({
|
||||
|
||||
116
apps/leaf/tests/unit/agent/attachments.test.ts
Normal file
116
apps/leaf/tests/unit/agent/attachments.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Attachment } from "chat";
|
||||
import { prepareAttachmentMessage } from "../../../src/agent/attachments.js";
|
||||
|
||||
const getUserContent = async (
|
||||
params: Parameters<typeof prepareAttachmentMessage>[0],
|
||||
) => {
|
||||
const prepared = await prepareAttachmentMessage(params);
|
||||
const [message] = prepared.message as Array<{
|
||||
content: Array<Record<string, unknown>>;
|
||||
role: string;
|
||||
}>;
|
||||
return { ...prepared, content: message.content };
|
||||
};
|
||||
|
||||
describe("Slack attachment message preparation", () => {
|
||||
test("adds PDFs as file parts", async () => {
|
||||
const attachment = {
|
||||
data: Buffer.from("pdf"),
|
||||
mimeType: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 3,
|
||||
type: "file",
|
||||
} satisfies Attachment;
|
||||
|
||||
const { attachmentCount, content } = await getUserContent({
|
||||
attachments: [attachment],
|
||||
text: "please provision this",
|
||||
});
|
||||
|
||||
expect(attachmentCount).toBe(1);
|
||||
expect(content[0]).toMatchObject({
|
||||
filename: "contract.pdf",
|
||||
mediaType: "application/pdf",
|
||||
type: "file",
|
||||
});
|
||||
expect(content[1]).toMatchObject({
|
||||
text: "please provision this",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("adds images as file parts", async () => {
|
||||
const attachment = {
|
||||
fetchData: async () => Buffer.from("png"),
|
||||
mimeType: "image/png",
|
||||
name: "screenshot.png",
|
||||
size: 3,
|
||||
type: "image",
|
||||
} satisfies Attachment;
|
||||
|
||||
const { attachmentCount, content } = await getUserContent({
|
||||
attachments: [attachment],
|
||||
text: "",
|
||||
});
|
||||
|
||||
expect(attachmentCount).toBe(1);
|
||||
expect(content[0]).toMatchObject({
|
||||
filename: "screenshot.png",
|
||||
mediaType: "image/png",
|
||||
type: "file",
|
||||
});
|
||||
expect(content[1]).toMatchObject({
|
||||
text: "Please answer using the attached Slack file(s).",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses fallback download when adapter fetchData is unavailable", async () => {
|
||||
const attachment = {
|
||||
mimeType: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 3,
|
||||
type: "file",
|
||||
} satisfies Attachment;
|
||||
|
||||
const { attachmentCount } = await prepareAttachmentMessage({
|
||||
attachments: [attachment],
|
||||
fetchFallback: async ({ attachment: fallbackAttachment }) => {
|
||||
expect(fallbackAttachment.name).toBe("contract.pdf");
|
||||
return Buffer.from("pdf");
|
||||
},
|
||||
text: "read this",
|
||||
});
|
||||
|
||||
expect(attachmentCount).toBe(1);
|
||||
});
|
||||
|
||||
test("skips unsupported and oversized attachments with notes", async () => {
|
||||
const attachments = [
|
||||
{
|
||||
mimeType: "application/zip",
|
||||
name: "archive.zip",
|
||||
size: 1,
|
||||
type: "file",
|
||||
},
|
||||
{
|
||||
mimeType: "application/pdf",
|
||||
name: "huge.pdf",
|
||||
size: 21 * 1024 * 1024,
|
||||
type: "file",
|
||||
},
|
||||
] satisfies Attachment[];
|
||||
|
||||
const { attachmentCount, notes } = await prepareAttachmentMessage({
|
||||
attachments,
|
||||
text: "read these",
|
||||
});
|
||||
|
||||
expect(attachmentCount).toBe(0);
|
||||
expect(notes).toEqual([
|
||||
"Skipped archive.zip: unsupported file type.",
|
||||
"Skipped huge.pdf: file is too large.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,19 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppEnv, type ChatApproval } from "@autumn/shared";
|
||||
import type { ActionEvent } from "chat";
|
||||
import { approvalErrorResult } from "../../../src/approvals/errors.js";
|
||||
import { approvalRequestFromOutput } from "../../../src/approvals/request.js";
|
||||
import type { AgentOutput } from "../../../src/types.js";
|
||||
|
||||
const setLeafTestEnv = () => {
|
||||
process.env.DATABASE_URL ??= "postgres://postgres:postgres@localhost:5432/db";
|
||||
process.env.ENCRYPTION_PASSWORD ??= "test-password";
|
||||
process.env.FIRECRAWL_API_KEY ??= "test-firecrawl-key";
|
||||
process.env.SLACK_CLIENT_ID ??= "test-slack-client-id";
|
||||
process.env.SLACK_CLIENT_SECRET ??= "test-slack-client-secret";
|
||||
process.env.SLACK_SIGNING_SECRET ??= "test-slack-signing-secret";
|
||||
};
|
||||
|
||||
describe("approval flow", () => {
|
||||
test("maps suspended destructive tool output to a pending approval request", () => {
|
||||
const request = approvalRequestFromOutput({
|
||||
@@ -44,4 +55,116 @@ describe("approval flow", () => {
|
||||
preview: { total: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
test("formats Autumn API errors for Slack approval cards", () => {
|
||||
const result = approvalErrorResult(
|
||||
new Error(
|
||||
'Autumn API request failed (400): {"message":"(Stripe Error) Missing email. In order to create invoices that are sent to the customer, the customer must have a valid email.","code":"stripe_error","env":"sandbox"}',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: true,
|
||||
message:
|
||||
"(Stripe Error) Missing email. In order to create invoices that are sent to the customer, the customer must have a valid email.",
|
||||
});
|
||||
});
|
||||
|
||||
test("formats returned tool failure objects for Slack approval cards", () => {
|
||||
const result = approvalErrorResult({
|
||||
id: "TOOL_EXECUTION_FAILED",
|
||||
error: {
|
||||
message:
|
||||
'Autumn API request failed (400): {"message":"Missing email.","code":"stripe_error"}',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: true,
|
||||
message: "Missing email.",
|
||||
});
|
||||
});
|
||||
|
||||
test("formats MCP isError responses for Slack approval cards", () => {
|
||||
const result = approvalErrorResult({
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
id: "TOOL_EXECUTION_FAILED",
|
||||
details: {
|
||||
errorMessage:
|
||||
'Error: Autumn API request failed (404): {"message":"Feature definitely_missing_feature_123 not found","code":"feature_not_found","env":"sandbox"}',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: true,
|
||||
message: "Feature definitely_missing_feature_123 not found",
|
||||
});
|
||||
});
|
||||
|
||||
test("detects MCP isError responses as failed tool results", async () => {
|
||||
setLeafTestEnv();
|
||||
const { isErrorResult } = await import("../../../src/approvals/store.js");
|
||||
|
||||
expect(
|
||||
isErrorResult({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Tool failed" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("edits the approval message to failed when the approved tool fails", async () => {
|
||||
setLeafTestEnv();
|
||||
const { handleApprovalActionWithDeps } = await import(
|
||||
"../../../src/approvals/flow.js"
|
||||
);
|
||||
const edits: unknown[] = [];
|
||||
const approval = {
|
||||
env: AppEnv.Sandbox,
|
||||
status: "pending",
|
||||
tool_name: "attach",
|
||||
tool_args: {
|
||||
request: {
|
||||
customer_id: "cus_1",
|
||||
plan_id: "pro",
|
||||
},
|
||||
},
|
||||
} as unknown as ChatApproval;
|
||||
const event = {
|
||||
actionId: "approve_billing_action",
|
||||
messageId: "message_1",
|
||||
threadId: "thread_1",
|
||||
user: { userId: "U1" },
|
||||
value: "approval_1",
|
||||
} as unknown as ActionEvent;
|
||||
|
||||
await handleApprovalActionWithDeps(event, {
|
||||
approveAndRun: async () => ({
|
||||
error: true,
|
||||
message: "Missing email.",
|
||||
}),
|
||||
cancelApproval: async () => approval,
|
||||
editActionMessage: async (_event, content) => {
|
||||
edits.push(content);
|
||||
},
|
||||
getApproval: async () => approval,
|
||||
logger: {
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(edits).toHaveLength(2);
|
||||
expect(JSON.stringify(edits[0])).toContain("Applying the approved action");
|
||||
expect(JSON.stringify(edits[1])).toContain("Attach plan failed");
|
||||
expect(JSON.stringify(edits[1])).toContain("Missing email.");
|
||||
});
|
||||
});
|
||||
|
||||
107
apps/leaf/tests/unit/providers/slack/files.test.ts
Normal file
107
apps/leaf/tests/unit/providers/slack/files.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { Attachment } from "chat";
|
||||
import {
|
||||
fetchSlackAttachmentFallback,
|
||||
getSlackFilesFromRaw,
|
||||
} from "../../../../src/providers/slack/files.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("Slack file helpers", () => {
|
||||
test("extracts Slack file metadata from raw messages", () => {
|
||||
expect(
|
||||
getSlackFilesFromRaw({
|
||||
raw: {
|
||||
files: [
|
||||
{
|
||||
id: "F1",
|
||||
mimetype: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 123,
|
||||
url_private: "https://files.slack.com/contract.pdf",
|
||||
},
|
||||
null,
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "F1",
|
||||
mimetype: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 123,
|
||||
url_private: "https://files.slack.com/contract.pdf",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("downloads fallback Slack private URLs with bot auth", async () => {
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
expect(String(url)).toBe("https://files.slack.com/contract.pdf");
|
||||
expect(init?.headers).toEqual({ Authorization: "Bearer xoxb-test" });
|
||||
return new Response("pdf");
|
||||
}) as typeof fetch;
|
||||
|
||||
const data = await fetchSlackAttachmentFallback({
|
||||
attachment: {
|
||||
mimeType: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 3,
|
||||
type: "file",
|
||||
} satisfies Attachment,
|
||||
botToken: "xoxb-test",
|
||||
rawFiles: [
|
||||
{
|
||||
id: "F1",
|
||||
mimetype: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 3,
|
||||
url_private: "https://files.slack.com/contract.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(data?.toString()).toBe("pdf");
|
||||
});
|
||||
|
||||
test("looks up url_private with files.info when raw URL is missing", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
calls.push(String(url));
|
||||
expect(init?.headers).toEqual({ Authorization: "Bearer xoxb-test" });
|
||||
if (String(url).startsWith("https://slack.com/api/files.info")) {
|
||||
return Response.json({
|
||||
ok: true,
|
||||
file: { url_private: "https://files.slack.com/contract.pdf" },
|
||||
});
|
||||
}
|
||||
return new Response("pdf");
|
||||
}) as typeof fetch;
|
||||
|
||||
const data = await fetchSlackAttachmentFallback({
|
||||
attachment: {
|
||||
mimeType: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 3,
|
||||
type: "file",
|
||||
} satisfies Attachment,
|
||||
botToken: "xoxb-test",
|
||||
rawFiles: [
|
||||
{
|
||||
id: "F1",
|
||||
mimetype: "application/pdf",
|
||||
name: "contract.pdf",
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(data?.toString()).toBe("pdf");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]).toContain("file=F1");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { approvalCard, approvalStatusCard } from "../../../src/ui/blocks.js";
|
||||
|
||||
describe("approval card", () => {
|
||||
@@ -49,6 +49,22 @@ describe("approval card", () => {
|
||||
expect(JSON.stringify(card)).not.toContain("validationErrors");
|
||||
});
|
||||
|
||||
test("renders failed execution errors in approval status cards", () => {
|
||||
const card = approvalStatusCard({
|
||||
status: "failed",
|
||||
toolName: "attach",
|
||||
result: {
|
||||
error: true,
|
||||
message: "Missing email.",
|
||||
},
|
||||
});
|
||||
|
||||
const json = JSON.stringify(card);
|
||||
expect(card.title).toBe("Attach plan failed");
|
||||
expect(json).toContain("Missing email.");
|
||||
expect(json).not.toContain('"error"');
|
||||
});
|
||||
|
||||
test("does not render raw request JSON as preview text", () => {
|
||||
const card = approvalCard({
|
||||
id: "approval_1",
|
||||
|
||||
@@ -14,7 +14,8 @@ Use this for external MCP clients that should call Autumn operations directly.
|
||||
Tools:
|
||||
|
||||
- `listCustomers`
|
||||
- `createCustomer`
|
||||
- `getOrCreateCustomer`
|
||||
- `updateCustomer`
|
||||
- `getCustomer`
|
||||
- `listPlans`
|
||||
- `createPlan`
|
||||
|
||||
36
packages/mcp/src/resources/balances/standalone-balances.md
Normal file
36
packages/mcp/src/resources/balances/standalone-balances.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: balances
|
||||
title: Standalone Balances
|
||||
description: How to create standalone, expiring, and entity-scoped balance grants.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Standalone Balances
|
||||
|
||||
Use previewCreateBalance and createBalance for standalone grants that are independent of a plan, such as promotional credits, referral credits, manual adjustments, or one-time entity-scoped grants.
|
||||
|
||||
Required fields:
|
||||
- customer_id: parent customer receiving the grant
|
||||
- feature_id: the balance feature, usually the credit pool such as "credits"
|
||||
- included_grant: amount to grant
|
||||
|
||||
Optional fields:
|
||||
- entity_id: scope the balance to one entity/workspace/user under the customer
|
||||
- expires_at: expiry timestamp as UTC epoch milliseconds
|
||||
- balance_id: stable id for later update/delete targeting
|
||||
|
||||
Rules:
|
||||
- For "50k credits", use included_grant: 50000.
|
||||
- For "expires in 2 months", use calendar months and compute expires_at from the current request date.
|
||||
- If preview or response data includes expires_at or next_reset_at, use epochMillisecondsToDate before explaining those timestamps to the user.
|
||||
- Do not include reset when using expires_at for a one-time expiring grant.
|
||||
- Do not use rewards for direct operational credit grants.
|
||||
- Do not grant the entity-count feature itself to the entity; grant the credit/balance feature.
|
||||
|
||||
Useful docs:
|
||||
- https://docs.useautumn.com/documentation/customers/managing-balances
|
||||
- https://docs.useautumn.com/documentation/customers/balances
|
||||
- https://docs.useautumn.com/documentation/modelling-pricing/sub-entity-balances
|
||||
- https://docs.useautumn.com/api-reference/balances/createBalance
|
||||
31
packages/mcp/src/resources/billing/billing-safety.md
Normal file
31
packages/mcp/src/resources/billing/billing-safety.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: billing-safety
|
||||
title: Billing Safety
|
||||
description: Preview-first rules for Autumn billing changes.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Billing Safety
|
||||
|
||||
Billing mutations must be preview-first.
|
||||
|
||||
- Use previewAttach before attach.
|
||||
- Use previewUpdateSubscription before updateSubscription.
|
||||
- Use previewCreateSchedule before createSchedule.
|
||||
- Use previewCreateBalance before createBalance.
|
||||
- Use createSchedule only after the user confirms the ordered phases, timing, and preview.
|
||||
- When using invoice_mode, usually set enable_plan_immediately true unless the user explicitly mentions otherwise.
|
||||
- invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing.
|
||||
- Use listFeatures only when customizing plan items or passing non-zero prepaid feature_quantities and the required feature ids/types are not already known.
|
||||
- Use previewAttach before attach, including feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.
|
||||
- Use createPlan only after the user confirms the plan configuration.
|
||||
- Show the user the material billing impact before applying a change.
|
||||
- Apply a write only after explicit confirmation of the exact previewed change.
|
||||
- Never claim a billing change was applied unless the write tool succeeds.
|
||||
|
||||
Useful docs:
|
||||
- https://docs.useautumn.com/api-reference/billing/attach
|
||||
- https://docs.useautumn.com/documentation/concepts/plan-items
|
||||
- https://docs.useautumn.com/documentation/customers/balances
|
||||
36
packages/mcp/src/resources/billing/schedules.md
Normal file
36
packages/mcp/src/resources/billing/schedules.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: schedules
|
||||
title: Billing Schedules
|
||||
description: How to create multi-phase billing schedules safely.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Billing Schedules
|
||||
|
||||
Use previewCreateSchedule and createSchedule for multi-phase future billing changes.
|
||||
|
||||
Before creating a schedule, resolve:
|
||||
- customer_id and optional entity_id
|
||||
- ordered phases with starts_at as UTC epoch millisecond timestamps
|
||||
- plans in each phase, including versions, feature quantities, and customizations
|
||||
- redirect_mode, success_url, invoice_mode, and checkout behavior if payment may be required
|
||||
|
||||
When using invoice_mode, usually set enable_plan_immediately true unless the user explicitly wants access to wait for payment.
|
||||
invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing.
|
||||
|
||||
Use listFeatures only when a phase customizes plan items or sets non-zero prepaid feature_quantities and the exact feature ids or types are not already known. Scheduling an existing plan as-is does not need feature lookup.
|
||||
|
||||
Use the exact calendar date from the user or contract. Convert date-only schedule starts to midnight UTC unless the user or contract specifies a timezone. Do not shift years when converting dates.
|
||||
When preview or response data includes starts_at or billing period timestamps, use epochMillisecondsToDate before explaining those timestamps to the user.
|
||||
|
||||
If the user says year 1 is already paid or should have no billing changes, do not create an immediate/year-1 phase with a null price or billing_behavior "none". Start the schedule at the first future billing change (for example year 2), then add later phases such as year 3.
|
||||
|
||||
Custom feature mapping:
|
||||
- "N credits per month/year" -> customize.items[].included = N and reset.interval = "month"/"year".
|
||||
- "unlimited X" -> customize.items[].unlimited = true.
|
||||
- Omit reset only for non-consumable, unlimited, or clearly one-time grants.
|
||||
- Credit systems should customize the credit_system feature, not each underlying metered feature.
|
||||
|
||||
There is no separate public update-schedule tool. For existing subscription changes, use previewUpdateSubscription and updateSubscription when the requested change fits that endpoint. For a new multi-phase transition, call previewCreateSchedule first, show the immediate billing impact and ordered phases, then call createSchedule only after explicit confirmation.
|
||||
115
packages/mcp/src/resources/compileResources.ts
Normal file
115
packages/mcp/src/resources/compileResources.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { AutumnMcpResourceDoc, ResourceFrontmatter } from "./types.js";
|
||||
|
||||
const DEFAULT_PRIORITY = 0.8;
|
||||
const DEFAULT_AUDIENCE = ["assistant"] as const;
|
||||
|
||||
const parseScalar = (value: string): string | number => {
|
||||
const trimmed = value.trim();
|
||||
const unquoted = trimmed.match(/^['"](.*)['"]$/);
|
||||
if (unquoted) return unquoted[1] ?? "";
|
||||
|
||||
const number = Number(trimmed);
|
||||
if (trimmed && Number.isFinite(number)) return number;
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const parseResourceMarkdown = ({
|
||||
path,
|
||||
text,
|
||||
}: {
|
||||
path: string;
|
||||
text: string;
|
||||
}): ResourceFrontmatter & { body: string } => {
|
||||
const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`MCP resource ${path} is missing frontmatter`);
|
||||
}
|
||||
|
||||
const frontmatter = match[1] ?? "";
|
||||
const body = (match[2] ?? "").trim();
|
||||
const values: Record<string, unknown> = {};
|
||||
let currentListKey: string | null = null;
|
||||
|
||||
for (const rawLine of frontmatter.split("\n")) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const listItem = line.match(/^\s*-\s+(.+)$/);
|
||||
if (listItem && currentListKey) {
|
||||
const existing = values[currentListKey];
|
||||
values[currentListKey] = [
|
||||
...(Array.isArray(existing) ? existing : []),
|
||||
String(parseScalar(listItem[1] ?? "")),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
|
||||
if (!field) {
|
||||
throw new Error(`Invalid frontmatter line in ${path}: ${rawLine}`);
|
||||
}
|
||||
|
||||
const key = field[1] ?? "";
|
||||
const value = field[2] ?? "";
|
||||
currentListKey = value ? null : key;
|
||||
values[key] = value ? parseScalar(value) : [];
|
||||
}
|
||||
|
||||
const name = values.name;
|
||||
const title = values.title;
|
||||
const description = values.description;
|
||||
if (typeof name !== "string" || !name) {
|
||||
throw new Error(`MCP resource ${path} is missing name`);
|
||||
}
|
||||
if (typeof title !== "string" || !title) {
|
||||
throw new Error(`MCP resource ${path} is missing title`);
|
||||
}
|
||||
if (typeof description !== "string" || !description) {
|
||||
throw new Error(`MCP resource ${path} is missing description`);
|
||||
}
|
||||
|
||||
const priority =
|
||||
typeof values.priority === "number" ? values.priority : DEFAULT_PRIORITY;
|
||||
const audience = Array.isArray(values.audience)
|
||||
? values.audience.map(String)
|
||||
: [...DEFAULT_AUDIENCE];
|
||||
if (!audience.every((value) => value === "assistant")) {
|
||||
throw new Error(`MCP resource ${path} has unsupported audience`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
audience: audience as ResourceFrontmatter["audience"],
|
||||
body,
|
||||
};
|
||||
};
|
||||
|
||||
export const compileResourceFiles = ({
|
||||
baseUrl,
|
||||
files,
|
||||
}: {
|
||||
baseUrl: string | URL;
|
||||
files: readonly string[];
|
||||
}): AutumnMcpResourceDoc[] =>
|
||||
files.map((file) => {
|
||||
const url = new URL(file, baseUrl);
|
||||
const parsed = parseResourceMarkdown({
|
||||
path: file,
|
||||
text: readFileSync(url, "utf8"),
|
||||
});
|
||||
|
||||
return {
|
||||
name: parsed.name,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
priority: parsed.priority,
|
||||
audience: parsed.audience,
|
||||
uri: `autumn://docs/${parsed.name}`,
|
||||
text: parsed.body,
|
||||
};
|
||||
});
|
||||
22
packages/mcp/src/resources/customers/querying-customers.md
Normal file
22
packages/mcp/src/resources/customers/querying-customers.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: querying-customers
|
||||
title: Querying Customers
|
||||
description: How to answer customer-heavy questions with listCustomers.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Querying Customers
|
||||
|
||||
listCustomers is the primary primitive for customer-heavy queries.
|
||||
|
||||
Prefer server-side filters before local filtering:
|
||||
- search: customer id, name, or email
|
||||
- plans: customers attached to specific plans and versions
|
||||
- subscription_status: active or scheduled subscriptions
|
||||
- processors: payment processor filters
|
||||
|
||||
Use limit 1000 for broad scans; that is the maximum page size.
|
||||
Always paginate until next_cursor is empty when the user asks for complete results. Use getCustomer only for details not returned by listCustomers.
|
||||
|
||||
38
packages/mcp/src/resources/features/feature-catalog.md
Normal file
38
packages/mcp/src/resources/features/feature-catalog.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: feature-catalog
|
||||
title: Feature Catalog
|
||||
description: How to use Autumn features when configuring plans and billing changes.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Feature Catalog
|
||||
|
||||
Use listFeatures only when a task needs feature-specific inputs: creating plan items, customizing plan items, or passing non-zero feature_quantities for prepaid features. Ordinary billing changes that attach or update an existing plan as-is do not need feature lookup. Never invent feature ids.
|
||||
|
||||
Feature fields:
|
||||
- id: stable feature id used in plan items, /check, and /track.
|
||||
- name: human-readable name; match user language to this, then use id in tool calls.
|
||||
- type: boolean, metered, or credit_system.
|
||||
- consumable: for metered features, true means usage resets periodically; false means persistent allocation such as seats or storage.
|
||||
- event_names: events that can increment usage for a metered feature.
|
||||
- credit_schema: for credit_system features, maps underlying metered feature ids to credit costs.
|
||||
- archived: avoid archived features unless the user explicitly asks for them.
|
||||
|
||||
Plan and billing usage:
|
||||
- Attaching or updating an existing plan as-is usually does not need listFeatures.
|
||||
- If a plan contains prepaid features and the request needs a non-zero quantity, use feature_quantities and know the feature_id.
|
||||
- Boolean feature: include or remove access; do not ask for quantity.
|
||||
- Metered consumable feature: ask for included amount or unlimited, and the reset interval unless it is clearly one-time.
|
||||
- Metered non-consumable feature: ask for quantity or unlimited; do not add a reset interval.
|
||||
- Credit system: grant the credit_system feature, not each underlying metered feature.
|
||||
- Prepaid quantity changes belong in feature_quantities; custom contract grants or item-level prices belong in customize.
|
||||
|
||||
For attach and updateSubscription, prefer patch-style customize.add_items, customize.remove_items, or customize.update_items when changing only part of an existing plan. customize.items replaces the full custom item list. createSchedule currently supports replacement-style customize.items for each phase.
|
||||
|
||||
Useful docs:
|
||||
- https://docs.useautumn.com/documentation/pricing/features
|
||||
- https://docs.useautumn.com/documentation/pricing/plan-features
|
||||
- https://docs.useautumn.com/documentation/modelling-pricing/prepaid-pricing
|
||||
- https://docs.useautumn.com/documentation/modelling-pricing/credit-systems
|
||||
27
packages/mcp/src/resources/general/tool-composition.md
Normal file
27
packages/mcp/src/resources/general/tool-composition.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: tool-composition
|
||||
title: Tool Composition
|
||||
description: How to compose Autumn MCP tools for operational questions.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Tool Composition
|
||||
|
||||
Use Autumn tools as composable primitives.
|
||||
|
||||
- Use listPlans first for questions based on plan attributes.
|
||||
- Use listCustomers for customer-heavy questions, with filters and pagination.
|
||||
- Use getPlan or getCustomer only when list results are missing required detail.
|
||||
- Do not fan out into many getCustomer calls unless the user needs per-customer details not present in listCustomers.
|
||||
- Use getOrCreateCustomer only when the user explicitly asks to create/pre-create a customer.
|
||||
- Use updateCustomer to set customer email before invoice-mode billing when an existing customer is missing email.
|
||||
- Use createPlan for confirmed plan configuration writes.
|
||||
- Use previewCreateBalance before createBalance for standalone balance or credit grants.
|
||||
- Use previewCreateSchedule before createSchedule for multi-phase billing schedules.
|
||||
- For custom feature grants, map "per month/year" to customize.items[].reset.interval.
|
||||
- Use epochMillisecondsToDate before explaining epoch millisecond response fields such as starts_at, expires_at, next_reset_at, or billing period timestamps.
|
||||
- For billing writes, always preview first and wait for explicit user confirmation before applying.
|
||||
|
||||
Docs index: https://docs.useautumn.com/llms.txt
|
||||
@@ -1,186 +1,28 @@
|
||||
import type { MCPServerResources } from "@mastra/mcp";
|
||||
import { compileResourceFiles } from "./compileResources.js";
|
||||
|
||||
type DocInput = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
text: string;
|
||||
};
|
||||
const resourceFiles = [
|
||||
"./general/tool-composition.md",
|
||||
"./features/feature-catalog.md",
|
||||
"./plans/querying-plans.md",
|
||||
"./plans/creating-plans.md",
|
||||
"./customers/querying-customers.md",
|
||||
"./billing/schedules.md",
|
||||
"./balances/standalone-balances.md",
|
||||
"./billing/billing-safety.md",
|
||||
"./logs/request-logs.md",
|
||||
"./logs/customers.md",
|
||||
"./logs/balances.md",
|
||||
"./logs/billing.md",
|
||||
"./logs/stripe-webhooks.md",
|
||||
"./logs/analytics.md",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Builds a single Autumn docs resource. The `autumn://docs/<name>` URI is
|
||||
* derived from `name` so each doc is declared once, with no duplicated key.
|
||||
*/
|
||||
const defineDoc = ({ name, title, description, text }: DocInput) => ({
|
||||
uri: `autumn://docs/${name}`,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
text,
|
||||
const docs = compileResourceFiles({
|
||||
baseUrl: import.meta.url,
|
||||
files: resourceFiles,
|
||||
});
|
||||
|
||||
type Doc = ReturnType<typeof defineDoc>;
|
||||
|
||||
const docs: Doc[] = [
|
||||
defineDoc({
|
||||
name: "tool-composition",
|
||||
title: "Tool Composition",
|
||||
description: "How to compose Autumn MCP tools for operational questions.",
|
||||
text: `# Tool Composition
|
||||
|
||||
Use Autumn tools as composable primitives.
|
||||
|
||||
- Use listPlans first for questions based on plan attributes.
|
||||
- Use listCustomers for customer-heavy questions, with filters and pagination.
|
||||
- Use getPlan or getCustomer only when list results are missing required detail.
|
||||
- Do not fan out into many getCustomer calls unless the user needs per-customer details not present in listCustomers.
|
||||
- Use createCustomer only when the user explicitly asks to create or pre-create a customer.
|
||||
- Use createPlan for confirmed plan configuration writes.
|
||||
- Use previewCreateBalance before createBalance for standalone balance or credit grants.
|
||||
- Use previewCreateSchedule before createSchedule for multi-phase billing schedules.
|
||||
- For custom feature grants, map "per month/year" to customize.items[].reset.interval.
|
||||
- For billing writes, always preview first and wait for explicit user confirmation before applying.
|
||||
|
||||
Docs index: https://docs.useautumn.com/llms.txt`,
|
||||
}),
|
||||
defineDoc({
|
||||
name: "querying-plans",
|
||||
title: "Querying Plans",
|
||||
description: "How to answer plan-filtering questions with listPlans.",
|
||||
text: `# Querying Plans
|
||||
|
||||
listPlans is usually a cheap full scan because organizations generally have a small number of plans.
|
||||
|
||||
Use listPlans for questions about:
|
||||
- plan price thresholds
|
||||
- free trials
|
||||
- archived plans
|
||||
- custom plan variants
|
||||
- plan versions
|
||||
- plan features and included quantities
|
||||
|
||||
Filter the returned plans locally. If the user asks for customers on matching plans, first resolve the matching plans, then call listCustomers with those plan ids. For upcoming, queued, or scheduled version queries, pass only the relevant target versions to listCustomers; with numeric versions, exclude the earliest historical version unless the user asks for all historical versions.`,
|
||||
}),
|
||||
defineDoc({
|
||||
name: "creating-plans",
|
||||
title: "Creating Plans",
|
||||
description: "How to gather plan details before using createPlan.",
|
||||
text: `# Creating Plans
|
||||
|
||||
Use createPlan only after the requested plan shape is clear.
|
||||
|
||||
Before creating a plan, resolve:
|
||||
- plan_id and name
|
||||
- whether it is a base plan or add-on
|
||||
- base price, interval, and currency if paid
|
||||
- items/features, included quantities, reset intervals, and item-level prices
|
||||
- free trial settings
|
||||
- whether the plan should auto-enable for new customers
|
||||
|
||||
For consumable features, recurring grants need reset intervals. "500 credits per month" means included 500 with reset.interval "month"; one-time grants use "one_off".
|
||||
|
||||
If any required pricing or feature detail is ambiguous, ask a concise clarification question before creating the plan.`,
|
||||
}),
|
||||
defineDoc({
|
||||
name: "querying-customers",
|
||||
title: "Querying Customers",
|
||||
description: "How to answer customer-heavy questions with listCustomers.",
|
||||
text: `# Querying Customers
|
||||
|
||||
listCustomers is the primary primitive for customer-heavy queries.
|
||||
|
||||
Prefer server-side filters before local filtering:
|
||||
- search: customer id, name, or email
|
||||
- plans: customers attached to specific plans and versions
|
||||
- subscription_status: active or scheduled subscriptions
|
||||
- processors: payment processor filters
|
||||
|
||||
Use limit 1000 for broad scans; that is the maximum page size.
|
||||
Always paginate until next_cursor is empty when the user asks for complete results. Use getCustomer only for details not returned by listCustomers.`,
|
||||
}),
|
||||
defineDoc({
|
||||
name: "schedules",
|
||||
title: "Billing Schedules",
|
||||
description: "How to create multi-phase billing schedules safely.",
|
||||
text: `# Billing Schedules
|
||||
|
||||
Use previewCreateSchedule and createSchedule for multi-phase future billing changes.
|
||||
|
||||
Before creating a schedule, resolve:
|
||||
- customer_id and optional entity_id
|
||||
- ordered phases with starts_at as UTC epoch millisecond timestamps
|
||||
- plans in each phase, including versions, feature quantities, and customizations
|
||||
- redirect_mode, success_url, invoice_mode, and checkout behavior if payment may be required
|
||||
|
||||
Use the exact calendar date from the user or contract. Convert date-only schedule starts to midnight UTC unless the user or contract specifies a timezone. Do not shift years when converting dates.
|
||||
|
||||
If the user says year 1 is already paid or should have no billing changes, do not create an immediate/year-1 phase with a null price or billing_behavior "none". Start the schedule at the first future billing change (for example year 2), then add later phases such as year 3.
|
||||
|
||||
Custom feature mapping:
|
||||
- "N credits per month/year" -> customize.items[].included = N and reset.interval = "month"/"year".
|
||||
- "unlimited X" -> customize.items[].unlimited = true.
|
||||
- Omit reset only for non-consumable, unlimited, or clearly one-time grants.
|
||||
|
||||
There is no separate public update-schedule tool. For existing subscription changes, use previewUpdateSubscription and updateSubscription when the requested change fits that endpoint. For a new multi-phase transition, call previewCreateSchedule first, show the immediate billing impact and ordered phases, then call createSchedule only after explicit confirmation.`,
|
||||
}),
|
||||
defineDoc({
|
||||
name: "balances",
|
||||
title: "Standalone Balances",
|
||||
description:
|
||||
"How to create standalone, expiring, and entity-scoped balance grants.",
|
||||
text: `# Standalone Balances
|
||||
|
||||
Use previewCreateBalance and createBalance for standalone grants that are independent of a plan, such as promotional credits, referral credits, manual adjustments, or one-time entity-scoped grants.
|
||||
|
||||
Required fields:
|
||||
- customer_id: parent customer receiving the grant
|
||||
- feature_id: the balance feature, usually the credit pool such as "credits"
|
||||
- included_grant: amount to grant
|
||||
|
||||
Optional fields:
|
||||
- entity_id: scope the balance to one entity/workspace/user under the customer
|
||||
- expires_at: expiry timestamp as UTC epoch milliseconds
|
||||
- balance_id: stable id for later update/delete targeting
|
||||
|
||||
Rules:
|
||||
- For "50k credits", use included_grant: 50000.
|
||||
- For "expires in 2 months", use calendar months and compute expires_at from the current request date.
|
||||
- Do not include reset when using expires_at for a one-time expiring grant.
|
||||
- Do not use rewards for direct operational credit grants.
|
||||
- Do not grant the entity-count feature itself to the entity; grant the credit/balance feature.
|
||||
|
||||
Useful docs:
|
||||
- https://docs.useautumn.com/documentation/customers/managing-balances
|
||||
- https://docs.useautumn.com/documentation/customers/balances
|
||||
- https://docs.useautumn.com/documentation/modelling-pricing/sub-entity-balances
|
||||
- https://docs.useautumn.com/api-reference/balances/createBalance`,
|
||||
}),
|
||||
defineDoc({
|
||||
name: "billing-safety",
|
||||
title: "Billing Safety",
|
||||
description: "Preview-first rules for Autumn billing changes.",
|
||||
text: `# Billing Safety
|
||||
|
||||
Billing mutations must be preview-first.
|
||||
|
||||
- Use previewAttach before attach.
|
||||
- Use previewUpdateSubscription before updateSubscription.
|
||||
- Use previewCreateSchedule before createSchedule.
|
||||
- Use previewCreateBalance before createBalance.
|
||||
- Use createSchedule only after the user confirms the ordered phases, timing, and preview.
|
||||
- Use previewAttach before attach, including feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.
|
||||
- Use createPlan only after the user confirms the plan configuration.
|
||||
- Show the user the material billing impact before applying a change.
|
||||
- Apply a write only after explicit confirmation of the exact previewed change.
|
||||
- Never claim a billing change was applied unless the write tool succeeds.
|
||||
|
||||
Useful docs:
|
||||
- https://docs.useautumn.com/api-reference/billing/attach
|
||||
- https://docs.useautumn.com/documentation/concepts/plan-items
|
||||
- https://docs.useautumn.com/documentation/customers/balances`,
|
||||
}),
|
||||
];
|
||||
|
||||
const docByUri = new Map(docs.map((doc) => [doc.uri, doc]));
|
||||
|
||||
export const autumnMcpResources: MCPServerResources = {
|
||||
@@ -193,8 +35,8 @@ export const autumnMcpResources: MCPServerResources = {
|
||||
mimeType: "text/markdown",
|
||||
size: doc.text.length,
|
||||
annotations: {
|
||||
audience: ["assistant"],
|
||||
priority: 0.8,
|
||||
audience: doc.audience,
|
||||
priority: doc.priority,
|
||||
},
|
||||
})),
|
||||
getResourceContent: async ({ uri }) => {
|
||||
|
||||
56
packages/mcp/src/resources/logs/analytics.md
Normal file
56
packages/mcp/src/resources/logs/analytics.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: request-log-analytics
|
||||
title: Request Log Analytics
|
||||
description: How to aggregate external request-log activity.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Request Log Analytics
|
||||
|
||||
Use queryRequestLogs for counts, grouping, and status-code summaries. Keep aggregates scoped by time range and avoid broad scans unless the user asks for organization-wide activity.
|
||||
|
||||
Requests by path:
|
||||
|
||||
```apl
|
||||
where source == 'api_request' | summarize requests = count() by request_path | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Failed requests by path:
|
||||
|
||||
```apl
|
||||
where source == 'api_request' and status_code >= 400 | summarize failed = count() by request_path | order by failed desc | limit 20
|
||||
```
|
||||
|
||||
Status-code breakdown:
|
||||
|
||||
```apl
|
||||
summarize requests = count() by status_code | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Customer activity:
|
||||
|
||||
```apl
|
||||
where source == 'api_request' | summarize requests = count() by customer_id | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Entity activity for one customer:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' | summarize requests = count() by entity_id | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Tracked feature activity:
|
||||
|
||||
```apl
|
||||
where source == 'api_request' and (request_path contains 'balances.track' or request_path contains 'track' or request_path contains 'events') and request_body.event_name != '' | summarize requests = count() by request_body.event_name | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Check outcomes:
|
||||
|
||||
```apl
|
||||
where source == 'api_request' and (request_path contains 'balances.check' or request_path contains 'check' or request_path contains 'entitled') | summarize allowed = countif(response_body.allowed == true), denied = countif(response_body.allowed == false) by request_body.feature_id | order by denied desc | limit 20
|
||||
```
|
||||
|
||||
When answering, describe the grouping and time range. Do not infer product usage beyond the request paths and payload fields returned by this interface.
|
||||
53
packages/mcp/src/resources/logs/balances.md
Normal file
53
packages/mcp/src/resources/logs/balances.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: request-log-balances
|
||||
title: Request Log Balances
|
||||
description: How to inspect balance, check, and track requests through the external request-log interface.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Request Log Balances
|
||||
|
||||
Use this resource for questions about checks, tracking, usage events, balances, credits, and whether a customer was allowed to use a feature. Many customers use RPC-style routes with dotted names, while older REST-style routes are legacy.
|
||||
|
||||
Relevant request paths usually include:
|
||||
- /v1/balances.check
|
||||
- /v1/balances.track
|
||||
- /v1/balances.update
|
||||
- /v1/balances.finalize
|
||||
- /v1/events.list
|
||||
- /v1/events.aggregate
|
||||
- legacy: /v1/check, /v1/entitled, /v1/track, /v1/events, /v1/balances
|
||||
|
||||
Recent balance-related records for a customer:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and (request_path contains 'balances.' or request_path contains 'events.' or request_path contains 'check' or request_path contains 'track' or request_path contains 'events' or request_path contains 'balances') | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Failed balance-related calls:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and status_code >= 400 and (request_path contains 'balances.' or request_path contains 'events.' or request_path contains 'check' or request_path contains 'track' or request_path contains 'events' or request_path contains 'balances') | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Find checks that returned not allowed:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and (request_path contains 'balances.check' or request_path contains 'check' or request_path contains 'entitled') and response_body.allowed == false | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Feature activity for a customer:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and (request_path contains 'balances.track' or request_path contains 'track' or request_path contains 'events') and request_body.event_name != '' | summarize requests = count() by request_body.event_name | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Denied checks by feature:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and (request_path contains 'balances.check' or request_path contains 'check' or request_path contains 'entitled') and response_body.allowed == false | summarize denied = count() by request_body.feature_id | order by denied desc | limit 20
|
||||
```
|
||||
|
||||
Inspect request_body and response_body for feature ids, event names, allowed, balance, remaining, usage, granted, and next reset fields. Prefer dot-path filters such as request_body.feature_id and response_body.balance.remaining after narrowing by customer, path, and time range.
|
||||
47
packages/mcp/src/resources/logs/billing.md
Normal file
47
packages/mcp/src/resources/logs/billing.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: request-log-billing
|
||||
title: Request Log Billing
|
||||
description: How to inspect billing requests through the external request-log interface.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Request Log Billing
|
||||
|
||||
Use this resource for billing attach, update, setup payment, customer portal, and schedule questions that can be answered from API request and response records. Billing activity is commonly on RPC-style dotted routes.
|
||||
|
||||
Relevant request paths usually include:
|
||||
- /v1/billing.attach
|
||||
- /v1/billing.update
|
||||
- /v1/billing.multi_attach
|
||||
- /v1/billing.setup_payment
|
||||
- /v1/billing.open_customer_portal
|
||||
- /v1/billing.create_schedule
|
||||
- /v1/billing.preview_create_schedule
|
||||
|
||||
Recent billing calls for a customer:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and request_path contains 'billing' | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Failed billing calls:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and request_path contains 'billing' and status_code >= 400 | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Attach or update timeline:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and (request_path contains 'billing.attach' or request_path contains 'billing.update') | order by timestamp desc | limit 50
|
||||
```
|
||||
|
||||
Billing activity by path:
|
||||
|
||||
```apl
|
||||
where source == 'api_request' and request_path contains 'billing' | summarize requests = count(), failed = countif(status_code >= 400) by request_path | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
Inspect request_body and response_body for plan ids, product ids, checkout URLs, status codes, customer ids, entity ids, and returned billing results. If a user asks why a downstream payment provider changed state, use the Stripe webhook resource to inspect webhook records too.
|
||||
39
packages/mcp/src/resources/logs/customers.md
Normal file
39
packages/mcp/src/resources/logs/customers.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: request-log-customers
|
||||
title: Request Log Customers
|
||||
description: How to investigate one customer through the external request-log interface.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Request Log Customers
|
||||
|
||||
Use customer_id as the primary filter when investigating one customer. Add entity_id when the customer has multiple entities and the user identifies one.
|
||||
|
||||
Start with a narrow recent range and list the customer's newest records:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Find failed calls for a customer:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and status_code >= 400 | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Build a mixed API and Stripe webhook timeline:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' | order by timestamp desc | limit 50
|
||||
```
|
||||
|
||||
Narrow to one entity:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and entity_id == 'ent_123' | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
When answering, state the time range, customer_id, optional entity_id, and whether matching records were API requests, Stripe webhooks, or both. If no records match, say that this log interface did not return matching records for the selected range.
|
||||
|
||||
87
packages/mcp/src/resources/logs/request-logs.md
Normal file
87
packages/mcp/src/resources/logs/request-logs.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: request-logs
|
||||
title: Request Logs
|
||||
description: How to query tenant-scoped Autumn API request logs.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Request Logs
|
||||
|
||||
Use request-log tools to investigate Autumn API requests and Stripe webhook deliveries for the authenticated organization. Treat this as the complete log interface. Do not ask for information outside the documented fields.
|
||||
|
||||
Use searchRequestLogs when the user needs matching request records:
|
||||
- failed calls for a customer
|
||||
- recent calls to a path
|
||||
- request or response payload inspection
|
||||
- a chronological list of relevant requests
|
||||
|
||||
Use queryRequestLogs when the user needs aggregate statistics:
|
||||
- count failed requests by path
|
||||
- count requests by status code
|
||||
- compare traffic across request methods
|
||||
- summarize failures over a time range
|
||||
|
||||
Queryable fields:
|
||||
- timestamp
|
||||
- source
|
||||
- status_code
|
||||
- request_method
|
||||
- request_url
|
||||
- request_path
|
||||
- request_body
|
||||
- response_body
|
||||
- org_id
|
||||
- customer_id
|
||||
- entity_id
|
||||
- stripe_event_id
|
||||
- stripe_event_type
|
||||
- stripe_object_id
|
||||
|
||||
source is either api_request or stripe_webhook.
|
||||
|
||||
Nested payload fields can be queried with dot paths under request_body and response_body:
|
||||
- request_body.feature_id
|
||||
- request_body.event_name
|
||||
- request_body.customer_id
|
||||
- response_body.allowed
|
||||
- response_body.balance.remaining
|
||||
|
||||
Only simple dot paths are supported. Do not use raw functions, brackets, or extraction syntax. For nested response_body fields, narrow by time range and customer, path, or status before filtering or grouping.
|
||||
|
||||
Supported query stages are where, order by, limit, summarize, and project. Use searchRequestLogs for where/order/limit list queries. Use queryRequestLogs for summarize/project aggregate queries.
|
||||
|
||||
Default raw searches to a narrow recent range. For count/aggregate queries, queryRequestLogs defaults to 30 days when the query filters customer_id and 15 days for org-scoped queries. Ask for a customer, path, source, or time range when the user gives no useful anchor and the query may scan broadly.
|
||||
|
||||
Do not reference fields outside this document. If a fact is not present in these fields, say that the log interface does not expose it.
|
||||
|
||||
Basic examples:
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and status_code >= 400 | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
```apl
|
||||
where request_path startswith '/v1/billing' and status_code >= 400 | limit 20
|
||||
```
|
||||
|
||||
```apl
|
||||
summarize requests = count() by source, request_path | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
```apl
|
||||
where customer_id == 'cus_123' and request_body.feature_id == 'credits' | summarize requests = count() by request_body.event_name | order by requests desc | limit 20
|
||||
```
|
||||
|
||||
```apl
|
||||
where request_path contains 'balances.check' and response_body.allowed == false | summarize denied = count() by request_body.feature_id | order by denied desc | limit 20
|
||||
```
|
||||
|
||||
When answering in Slack, include:
|
||||
- the time range used
|
||||
- the filters or grouping used
|
||||
- the most relevant findings in short bullets
|
||||
- any uncertainty, such as no matching logs or a range that may be too narrow
|
||||
|
||||
Never describe this interface as public API documentation.
|
||||
37
packages/mcp/src/resources/logs/stripe-webhooks.md
Normal file
37
packages/mcp/src/resources/logs/stripe-webhooks.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: request-log-stripe-webhooks
|
||||
title: Request Log Stripe Webhooks
|
||||
description: How to inspect public-safe Stripe webhook timelines through the external request-log interface.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Request Log Stripe Webhooks
|
||||
|
||||
Use this resource for Stripe webhook timelines. Stripe webhook records are separated from normal API requests with source == 'stripe_webhook'.
|
||||
|
||||
Webhook fields:
|
||||
- stripe_event_id
|
||||
- stripe_event_type
|
||||
- stripe_object_id
|
||||
|
||||
Recent Stripe webhooks for a customer:
|
||||
|
||||
```apl
|
||||
where source == 'stripe_webhook' and customer_id == 'cus_123' | order by timestamp desc | limit 25
|
||||
```
|
||||
|
||||
Webhook events by type:
|
||||
|
||||
```apl
|
||||
where source == 'stripe_webhook' and customer_id == 'cus_123' | summarize events = count() by stripe_event_type | order by events desc | limit 20
|
||||
```
|
||||
|
||||
Timeline for one Stripe object:
|
||||
|
||||
```apl
|
||||
where source == 'stripe_webhook' and stripe_object_id == 'sub_123' | order by timestamp desc | limit 50
|
||||
```
|
||||
|
||||
Inspect request_url, status_code, request_body, and response_body for what the webhook delivery returned. Do not ask for raw event payloads beyond the fields returned by this interface.
|
||||
28
packages/mcp/src/resources/plans/creating-plans.md
Normal file
28
packages/mcp/src/resources/plans/creating-plans.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: creating-plans
|
||||
title: Creating Plans
|
||||
description: How to gather plan details before using createPlan.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Creating Plans
|
||||
|
||||
Use createPlan only after the requested plan shape is clear.
|
||||
|
||||
Before creating a plan, resolve:
|
||||
- plan_id and name
|
||||
- whether it is a base plan or add-on
|
||||
- base price, interval, and currency if paid
|
||||
- items/features, included quantities, reset intervals, and item-level prices
|
||||
- free trial settings
|
||||
- whether the plan should auto-enable for new customers
|
||||
|
||||
If the user names features but not exact ids, use listFeatures before drafting custom plan items. Never invent feature ids.
|
||||
|
||||
For consumable features, recurring grants need reset intervals. "500 credits per month" means included 500 with reset.interval "month"; one-time grants use "one_off".
|
||||
|
||||
For boolean features, include access without asking for quantity. For credit systems, grant the credit_system feature instead of each underlying metered feature.
|
||||
|
||||
If any required pricing or feature detail is ambiguous, ask a concise clarification question before creating the plan.
|
||||
23
packages/mcp/src/resources/plans/querying-plans.md
Normal file
23
packages/mcp/src/resources/plans/querying-plans.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: querying-plans
|
||||
title: Querying Plans
|
||||
description: How to answer plan-filtering questions with listPlans.
|
||||
priority: 0.8
|
||||
audience:
|
||||
- assistant
|
||||
---
|
||||
|
||||
# Querying Plans
|
||||
|
||||
listPlans is usually a cheap full scan because organizations generally have a small number of plans.
|
||||
|
||||
Use listPlans for questions about:
|
||||
- plan price thresholds
|
||||
- free trials
|
||||
- archived plans
|
||||
- custom plan variants
|
||||
- plan versions
|
||||
- plan features and included quantities
|
||||
|
||||
Filter the returned plans locally. If the user asks for customers on matching plans, first resolve the matching plans, then call listCustomers with those plan ids. For upcoming, queued, or scheduled version queries, pass only the relevant target versions to listCustomers; with numeric versions, exclude the earliest historical version unless the user asks for all historical versions.
|
||||
|
||||
14
packages/mcp/src/resources/types.ts
Normal file
14
packages/mcp/src/resources/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type ResourceAudience = "assistant";
|
||||
|
||||
export type ResourceFrontmatter = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority: number;
|
||||
audience: ResourceAudience[];
|
||||
};
|
||||
|
||||
export type AutumnMcpResourceDoc = ResourceFrontmatter & {
|
||||
uri: string;
|
||||
text: string;
|
||||
};
|
||||
@@ -50,7 +50,7 @@ const domain = {
|
||||
billingPreview({
|
||||
id: "previewAttach",
|
||||
description:
|
||||
"Preview attaching a plan before attach. Include feature_quantities and custom items/prices; map recurring custom grants like 'per month/year' to reset.interval.",
|
||||
"Preview attaching a plan before attach. Include feature_quantities and custom items/prices; map recurring custom grants like 'per month/year' to reset.interval. When using invoice_mode, usually set enable_plan_immediately true unless the user explicitly wants access to wait for payment. invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing.",
|
||||
writeToolName: "attach",
|
||||
}),
|
||||
billingPreview({
|
||||
@@ -62,7 +62,7 @@ const domain = {
|
||||
billingPreview({
|
||||
id: "previewCreateSchedule",
|
||||
description:
|
||||
"Preview billing impact of a multi-phase schedule before createSchedule. starts_at accepts epoch milliseconds or ISO/date strings; preserve exact calendar dates from the user or contract. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities; map 'per month/year' to reset.interval month/year. If the user says year 1 is already paid or should have no billing changes, do not add a year-1 phase; start phases at the first future billing change.",
|
||||
"Preview billing impact of a multi-phase schedule before createSchedule. starts_at accepts epoch milliseconds or ISO/date strings; preserve exact calendar dates from the user or contract. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If using invoice_mode, usually set enable_plan_immediately true unless the user explicitly wants access to wait for payment. invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities; map 'per month/year' to reset.interval month/year. If the user says year 1 is already paid or should have no billing changes, do not add a year-1 phase; start phases at the first future billing change.",
|
||||
writeToolName: "createSchedule",
|
||||
}),
|
||||
],
|
||||
@@ -70,7 +70,7 @@ const domain = {
|
||||
confirmedWrite({
|
||||
id: "attach",
|
||||
description:
|
||||
"Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.",
|
||||
"Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior. When using invoice_mode, usually set enable_plan_immediately true unless the user explicitly wants access to wait for payment. invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing.",
|
||||
}),
|
||||
confirmedWrite({
|
||||
id: "updateSubscription",
|
||||
@@ -80,7 +80,7 @@ const domain = {
|
||||
confirmedWrite({
|
||||
id: "createSchedule",
|
||||
description:
|
||||
"Create a multi-phase billing schedule. Destructive: preview first; preserve phase starts_at and redirect_mode values from the previewed request. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities. If year 1 is already paid/no billing changes, do not add a year-1 phase; start at the first future billing change.",
|
||||
"Create a multi-phase billing schedule. Destructive: preview first; preserve phase starts_at and redirect_mode values from the previewed request. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. When using invoice_mode, usually set enable_plan_immediately true unless the user explicitly wants access to wait for payment. invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities. If year 1 is already paid/no billing changes, do not add a year-1 phase; start at the first future billing change.",
|
||||
}),
|
||||
],
|
||||
} satisfies ToolDomain;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CreateCustomerParamsV1Schema,
|
||||
GetCustomerParamsV1Schema,
|
||||
ListCustomersV2_3ParamsSchema,
|
||||
UpdateCustomerParamsV1Schema,
|
||||
} from "@autumn/shared/publicApiSchemas";
|
||||
import * as z from "zod/v4";
|
||||
import { createDomainTools } from "./utils/builders.js";
|
||||
@@ -18,13 +19,15 @@ const listCustomersSchema = ListCustomersV2_3ParamsSchema.extend({
|
||||
|
||||
const endpoints = {
|
||||
listCustomers: "/v1/customers.list",
|
||||
createCustomer: "/v1/customers.get_or_create",
|
||||
getOrCreateCustomer: "/v1/customers.get_or_create",
|
||||
updateCustomer: "/v1/customers.update",
|
||||
getCustomer: "/v1/customers.get",
|
||||
} as const;
|
||||
|
||||
const schemas = {
|
||||
listCustomers: listCustomersSchema,
|
||||
createCustomer: CreateCustomerParamsV1Schema,
|
||||
getOrCreateCustomer: CreateCustomerParamsV1Schema,
|
||||
updateCustomer: UpdateCustomerParamsV1Schema,
|
||||
getCustomer: GetCustomerParamsV1Schema,
|
||||
} as const;
|
||||
|
||||
@@ -38,11 +41,16 @@ const domain = {
|
||||
"List Autumn customers. Use search, plans, subscription_status, and processors filters for customer-heavy queries. limit max is 1000. For queued/upcoming plan version queries, use subscription_status scheduled and omit the earliest matching version unless the user asks for all historical versions (versions 1,2,3 -> filter 2,3). 'live', 'paying', and active subscribers usually mean subscription_status active. When a plan is named, include the plans filter instead of listing broad customer sets. If listPlans returned matching versions, pass only relevant versions in plans[].versions, never guessed versions. For every/all/complete requests, paginate by calling again with start_cursor set to the previous response's next_cursor until next_cursor is empty.",
|
||||
}),
|
||||
operation({
|
||||
id: "createCustomer",
|
||||
id: "getOrCreateCustomer",
|
||||
description:
|
||||
"Create an Autumn customer, or return the existing customer with the same id. Use when the user explicitly wants a customer record created.",
|
||||
"Get an existing Autumn customer by id, or create it if missing. Use when the user explicitly wants a customer record created.",
|
||||
idempotent: true,
|
||||
}),
|
||||
operation({
|
||||
id: "updateCustomer",
|
||||
description:
|
||||
"Update an existing Autumn customer. For invoice_mode billing, set missing email with customer_id and email before previewing billing so linked Stripe customer records are updated.",
|
||||
}),
|
||||
operation({
|
||||
id: "getCustomer",
|
||||
description: "Fetch one Autumn customer by id.",
|
||||
|
||||
27
packages/mcp/src/tools/features.ts
Normal file
27
packages/mcp/src/tools/features.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from "zod/v4";
|
||||
import { createDomainTools } from "./utils/builders.js";
|
||||
import type { ToolDomain } from "./utils/types.js";
|
||||
|
||||
const listFeaturesSchema = z.object({}).strict();
|
||||
|
||||
const endpoints = {
|
||||
listFeatures: "/v1/features.list",
|
||||
} as const;
|
||||
|
||||
const schemas = {
|
||||
listFeatures: listFeaturesSchema,
|
||||
} as const;
|
||||
|
||||
const { operation } = createDomainTools({ endpoints, schemas });
|
||||
|
||||
const domain = {
|
||||
operations: [
|
||||
operation({
|
||||
id: "listFeatures",
|
||||
description:
|
||||
"List Autumn features. Use when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids, types, credit systems, or consumable behavior are not already known.",
|
||||
}),
|
||||
],
|
||||
} satisfies ToolDomain;
|
||||
|
||||
export const features = { endpoints, schemas, domain };
|
||||
@@ -6,10 +6,15 @@ import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
|
||||
import { balances } from "./balances.js";
|
||||
import { billing } from "./billing.js";
|
||||
import { customers } from "./customers.js";
|
||||
import { features } from "./features.js";
|
||||
import { logs } from "./logs.js";
|
||||
import { orgTools } from "./org.js";
|
||||
import { plans } from "./plans.js";
|
||||
import { callAutumn } from "./utils/client.js";
|
||||
import { dateToEpochMillisecondsTool } from "./utils/dates.js";
|
||||
import {
|
||||
dateToEpochMillisecondsTool,
|
||||
epochMillisecondsToDateTool,
|
||||
} from "./utils/dates.js";
|
||||
import { logTool } from "./utils/debug.js";
|
||||
import {
|
||||
agentBillingPreviewTool,
|
||||
@@ -22,22 +27,29 @@ import {
|
||||
import { requireIntentOnTools } from "./utils/intent.js";
|
||||
import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js";
|
||||
|
||||
export { dateToEpochMillisecondsTool } from "./utils/dates.js";
|
||||
export {
|
||||
dateToEpochMillisecondsTool,
|
||||
epochMillisecondsToDateTool,
|
||||
} from "./utils/dates.js";
|
||||
|
||||
/** Endpoint each tool calls, keyed by tool id (preview tools use their preview path). */
|
||||
export const endpointByTool = {
|
||||
...customers.endpoints,
|
||||
...features.endpoints,
|
||||
...plans.endpoints,
|
||||
...billing.endpoints,
|
||||
...balances.endpoints,
|
||||
...logs.endpoints,
|
||||
} as const;
|
||||
|
||||
/** Request schema each tool validates against, keyed by tool id. */
|
||||
export const schemaByTool = {
|
||||
...customers.schemas,
|
||||
...features.schemas,
|
||||
...plans.schemas,
|
||||
...billing.schemas,
|
||||
...balances.schemas,
|
||||
...logs.schemas,
|
||||
} as const satisfies Record<
|
||||
keyof typeof endpointByTool | "previewCreateBalance",
|
||||
z.ZodType
|
||||
@@ -45,9 +57,11 @@ export const schemaByTool = {
|
||||
|
||||
const domains: ToolDomain[] = [
|
||||
customers.domain,
|
||||
features.domain,
|
||||
plans.domain,
|
||||
billing.domain,
|
||||
balances.domain,
|
||||
logs.domain,
|
||||
];
|
||||
const operations = domains.flatMap((domain) => domain.operations ?? []);
|
||||
const billingPreviews = domains.flatMap(
|
||||
@@ -58,23 +72,31 @@ const confirmedWrites = domains.flatMap(
|
||||
(domain) => domain.confirmedWrites ?? [],
|
||||
);
|
||||
|
||||
type ToolRecord = Record<string, ReturnType<typeof createTool>>;
|
||||
|
||||
/**
|
||||
* Public MCP toolset: previews call Autumn's preview endpoints directly and
|
||||
* writes apply immediately (external clients gate destructive calls themselves).
|
||||
*/
|
||||
const createRawAutumnOperationToolset = (): ToolRecord => ({
|
||||
...requireIntentOnTools({
|
||||
...toTools(operations, operationTool),
|
||||
...toTools(billingPreviews, (config) =>
|
||||
operationTool({ ...config, endpoint: config.previewEndpoint }),
|
||||
),
|
||||
...toTools(localPreviews, rawLocalPreviewTool),
|
||||
...toTools(confirmedWrites, operationTool),
|
||||
...orgTools,
|
||||
} as ToolRecord),
|
||||
dateToEpochMilliseconds: dateToEpochMillisecondsTool,
|
||||
epochMillisecondsToDate: epochMillisecondsToDateTool,
|
||||
});
|
||||
|
||||
export const createRawAutumnOperationTools = () =>
|
||||
instrumentToolsWithAnalytics({
|
||||
// Require a one-sentence `intent` on every external tool call so we can
|
||||
// see what clients are actually trying to do (captured in analytics).
|
||||
tools: requireIntentOnTools({
|
||||
...toTools(operations, operationTool),
|
||||
...toTools(billingPreviews, (config) =>
|
||||
operationTool({ ...config, endpoint: config.previewEndpoint }),
|
||||
),
|
||||
...toTools(localPreviews, rawLocalPreviewTool),
|
||||
...toTools(confirmedWrites, operationTool),
|
||||
...orgTools,
|
||||
} as Record<string, ReturnType<typeof createTool>>),
|
||||
tools: createRawAutumnOperationToolset(),
|
||||
surface: "mcp",
|
||||
});
|
||||
|
||||
@@ -98,7 +120,7 @@ export const executeConfirmedBillingAction = ({
|
||||
* Agent toolset: destructive operations and billing writes are staged as pending
|
||||
* actions (preview-first), then applied via `confirmBillingAction` once approved.
|
||||
*/
|
||||
const createAgentAutumnOperationToolset = () => ({
|
||||
const createAgentAutumnOperationToolset = (): ToolRecord => ({
|
||||
...toTools(
|
||||
operations.filter(({ destructive }) => !destructive),
|
||||
operationTool,
|
||||
@@ -110,6 +132,7 @@ const createAgentAutumnOperationToolset = () => ({
|
||||
...toTools(billingPreviews, agentBillingPreviewTool),
|
||||
...toTools(localPreviews, agentLocalPreviewTool),
|
||||
dateToEpochMilliseconds: dateToEpochMillisecondsTool,
|
||||
epochMillisecondsToDate: epochMillisecondsToDateTool,
|
||||
confirmBillingAction: createTool({
|
||||
id: "confirmBillingAction",
|
||||
description:
|
||||
|
||||
55
packages/mcp/src/tools/logs.ts
Normal file
55
packages/mcp/src/tools/logs.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as z from "zod/v4";
|
||||
import { createDomainTools } from "./utils/builders.js";
|
||||
import type { ToolDomain } from "./utils/types.js";
|
||||
|
||||
const logsRangeSchema = z
|
||||
.object({
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const searchRequestLogsSchema = z
|
||||
.object({
|
||||
query: z.string().max(4000).optional(),
|
||||
range: logsRangeSchema.optional(),
|
||||
limit: z.number().int().positive().max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const queryRequestLogsSchema = z
|
||||
.object({
|
||||
query: z.string().min(1).max(4000),
|
||||
range: logsRangeSchema.optional(),
|
||||
limit: z.number().int().positive().max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const endpoints = {
|
||||
searchRequestLogs: "/v1/logs.search",
|
||||
queryRequestLogs: "/v1/logs.query",
|
||||
} as const;
|
||||
|
||||
const schemas = {
|
||||
searchRequestLogs: searchRequestLogsSchema,
|
||||
queryRequestLogs: queryRequestLogsSchema,
|
||||
} as const;
|
||||
|
||||
const { operation } = createDomainTools({ endpoints, schemas });
|
||||
|
||||
const domain = {
|
||||
operations: [
|
||||
operation({
|
||||
id: "searchRequestLogs",
|
||||
description:
|
||||
"Search tenant-scoped Autumn API request logs. Use this for listing matching request records, inspecting request/response bodies, and debugging recent customer API calls. Supports restricted APL over projected request-log fields only.",
|
||||
}),
|
||||
operation({
|
||||
id: "queryRequestLogs",
|
||||
description:
|
||||
"Query tenant-scoped Autumn API request logs with aggregate restricted APL. Use this for counts, grouping, and request-log statistics such as errors by path or status-code breakdowns.",
|
||||
}),
|
||||
],
|
||||
} satisfies ToolDomain;
|
||||
|
||||
export const logs = { endpoints, schemas, domain };
|
||||
@@ -40,6 +40,55 @@ const toEpochMilliseconds = (date: string): number => {
|
||||
return epoch;
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
] as const;
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const formatUtcDate = (date: Date) =>
|
||||
`${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
|
||||
|
||||
const parseEpochMilliseconds = (value: number | string): number => {
|
||||
const epoch = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(epoch)) {
|
||||
throw new Error(`Invalid epoch milliseconds: ${value}`);
|
||||
}
|
||||
return epoch;
|
||||
};
|
||||
|
||||
const epochMillisecondsToDate = (
|
||||
epochMsByKey: Record<string, number | string>,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(epochMsByKey).map(([key, value]) => {
|
||||
const epochMs = parseEpochMilliseconds(value);
|
||||
const date = new Date(epochMs);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
throw new Error(`Invalid epoch milliseconds: ${value}`);
|
||||
}
|
||||
return [
|
||||
key,
|
||||
{
|
||||
epoch_ms: epochMs,
|
||||
iso: date.toISOString(),
|
||||
utc: formatUtcDate(date),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
export const dateToEpochMillisecondsTool = createTool({
|
||||
id: "dateToEpochMilliseconds",
|
||||
description:
|
||||
@@ -51,3 +100,18 @@ export const dateToEpochMillisecondsTool = createTool({
|
||||
.strict(),
|
||||
execute: async ({ date }) => toEpochMilliseconds(date),
|
||||
});
|
||||
|
||||
export const epochMillisecondsToDateTool = createTool({
|
||||
id: "epochMillisecondsToDate",
|
||||
description:
|
||||
"Convert one or more epoch millisecond timestamps from Autumn responses into UTC date formats. Use this before explaining starts_at, expires_at, next_reset_at, or other millisecond timestamp fields to users.",
|
||||
inputSchema: z
|
||||
.object({
|
||||
timestamps: z.record(z.string(), z.union([z.number(), z.string()])).meta({
|
||||
description:
|
||||
"Object keyed by semantic timestamp names, with epoch millisecond values.",
|
||||
}),
|
||||
})
|
||||
.strict(),
|
||||
execute: async ({ timestamps }) => epochMillisecondsToDate(timestamps),
|
||||
});
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseResourceMarkdown } from "../../../../src/resources/compileResources.js";
|
||||
import { autumnMcpResourceUris } from "../../../../src/resources/index.js";
|
||||
import { createAutumnOperationsMCPServer } from "../../../../src/server/server.js";
|
||||
|
||||
describe("Autumn MCP server", () => {
|
||||
const logResourceUris = [
|
||||
"autumn://docs/request-logs",
|
||||
"autumn://docs/request-log-customers",
|
||||
"autumn://docs/request-log-balances",
|
||||
"autumn://docs/request-log-billing",
|
||||
"autumn://docs/request-log-stripe-webhooks",
|
||||
"autumn://docs/request-log-analytics",
|
||||
] as const;
|
||||
|
||||
test("public server advertises raw operation tools", async () => {
|
||||
const tools = await createAutumnOperationsMCPServer().getToolListInfo();
|
||||
|
||||
expect(tools.tools.map((tool) => tool.name)).toEqual([
|
||||
"listCustomers",
|
||||
"createCustomer",
|
||||
"getOrCreateCustomer",
|
||||
"updateCustomer",
|
||||
"getCustomer",
|
||||
"listFeatures",
|
||||
"listPlans",
|
||||
"createPlan",
|
||||
"getPlan",
|
||||
"createBalance",
|
||||
"searchRequestLogs",
|
||||
"queryRequestLogs",
|
||||
"previewAttach",
|
||||
"previewUpdateSubscription",
|
||||
"previewCreateSchedule",
|
||||
@@ -22,6 +36,8 @@ describe("Autumn MCP server", () => {
|
||||
"updateSubscription",
|
||||
"createSchedule",
|
||||
"getCurrentOrganization",
|
||||
"dateToEpochMilliseconds",
|
||||
"epochMillisecondsToDate",
|
||||
]);
|
||||
expect(tools.tools.map((tool) => tool.name)).not.toContain("ask_autumn");
|
||||
expect(tools.tools.map((tool) => tool.name)).not.toContain(
|
||||
@@ -55,6 +71,57 @@ describe("Autumn MCP server", () => {
|
||||
const resource = await server.readResource(uri);
|
||||
expect(resource.contents[0]?.text).toContain("# ");
|
||||
}
|
||||
|
||||
const requestLogs = await server.readResource("autumn://docs/request-logs");
|
||||
expect(requestLogs.contents[0]?.text).toContain("searchRequestLogs");
|
||||
expect(requestLogs.contents[0]?.text).toContain("queryRequestLogs");
|
||||
|
||||
const featureCatalog = await server.readResource(
|
||||
"autumn://docs/feature-catalog",
|
||||
);
|
||||
expect(featureCatalog.contents[0]?.text).toContain("listFeatures");
|
||||
|
||||
const billingSafety = await server.readResource(
|
||||
"autumn://docs/billing-safety",
|
||||
);
|
||||
expect(billingSafety.contents[0]?.text).toContain(
|
||||
"invoice_mode requires customer email",
|
||||
);
|
||||
expect(billingSafety.contents[0]?.text).toContain("updateCustomer");
|
||||
|
||||
const schedules = await server.readResource("autumn://docs/schedules");
|
||||
expect(schedules.contents[0]?.text).toContain(
|
||||
"invoice_mode requires customer email",
|
||||
);
|
||||
expect(schedules.contents[0]?.text).toContain("updateCustomer");
|
||||
|
||||
for (const uri of logResourceUris) {
|
||||
expect(autumnMcpResourceUris).toContain(uri);
|
||||
}
|
||||
});
|
||||
|
||||
test("log resources stay external-safe", async () => {
|
||||
const server = createAutumnOperationsMCPServer();
|
||||
const bannedTerms = [
|
||||
"Axiom",
|
||||
"extras",
|
||||
"workflow",
|
||||
"req.id",
|
||||
"msg",
|
||||
"level",
|
||||
"server/src",
|
||||
"implementation files",
|
||||
"database state",
|
||||
"stack traces",
|
||||
];
|
||||
|
||||
for (const uri of logResourceUris) {
|
||||
const resource = await server.readResource(uri);
|
||||
const text = String(resource.contents[0]?.text ?? "");
|
||||
for (const term of bannedTerms) {
|
||||
expect(text).not.toContain(term);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown resources are rejected", async () => {
|
||||
@@ -67,4 +134,34 @@ describe("Autumn MCP server", () => {
|
||||
"Unknown Autumn MCP resource",
|
||||
);
|
||||
});
|
||||
|
||||
test("resource markdown parser validates frontmatter", () => {
|
||||
expect(
|
||||
parseResourceMarkdown({
|
||||
path: "logs/request-logs.md",
|
||||
text: [
|
||||
"---",
|
||||
"name: request-logs",
|
||||
"title: Request Logs",
|
||||
"description: Log docs",
|
||||
"---",
|
||||
"# Request Logs",
|
||||
].join("\n"),
|
||||
}),
|
||||
).toMatchObject({
|
||||
name: "request-logs",
|
||||
title: "Request Logs",
|
||||
description: "Log docs",
|
||||
priority: 0.8,
|
||||
audience: ["assistant"],
|
||||
body: "# Request Logs",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
parseResourceMarkdown({
|
||||
path: "bad.md",
|
||||
text: "---\ntitle: Missing Name\ndescription: Bad\n---\n# Bad",
|
||||
}),
|
||||
).toThrow("missing name");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
createAgentAutumnOperationTools,
|
||||
createRawAutumnOperationTools,
|
||||
dateToEpochMillisecondsTool,
|
||||
endpointByTool,
|
||||
epochMillisecondsToDateTool,
|
||||
schemaByTool,
|
||||
} from "../../../../src/tools/index.js";
|
||||
import { createTestRedis } from "../../../utils/test-redis.js";
|
||||
|
||||
@@ -36,13 +39,40 @@ describe("Autumn operation tools", () => {
|
||||
expect(tools.listPlans.description).toContain(
|
||||
"filter returned plans locally",
|
||||
);
|
||||
expect(tools.listFeatures.description).toContain("List Autumn features");
|
||||
expect(tools.listCustomers.description).toContain("plans");
|
||||
expect(tools.listCustomers.description).toContain("paginate");
|
||||
expect(tools.updateCustomer.description).toContain("invoice_mode");
|
||||
expect(tools.updateCustomer.description).toContain("Stripe");
|
||||
expect(tools.createPlan.description).toContain("confirmation");
|
||||
expect(tools.createBalance.description).toContain("entity-scoped credits");
|
||||
expect(tools.searchRequestLogs.description).toContain("request logs");
|
||||
expect(tools.queryRequestLogs.description).toContain("aggregate");
|
||||
expect(tools.previewCreateBalance.description).toContain("Does not mutate");
|
||||
expect(tools.createSchedule.description).toContain("starts_at");
|
||||
expect(tools.previewCreateSchedule.description).toContain("billing impact");
|
||||
expect(tools.previewAttach.description).toContain(
|
||||
"enable_plan_immediately",
|
||||
);
|
||||
expect(tools.previewAttach.description).toContain(
|
||||
"invoice_mode requires customer email",
|
||||
);
|
||||
expect(tools.attach.description).toContain("enable_plan_immediately");
|
||||
expect(tools.attach.description).toContain(
|
||||
"invoice_mode requires customer email",
|
||||
);
|
||||
expect(tools.previewCreateSchedule.description).toContain(
|
||||
"enable_plan_immediately",
|
||||
);
|
||||
expect(tools.previewCreateSchedule.description).toContain(
|
||||
"invoice_mode requires customer email",
|
||||
);
|
||||
expect(tools.createSchedule.description).toContain(
|
||||
"enable_plan_immediately",
|
||||
);
|
||||
expect(tools.createSchedule.description).toContain(
|
||||
"invoice_mode requires customer email",
|
||||
);
|
||||
expect(tools.getCurrentOrganization.description).toContain("organization");
|
||||
});
|
||||
|
||||
@@ -61,9 +91,13 @@ describe("Autumn operation tools", () => {
|
||||
|
||||
for (const name of [
|
||||
"listCustomers",
|
||||
"updateCustomer",
|
||||
"getCustomer",
|
||||
"listFeatures",
|
||||
"listPlans",
|
||||
"getPlan",
|
||||
"searchRequestLogs",
|
||||
"queryRequestLogs",
|
||||
"previewAttach",
|
||||
"previewUpdateSubscription",
|
||||
"previewCreateSchedule",
|
||||
@@ -74,6 +108,16 @@ describe("Autumn operation tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("listFeatures uses a strict empty request schema", () => {
|
||||
expect(endpointByTool.listFeatures).toBe("/v1/features.list");
|
||||
expect(schemaByTool.listFeatures.parse({})).toEqual({});
|
||||
expect(() =>
|
||||
schemaByTool.listFeatures.parse({ archived: false }),
|
||||
).toThrow();
|
||||
|
||||
expect(createAgentAutumnOperationTools().listFeatures).toBeDefined();
|
||||
});
|
||||
|
||||
test("dateToEpochMilliseconds converts UTC dates and offsets", async () => {
|
||||
const tool = dateToEpochMillisecondsTool as ExecutableTool;
|
||||
if (!tool.execute)
|
||||
@@ -87,7 +131,36 @@ describe("Autumn operation tools", () => {
|
||||
).resolves.toBe(Date.UTC(2027, 0, 1, 8));
|
||||
});
|
||||
|
||||
test("raw createCustomer calls the get-or-create endpoint", async () => {
|
||||
test("epochMillisecondsToDate converts keyed epoch milliseconds", async () => {
|
||||
const tool = epochMillisecondsToDateTool as ExecutableTool;
|
||||
if (!tool.execute)
|
||||
throw new Error("epochMillisecondsToDate is not executable");
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
{
|
||||
timestamps: {
|
||||
starts_at: Date.UTC(2026, 0, 1),
|
||||
expires_at: String(Date.UTC(2026, 5, 6, 12, 30, 45)),
|
||||
},
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
starts_at: {
|
||||
epoch_ms: Date.UTC(2026, 0, 1),
|
||||
iso: "2026-01-01T00:00:00.000Z",
|
||||
utc: "January 1, 2026, 00:00:00 UTC",
|
||||
},
|
||||
expires_at: {
|
||||
epoch_ms: Date.UTC(2026, 5, 6, 12, 30, 45),
|
||||
iso: "2026-06-06T12:30:45.000Z",
|
||||
utc: "June 6, 2026, 12:30:45 UTC",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("raw getOrCreateCustomer calls the get-or-create endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
expect(String(url)).toBe(
|
||||
@@ -101,8 +174,9 @@ describe("Autumn operation tools", () => {
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const tool = createRawAutumnOperationTools().createCustomer;
|
||||
if (!tool.execute) throw new Error("createCustomer is not executable");
|
||||
const tool = createRawAutumnOperationTools().getOrCreateCustomer;
|
||||
if (!tool.execute)
|
||||
throw new Error("getOrCreateCustomer is not executable");
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
@@ -118,6 +192,44 @@ describe("Autumn operation tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("raw updateCustomer calls the update endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
expect(String(url)).toBe("http://localhost:8080/v1/customers.update");
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||
customer_id: "mintlify",
|
||||
email: "johnyeocx@gmail.com",
|
||||
});
|
||||
return Response.json({
|
||||
id: "mintlify",
|
||||
email: "johnyeocx@gmail.com",
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const tool = createRawAutumnOperationTools().updateCustomer;
|
||||
if (!tool.execute) throw new Error("updateCustomer is not executable");
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
{
|
||||
intent: "set customer email",
|
||||
request: {
|
||||
customer_id: "mintlify",
|
||||
email: "johnyeocx@gmail.com",
|
||||
},
|
||||
},
|
||||
{ mcp: { extra: { authInfo: auth } } } as never,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: "mintlify",
|
||||
email: "johnyeocx@gmail.com",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("raw createPlan calls the create plan endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
@@ -348,6 +460,99 @@ describe("Autumn operation tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("raw listFeatures calls the feature list endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
expect(String(url)).toBe("http://localhost:8080/v1/features.list");
|
||||
expect(JSON.parse(init?.body as string)).toEqual({});
|
||||
return Response.json({ list: [] });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const tool = createRawAutumnOperationTools().listFeatures;
|
||||
if (!tool.execute) throw new Error("listFeatures is not executable");
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
{
|
||||
intent: "find available product features",
|
||||
request: {},
|
||||
},
|
||||
{ mcp: { extra: { authInfo: auth } } } as never,
|
||||
),
|
||||
).resolves.toEqual({ list: [] });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("raw request-log tools call the logs endpoints", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: unknown }> = [];
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(init?.body as string),
|
||||
});
|
||||
return Response.json({ list: [] });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const tools = createRawAutumnOperationTools();
|
||||
if (!tools.searchRequestLogs.execute) {
|
||||
throw new Error("searchRequestLogs is not executable");
|
||||
}
|
||||
if (!tools.queryRequestLogs.execute) {
|
||||
throw new Error("queryRequestLogs is not executable");
|
||||
}
|
||||
|
||||
await expect(
|
||||
tools.searchRequestLogs.execute(
|
||||
{
|
||||
intent: "find recent failed requests",
|
||||
request: {
|
||||
query: "where status_code >= 400 | limit 10",
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
{ mcp: { extra: { authInfo: auth } } } as never,
|
||||
),
|
||||
).resolves.toEqual({ list: [] });
|
||||
|
||||
await expect(
|
||||
tools.queryRequestLogs.execute(
|
||||
{
|
||||
intent: "count errors by path",
|
||||
request: {
|
||||
query:
|
||||
"where status_code >= 400 | summarize errors = count() by request_path",
|
||||
},
|
||||
},
|
||||
{ mcp: { extra: { authInfo: auth } } } as never,
|
||||
),
|
||||
).resolves.toEqual({ list: [] });
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
url: "http://localhost:8080/v1/logs.search",
|
||||
body: {
|
||||
query: "where status_code >= 400 | limit 10",
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "http://localhost:8080/v1/logs.query",
|
||||
body: {
|
||||
query:
|
||||
"where status_code >= 400 | summarize errors = count() by request_path",
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("raw previewAttach does not create a pending action", async () => {
|
||||
await clearPendingActions();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -10,6 +10,7 @@ const defaultSlackScopes = [
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
@@ -22,6 +23,8 @@ const defaultSlackScopes = [
|
||||
|
||||
const defaultBotEvents = [
|
||||
"app_mention",
|
||||
"assistant_thread_started",
|
||||
"assistant_thread_context_changed",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
@@ -30,6 +33,7 @@ const defaultBotEvents = [
|
||||
|
||||
type Args = {
|
||||
action?: string;
|
||||
appId?: string;
|
||||
appName?: string;
|
||||
baseUrl?: string;
|
||||
dryRun: boolean;
|
||||
@@ -38,10 +42,12 @@ type Args = {
|
||||
printManifest: boolean;
|
||||
provider?: SlackInstallProvider;
|
||||
scopes: string[];
|
||||
target?: SlackManifestTarget;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
type SlackInstallProvider = "slack" | "slack_admin";
|
||||
type SlackManifestTarget = "local" | "prod" | "admin" | "all";
|
||||
|
||||
type SlackManifest = {
|
||||
display_information: {
|
||||
@@ -94,6 +100,10 @@ type SlackManifestCreateResponse = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SlackManifestUpdateResponse = SlackApiResponse & {
|
||||
app_id?: string;
|
||||
};
|
||||
|
||||
type SlackApiResponse = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -104,13 +114,16 @@ const usage = () =>
|
||||
[
|
||||
"Usage:",
|
||||
" bun slack [setup-bot] [options]",
|
||||
" bun slack update-manifest --target <local|prod|admin|all> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --app-id <id> Existing Slack app id for manifest updates.",
|
||||
" --base-url <url> Public Leaf URL. Defaults to NGROK_URL, SLACK_BOT_URL, or CHAT_URL.",
|
||||
" --name <name> Slack app name. Defaults to Autumn Chat Local.",
|
||||
" --env-file <path> Write Slack env vars to this file.",
|
||||
" --provider <provider> slack or slack_admin. Defaults to prompt for setup-bot.",
|
||||
" --scopes <csv> Override bot scopes.",
|
||||
" --target <target> Manifest update target: local, prod, admin, or all.",
|
||||
" --team-id <id> Workspace team id for org-scoped Slack CLI auth.",
|
||||
" --print-manifest Print generated Slack app manifest.",
|
||||
" --dry-run Print manifest/env without calling Slack.",
|
||||
@@ -119,6 +132,7 @@ const usage = () =>
|
||||
"Example:",
|
||||
" bun slack",
|
||||
" bun slack --provider slack_admin",
|
||||
" bun slack update-manifest --target all --base-url https://j.dev.useautumn.com",
|
||||
" bun slack --base-url https://j.dev.useautumn.com --env-file .env.slack-local",
|
||||
].join("\n");
|
||||
|
||||
@@ -143,6 +157,7 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => {
|
||||
: (argv[0] ?? "setup-bot");
|
||||
const scopes = readOption({ args: argv, name: "--scopes" });
|
||||
const providerArg = readOption({ args: argv, name: "--provider" });
|
||||
const targetArg = readOption({ args: argv, name: "--target" });
|
||||
const provider =
|
||||
providerArg === "slack" || providerArg === "slack_admin"
|
||||
? providerArg
|
||||
@@ -158,6 +173,7 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => {
|
||||
|
||||
return {
|
||||
action,
|
||||
appId: readOption({ args: argv, name: "--app-id" }),
|
||||
appName: readOption({ args: argv, name: "--name" }) ?? defaultAppName,
|
||||
baseUrl:
|
||||
readOption({ args: argv, name: "--base-url" }) ??
|
||||
@@ -172,6 +188,21 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => {
|
||||
scopes: scopes
|
||||
? scopes.split(",").map((scope) => scope.trim())
|
||||
: defaultSlackScopes,
|
||||
target:
|
||||
targetArg === "local" ||
|
||||
targetArg === "prod" ||
|
||||
targetArg === "admin" ||
|
||||
targetArg === "all"
|
||||
? targetArg
|
||||
: action === "update-local-manifest"
|
||||
? "local"
|
||||
: action === "update-prod-manifest"
|
||||
? "prod"
|
||||
: action === "update-admin-manifest"
|
||||
? "admin"
|
||||
: action === "update-all-manifests"
|
||||
? "all"
|
||||
: undefined,
|
||||
teamId: readOption({ args: argv, name: "--team-id" }),
|
||||
};
|
||||
};
|
||||
@@ -512,6 +543,41 @@ const createSlackApp = async ({
|
||||
return json;
|
||||
};
|
||||
|
||||
const updateSlackAppManifest = async ({
|
||||
appId,
|
||||
manifest,
|
||||
serviceToken,
|
||||
teamId,
|
||||
}: {
|
||||
appId: string;
|
||||
manifest: SlackManifest;
|
||||
serviceToken?: string;
|
||||
teamId?: string;
|
||||
}): Promise<SlackManifestUpdateResponse> => {
|
||||
const output = runSlackCli({
|
||||
args: [
|
||||
"api",
|
||||
"apps.manifest.update",
|
||||
...(serviceToken ? ["--token", serviceToken] : []),
|
||||
"--json",
|
||||
JSON.stringify({
|
||||
app_id: appId,
|
||||
manifest: JSON.stringify(manifest),
|
||||
...(teamId ? { team_id: teamId } : {}),
|
||||
}),
|
||||
],
|
||||
quiet: true,
|
||||
});
|
||||
const json = parseSlackJson<SlackManifestUpdateResponse>({
|
||||
output,
|
||||
label: "apps.manifest.update",
|
||||
});
|
||||
if (!json.ok)
|
||||
throw new Error(`Slack app manifest update failed: ${json.error}`);
|
||||
|
||||
return json;
|
||||
};
|
||||
|
||||
const escapeEnvValue = ({ value }: { value: string }) => {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
|
||||
return JSON.stringify(value);
|
||||
@@ -559,6 +625,8 @@ const printEnvExports = ({ vars }: { vars: Record<string, string> }) => {
|
||||
const setupSlackBot = async ({ args }: { args: Args }) => {
|
||||
const resolvedArgs = await resolveInteractiveArgs({ args });
|
||||
const provider = resolvedArgs.provider;
|
||||
const baseUrl = resolvedArgs.baseUrl;
|
||||
if (!baseUrl) throw new Error("Missing public Leaf URL");
|
||||
const readyLabel =
|
||||
provider === "slack_admin"
|
||||
? "Slack admin app ready"
|
||||
@@ -570,7 +638,7 @@ const setupSlackBot = async ({ args }: { args: Args }) => {
|
||||
|
||||
const manifest = buildSlackManifest({
|
||||
appName: resolvedArgs.appName ?? defaultAppNameForProvider({ provider }),
|
||||
baseUrl: resolvedArgs.baseUrl,
|
||||
baseUrl,
|
||||
scopes: resolvedArgs.scopes,
|
||||
});
|
||||
|
||||
@@ -641,11 +709,160 @@ const setupAdminBot = async ({ args }: { args: Args }) =>
|
||||
const setupLocalBot = async ({ args }: { args: Args }) =>
|
||||
setupSlackBot({ args: { ...args, provider: "slack" } });
|
||||
|
||||
const prodBaseUrl = "https://api.useautumn.com";
|
||||
|
||||
const targetDefaults = ({
|
||||
target,
|
||||
}: {
|
||||
target: Exclude<SlackManifestTarget, "all">;
|
||||
}) => {
|
||||
if (target === "prod") {
|
||||
return {
|
||||
appId: process.env.SLACK_PROD_APP_ID,
|
||||
appName: process.env.SLACK_PROD_APP_NAME ?? "Autumn",
|
||||
baseUrl: prodBaseUrl,
|
||||
provider: "slack" as const,
|
||||
};
|
||||
}
|
||||
if (target === "admin") {
|
||||
return {
|
||||
appId: process.env.SLACK_ADMIN_APP_IDS ?? process.env.SLACK_ADMIN_APP_ID,
|
||||
appName: process.env.SLACK_ADMIN_APP_NAME ?? "Autumn Chat Admin Local",
|
||||
baseUrl:
|
||||
process.env.NGROK_URL ??
|
||||
process.env.SLACK_BOT_URL ??
|
||||
process.env.CHAT_URL,
|
||||
provider: "slack_admin" as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
appId: process.env.SLACK_APP_ID ?? process.env.SLACK_LOCAL_APP_ID,
|
||||
appName: process.env.SLACK_APP_NAME ?? "Autumn Chat Local",
|
||||
baseUrl:
|
||||
process.env.NGROK_URL ??
|
||||
process.env.SLACK_BOT_URL ??
|
||||
process.env.CHAT_URL,
|
||||
provider: "slack" as const,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveManifestUpdateTarget = async ({
|
||||
args,
|
||||
target,
|
||||
}: {
|
||||
args: Args;
|
||||
target: Exclude<SlackManifestTarget, "all">;
|
||||
}) => {
|
||||
const defaults = targetDefaults({ target });
|
||||
const answers = await inquirer.prompt<{
|
||||
appIds?: string;
|
||||
appName?: string;
|
||||
baseUrl?: string;
|
||||
}>([
|
||||
...(!args.dryRun && !args.appId && !defaults.appId
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
name: "appIds" as const,
|
||||
message: `Slack app id(s) for ${target}`,
|
||||
validate: (value: string) =>
|
||||
Boolean(value.trim()) || "At least one Slack app id is required",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!args.baseUrl && !defaults.baseUrl
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
name: "baseUrl" as const,
|
||||
message: `Public Leaf URL for ${target}`,
|
||||
filter: (value: string) => trimTrailingSlash({ url: value.trim() }),
|
||||
validate: (value: string) =>
|
||||
isUrl({ value }) || "Enter a valid http(s) URL",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const baseUrl = args.baseUrl ?? answers.baseUrl ?? defaults.baseUrl;
|
||||
if (!baseUrl) throw new Error(`Missing base URL for ${target} manifest`);
|
||||
|
||||
return {
|
||||
appIds: (args.appId ?? answers.appIds ?? defaults.appId)
|
||||
?.split(",")
|
||||
.map((appId) => appId.trim())
|
||||
.filter(Boolean),
|
||||
appName: args.appName ?? defaults.appName,
|
||||
baseUrl,
|
||||
provider: defaults.provider,
|
||||
};
|
||||
};
|
||||
|
||||
const updateManifestTargets = async ({ args }: { args: Args }) => {
|
||||
const target = args.target ?? "local";
|
||||
const targets =
|
||||
target === "all"
|
||||
? (["local", "prod", "admin"] as const)
|
||||
: ([target] as Exclude<SlackManifestTarget, "all">[]);
|
||||
|
||||
if (!args.dryRun) {
|
||||
ensureSlackCli();
|
||||
maybeShowSlackCliAuthInstructions();
|
||||
}
|
||||
const serviceToken = args.dryRun ? undefined : await ensureSlackApiAuth();
|
||||
|
||||
for (const updateTarget of targets) {
|
||||
const targetArgs =
|
||||
target === "all"
|
||||
? {
|
||||
...args,
|
||||
appId: undefined,
|
||||
appName: undefined,
|
||||
baseUrl: updateTarget === "prod" ? undefined : args.baseUrl,
|
||||
}
|
||||
: args;
|
||||
const resolved = await resolveManifestUpdateTarget({
|
||||
args: targetArgs,
|
||||
target: updateTarget,
|
||||
});
|
||||
const manifest = buildSlackManifest({
|
||||
appName: resolved.appName,
|
||||
baseUrl: resolved.baseUrl,
|
||||
scopes: args.scopes,
|
||||
});
|
||||
|
||||
if (args.printManifest || args.dryRun) {
|
||||
console.log(chalk.cyan(`\n${updateTarget} Slack app manifest:`));
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
if (args.dryRun) continue;
|
||||
if (!resolved.appIds?.length) {
|
||||
throw new Error(`Missing Slack app id for ${updateTarget} manifest`);
|
||||
}
|
||||
for (const appId of resolved.appIds) {
|
||||
await updateSlackAppManifest({
|
||||
appId,
|
||||
manifest,
|
||||
serviceToken,
|
||||
teamId: args.teamId,
|
||||
});
|
||||
console.log(
|
||||
chalk.green(`Updated ${updateTarget} Slack app manifest (${appId})`),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const actions = {
|
||||
"setup-bot": setupSlackBot,
|
||||
"setup-admin-bot": setupAdminBot,
|
||||
"setup-local-bot": setupLocalBot,
|
||||
"setup-regular-bot": setupLocalBot,
|
||||
"update-admin-manifest": updateManifestTargets,
|
||||
"update-all-manifests": updateManifestTargets,
|
||||
"update-local-manifest": updateManifestTargets,
|
||||
"update-manifest": updateManifestTargets,
|
||||
"update-prod-manifest": updateManifestTargets,
|
||||
} satisfies Record<string, (params: { args: Args }) => Promise<void>>;
|
||||
|
||||
type Action = keyof typeof actions;
|
||||
|
||||
12
server/src/external/axiom/queryAxiom.ts
vendored
Normal file
12
server/src/external/axiom/queryAxiom.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getAxiomClient } from "./initAxiom.js";
|
||||
|
||||
export const queryAxiom = async ({
|
||||
apl,
|
||||
options,
|
||||
}: {
|
||||
apl: string;
|
||||
options?: {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
};
|
||||
}) => getAxiomClient().query(apl, options);
|
||||
43
server/src/internal/logs/actions/queryLogs/queryLogs.ts
Normal file
43
server/src/internal/logs/actions/queryLogs/queryLogs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { isAxiomConfigured } from "@/external/axiom/initAxiom.js";
|
||||
import { queryAxiom } from "@/external/axiom/queryAxiom.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { buildRequestLogsApl } from "../searchRequestLogs/buildRequestLogsApl.js";
|
||||
|
||||
export const queryLogs = async ({
|
||||
ctx,
|
||||
query,
|
||||
range,
|
||||
limit,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
query: string;
|
||||
range: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
limit: number;
|
||||
}) => {
|
||||
if (!isAxiomConfigured()) {
|
||||
return { list: [], unconfigured: true };
|
||||
}
|
||||
|
||||
const apl = buildRequestLogsApl({
|
||||
ctx,
|
||||
query,
|
||||
limit,
|
||||
allowedStages: ["where", "summarize", "project", "orderBy", "limit"],
|
||||
appendDefaultOrder: false,
|
||||
});
|
||||
|
||||
const result = await queryAxiom({
|
||||
apl,
|
||||
options: {
|
||||
startTime: range.startDate,
|
||||
endTime: range.endDate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
list: (result.matches ?? []).map((match) => match.data ?? {}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AppEnv, Organization } from "@autumn/shared";
|
||||
import {
|
||||
escapeAplString,
|
||||
parseRestrictedApl,
|
||||
restrictedAplToApl,
|
||||
} from "../../parser/restrictedApl.js";
|
||||
import type { RestrictedAplStageKind } from "../../parser/restrictedAplConfig.js";
|
||||
|
||||
export type RequestLogsAplInput = {
|
||||
ctx: {
|
||||
org: Pick<Organization, "id" | "slug">;
|
||||
env: AppEnv;
|
||||
};
|
||||
query?: string;
|
||||
limit: number;
|
||||
allowedStages?: RestrictedAplStageKind[];
|
||||
appendDefaultOrder?: boolean;
|
||||
};
|
||||
|
||||
type ProjectionField = {
|
||||
alias: string;
|
||||
expression: string;
|
||||
};
|
||||
|
||||
const REQUEST_LOG_PROJECTION: ProjectionField[] = [
|
||||
{ alias: "timestamp", expression: "_time" },
|
||||
{ alias: "source", expression: "source" },
|
||||
{ alias: "status_code", expression: "statusCode" },
|
||||
{ alias: "request_method", expression: "['req.method']" },
|
||||
{ alias: "request_url", expression: "['req.url']" },
|
||||
{
|
||||
alias: "request_path",
|
||||
expression: "request_path",
|
||||
},
|
||||
{ alias: "request_body", expression: "['req.body']" },
|
||||
{ alias: "response_body", expression: "res" },
|
||||
{ alias: "org_id", expression: "['context.org_id']" },
|
||||
{ alias: "customer_id", expression: "['context.customer_id']" },
|
||||
{ alias: "entity_id", expression: "['context.entity_id']" },
|
||||
{ alias: "stripe_event_id", expression: "['stripe_event.id']" },
|
||||
{ alias: "stripe_event_type", expression: "['stripe_event.type']" },
|
||||
{ alias: "stripe_object_id", expression: "['stripe_event.object_id']" },
|
||||
];
|
||||
|
||||
const tenantClauses = ({ ctx }: RequestLogsAplInput): string[] => [
|
||||
`| where ['context.org_id'] == '${escapeAplString(ctx.org.id)}'`,
|
||||
`| where ['context.org_slug'] == '${escapeAplString(ctx.org.slug)}'`,
|
||||
`| where (['context.env'] == '${escapeAplString(ctx.env)}' or env == '${escapeAplString(ctx.env)}')`,
|
||||
];
|
||||
|
||||
const projectionStage = (): string =>
|
||||
`| project ${REQUEST_LOG_PROJECTION.map(
|
||||
({ alias, expression }) => `${alias} = ${expression}`,
|
||||
).join(", ")}`;
|
||||
|
||||
export const buildRequestLogsApl = (input: RequestLogsAplInput): string => {
|
||||
const ast = parseRestrictedApl({
|
||||
query: input.query,
|
||||
allowedStages: input.allowedStages,
|
||||
});
|
||||
const userStages = restrictedAplToApl(ast);
|
||||
const shouldAppendDefaultOrder =
|
||||
(input.appendDefaultOrder ?? true) &&
|
||||
!userStages.some((stage) => stage.startsWith("| order by "));
|
||||
|
||||
return [
|
||||
"['express']",
|
||||
...tenantClauses(input),
|
||||
"| where isnotnull(statusCode)",
|
||||
"| where isnotnull(['req.url'])",
|
||||
"| extend request_path = tostring(parse_url(['req.url']).path)",
|
||||
"| extend source = case(request_path startswith '/v1', 'api_request', request_path startswith '/webhooks/connect/', 'stripe_webhook', request_path startswith '/webhooks/stripe/', 'stripe_webhook', '')",
|
||||
projectionStage(),
|
||||
"| where source in ('api_request', 'stripe_webhook')",
|
||||
...userStages,
|
||||
shouldAppendDefaultOrder ? "| order by timestamp desc" : null,
|
||||
`| limit ${input.limit}`,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n");
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
export type RequestLogSource = "api_request" | "stripe_webhook";
|
||||
|
||||
export type ApiRequestLogEntry = {
|
||||
timestamp: string;
|
||||
source: RequestLogSource | null;
|
||||
status_code: number;
|
||||
request: {
|
||||
method: string | null;
|
||||
url: string | null;
|
||||
path: string | null;
|
||||
};
|
||||
context: {
|
||||
org_id: string | null;
|
||||
customer_id: string | null;
|
||||
entity_id: string | null;
|
||||
};
|
||||
stripe: {
|
||||
event_id: string | null;
|
||||
event_type: string | null;
|
||||
object_id: string | null;
|
||||
};
|
||||
request_body: unknown | null;
|
||||
response_body: unknown | null;
|
||||
};
|
||||
|
||||
type AxiomMatch = {
|
||||
_time?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const pickString = (
|
||||
data: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): string | null => {
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pickNumber = (
|
||||
data: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | null => {
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pickUnknown = (
|
||||
data: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): unknown | null => {
|
||||
for (const key of keys) {
|
||||
if (key in data) return data[key] ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const extractPath = (url: string | null): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url.startsWith("/") ? url.split("?")[0] : null;
|
||||
}
|
||||
};
|
||||
|
||||
const sourceFromPath = (path: string | null): RequestLogSource | null => {
|
||||
if (path?.startsWith("/v1") === true) return "api_request";
|
||||
if (
|
||||
path?.startsWith("/webhooks/connect/") === true ||
|
||||
path?.startsWith("/webhooks/stripe/") === true
|
||||
) {
|
||||
return "stripe_webhook";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pickSource = (
|
||||
data: Record<string, unknown>,
|
||||
path: string | null,
|
||||
): RequestLogSource | null => {
|
||||
const source = pickString(data, ["source"]);
|
||||
if (source === "api_request" || source === "stripe_webhook") return source;
|
||||
return sourceFromPath(path);
|
||||
};
|
||||
|
||||
export const projectRequestLog = (match: AxiomMatch): ApiRequestLogEntry => {
|
||||
const data = match.data ?? {};
|
||||
const url = pickString(data, ["request_url", "req.url", "url"]);
|
||||
const projectedPath = pickString(data, ["request_path"]);
|
||||
const path = projectedPath ?? extractPath(url);
|
||||
const source = pickSource(data, path);
|
||||
|
||||
return {
|
||||
timestamp: pickString(data, ["timestamp"]) ?? match._time ?? "",
|
||||
source,
|
||||
status_code: pickNumber(data, ["status_code", "statusCode"]) ?? 0,
|
||||
request: {
|
||||
method: pickString(data, ["request_method", "req.method", "method"]),
|
||||
url,
|
||||
path,
|
||||
},
|
||||
context: {
|
||||
org_id: pickString(data, ["org_id", "context.org_id"]),
|
||||
customer_id: pickString(data, [
|
||||
"customer_id",
|
||||
"context.customer_id",
|
||||
"req.customer_id",
|
||||
]),
|
||||
entity_id: pickString(data, [
|
||||
"entity_id",
|
||||
"context.entity_id",
|
||||
"req.entity_id",
|
||||
]),
|
||||
},
|
||||
stripe: {
|
||||
event_id: pickString(data, ["stripe_event_id", "stripe_event.id"]),
|
||||
event_type: pickString(data, ["stripe_event_type", "stripe_event.type"]),
|
||||
object_id: pickString(data, [
|
||||
"stripe_object_id",
|
||||
"stripe_event.object_id",
|
||||
]),
|
||||
},
|
||||
request_body: pickUnknown(data, ["request_body", "req.body"]),
|
||||
response_body: pickUnknown(data, ["response_body", "res"]),
|
||||
};
|
||||
};
|
||||
|
||||
export const isExternalRequestLog = (log: ApiRequestLogEntry): boolean =>
|
||||
log.source === "api_request" || log.source === "stripe_webhook";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isAxiomConfigured } from "@/external/axiom/initAxiom.js";
|
||||
import { queryAxiom } from "@/external/axiom/queryAxiom.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { buildRequestLogsApl } from "./buildRequestLogsApl.js";
|
||||
import {
|
||||
isExternalRequestLog,
|
||||
projectRequestLog,
|
||||
} from "./projectRequestLog.js";
|
||||
|
||||
export const searchRequestLogs = async ({
|
||||
ctx,
|
||||
query,
|
||||
range,
|
||||
limit,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
query?: string;
|
||||
range: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
limit: number;
|
||||
}) => {
|
||||
if (!isAxiomConfigured()) {
|
||||
return { list: [], unconfigured: true };
|
||||
}
|
||||
|
||||
const apl = buildRequestLogsApl({
|
||||
ctx,
|
||||
query,
|
||||
limit,
|
||||
allowedStages: ["where", "orderBy", "limit"],
|
||||
appendDefaultOrder: true,
|
||||
});
|
||||
|
||||
const result = await queryAxiom({
|
||||
apl,
|
||||
options: {
|
||||
startTime: range.startDate,
|
||||
endTime: range.endDate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
list: (result.matches ?? [])
|
||||
.map(projectRequestLog)
|
||||
.filter(isExternalRequestLog)
|
||||
.slice(0, limit),
|
||||
};
|
||||
};
|
||||
48
server/src/internal/logs/handlers/handleQueryLogs.ts
Normal file
48
server/src/internal/logs/handlers/handleQueryLogs.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { queryLogs } from "../actions/queryLogs/queryLogs.js";
|
||||
import { parseRestrictedApl } from "../parser/restrictedApl.js";
|
||||
import {
|
||||
getQueryLogsRangePolicy,
|
||||
LogsRangeSchema,
|
||||
resolveLogsRange,
|
||||
} from "./logsRequestUtils.js";
|
||||
|
||||
const QueryLogsSchema = z
|
||||
.object({
|
||||
query: z.string().min(1).max(4000),
|
||||
range: LogsRangeSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const handleQueryLogs = createRoute({
|
||||
scopes: [Scopes.Analytics.Read],
|
||||
body: QueryLogsSchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const ast = parseRestrictedApl({
|
||||
query: body.query,
|
||||
allowedStages: ["where", "summarize", "project", "orderBy", "limit"],
|
||||
});
|
||||
const rangePolicy = getQueryLogsRangePolicy(ast);
|
||||
|
||||
const range = resolveLogsRange({
|
||||
startDate: body.range?.start_date,
|
||||
endDate: body.range?.end_date,
|
||||
...rangePolicy,
|
||||
});
|
||||
|
||||
const result = await queryLogs({
|
||||
ctx,
|
||||
query: body.query,
|
||||
range,
|
||||
limit: body.limit,
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
},
|
||||
});
|
||||
42
server/src/internal/logs/handlers/handleSearchLogs.ts
Normal file
42
server/src/internal/logs/handlers/handleSearchLogs.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { searchRequestLogs } from "../actions/searchRequestLogs/searchRequestLogs.js";
|
||||
import { parseRestrictedApl } from "../parser/restrictedApl.js";
|
||||
import { LogsRangeSchema, resolveLogsRange } from "./logsRequestUtils.js";
|
||||
|
||||
const SearchLogsSchema = z
|
||||
.object({
|
||||
query: z.string().max(4000).optional(),
|
||||
range: LogsRangeSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const handleSearchLogs = createRoute({
|
||||
scopes: [Scopes.Analytics.Read],
|
||||
body: SearchLogsSchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
parseRestrictedApl({
|
||||
query: body.query,
|
||||
allowedStages: ["where", "orderBy", "limit"],
|
||||
});
|
||||
|
||||
const range = resolveLogsRange({
|
||||
startDate: body.range?.start_date,
|
||||
endDate: body.range?.end_date,
|
||||
});
|
||||
|
||||
const result = await searchRequestLogs({
|
||||
ctx,
|
||||
query: body.query,
|
||||
range,
|
||||
limit: body.limit,
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
},
|
||||
});
|
||||
105
server/src/internal/logs/handlers/logsRequestUtils.ts
Normal file
105
server/src/internal/logs/handlers/logsRequestUtils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { z } from "zod/v4";
|
||||
import type {
|
||||
RestrictedAplAst,
|
||||
RestrictedAplExpr,
|
||||
RestrictedAplField,
|
||||
} from "../parser/restrictedApl.js";
|
||||
|
||||
const days = (count: number) => count * 24 * 60 * 60 * 1000;
|
||||
|
||||
const SEARCH_MAX_RANGE_MS = days(7);
|
||||
const QUERY_ORG_MAX_RANGE_MS = days(15);
|
||||
const QUERY_CUSTOMER_MAX_RANGE_MS = days(30);
|
||||
const DEFAULT_RANGE_MS = 30 * 60 * 1000;
|
||||
|
||||
const isoDateTimeString = z.string().refine((value) => {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime());
|
||||
}, "Expected an ISO datetime string");
|
||||
|
||||
export const LogsRangeSchema = z
|
||||
.object({
|
||||
start_date: isoDateTimeString.optional(),
|
||||
end_date: isoDateTimeString.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const resolveLogsRange = ({
|
||||
startDate,
|
||||
endDate,
|
||||
defaultRangeMs = DEFAULT_RANGE_MS,
|
||||
maxRangeMs = SEARCH_MAX_RANGE_MS,
|
||||
maxRangeLabel = "7 days",
|
||||
now = new Date(),
|
||||
}: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
defaultRangeMs?: number;
|
||||
maxRangeMs?: number;
|
||||
maxRangeLabel?: string;
|
||||
now?: Date;
|
||||
}) => {
|
||||
const end = endDate ? new Date(endDate) : now;
|
||||
const start = startDate
|
||||
? new Date(startDate)
|
||||
: new Date(end.getTime() - defaultRangeMs);
|
||||
|
||||
if (start.getTime() >= end.getTime()) {
|
||||
throw new RecaseError({
|
||||
message: "range.start_date must be before range.end_date",
|
||||
code: ErrCode.InvalidInputs,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
if (end.getTime() - start.getTime() > maxRangeMs) {
|
||||
throw new RecaseError({
|
||||
message: `Log range cannot exceed ${maxRangeLabel}`,
|
||||
code: ErrCode.InvalidInputs,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const isCustomerIdField = (field: RestrictedAplField) =>
|
||||
field.kind === "topLevel" && field.name === "customer_id";
|
||||
|
||||
const exprHasCustomerIdFilter = (expr: RestrictedAplExpr): boolean => {
|
||||
switch (expr.kind) {
|
||||
case "comparison":
|
||||
case "stringMatch":
|
||||
case "in":
|
||||
return isCustomerIdField(expr.field);
|
||||
case "and":
|
||||
case "or":
|
||||
return (
|
||||
exprHasCustomerIdFilter(expr.left) ||
|
||||
exprHasCustomerIdFilter(expr.right)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueryLogsRangePolicy = (ast: RestrictedAplAst) => {
|
||||
const hasCustomerIdFilter = ast.stages.some(
|
||||
(stage) => stage.kind === "where" && exprHasCustomerIdFilter(stage.expr),
|
||||
);
|
||||
|
||||
return hasCustomerIdFilter
|
||||
? {
|
||||
defaultRangeMs: QUERY_CUSTOMER_MAX_RANGE_MS,
|
||||
maxRangeMs: QUERY_CUSTOMER_MAX_RANGE_MS,
|
||||
maxRangeLabel: "30 days",
|
||||
}
|
||||
: {
|
||||
defaultRangeMs: QUERY_ORG_MAX_RANGE_MS,
|
||||
maxRangeMs: QUERY_ORG_MAX_RANGE_MS,
|
||||
maxRangeLabel: "15 days",
|
||||
};
|
||||
};
|
||||
9
server/src/internal/logs/logsRouter.ts
Normal file
9
server/src/internal/logs/logsRouter.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleQueryLogs } from "./handlers/handleQueryLogs.js";
|
||||
import { handleSearchLogs } from "./handlers/handleSearchLogs.js";
|
||||
|
||||
export const logsRpcRouter = new Hono<HonoEnv>();
|
||||
|
||||
logsRpcRouter.post("/logs.search", ...handleSearchLogs);
|
||||
logsRpcRouter.post("/logs.query", ...handleQueryLogs);
|
||||
795
server/src/internal/logs/parser/restrictedApl.ts
Normal file
795
server/src/internal/logs/parser/restrictedApl.ts
Normal file
@@ -0,0 +1,795 @@
|
||||
import {
|
||||
DEFAULT_RESTRICTED_APL_STAGES,
|
||||
RESTRICTED_APL_DANGEROUS_TEXT_PATTERNS,
|
||||
RESTRICTED_APL_FIELD_ALIASES,
|
||||
RESTRICTED_APL_MAX_LIMIT,
|
||||
RESTRICTED_APL_MAX_NESTED_PATH_DEPTH,
|
||||
RESTRICTED_APL_NESTED_ROOTS,
|
||||
RESTRICTED_APL_NUMERIC_AGGREGATE_FIELDS,
|
||||
RESTRICTED_APL_TOP_LEVEL_FIELDS,
|
||||
type RestrictedAplNestedRoot,
|
||||
type RestrictedAplStageKind,
|
||||
type RestrictedAplTopLevelField,
|
||||
SAFE_APL_IDENTIFIER,
|
||||
} from "./restrictedAplConfig.js";
|
||||
|
||||
export type RestrictedAplField =
|
||||
| {
|
||||
kind: "topLevel";
|
||||
name: RestrictedAplTopLevelField;
|
||||
}
|
||||
| {
|
||||
kind: "nested";
|
||||
root: RestrictedAplNestedRoot;
|
||||
path: string[];
|
||||
};
|
||||
|
||||
export type LiteralValue = string | number | boolean | null;
|
||||
|
||||
export type CompareOperator = "==" | "!=" | ">" | ">=" | "<" | "<=";
|
||||
|
||||
export type RestrictedAplExpr =
|
||||
| {
|
||||
kind: "comparison";
|
||||
field: RestrictedAplField;
|
||||
op: CompareOperator;
|
||||
value: LiteralValue;
|
||||
}
|
||||
| {
|
||||
kind: "stringMatch";
|
||||
field: RestrictedAplField;
|
||||
op: "contains" | "startswith";
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
kind: "in";
|
||||
field: RestrictedAplField;
|
||||
values: LiteralValue[];
|
||||
}
|
||||
| {
|
||||
kind: "and" | "or";
|
||||
left: RestrictedAplExpr;
|
||||
right: RestrictedAplExpr;
|
||||
};
|
||||
|
||||
export type SummarizeFunction =
|
||||
| { kind: "count" }
|
||||
| { kind: "countif"; expr: RestrictedAplExpr }
|
||||
| {
|
||||
kind: "numeric";
|
||||
name: "avg" | "sum" | "min" | "max";
|
||||
field: RestrictedAplField;
|
||||
}
|
||||
| {
|
||||
kind: "percentile";
|
||||
field: RestrictedAplField;
|
||||
percentile: number;
|
||||
};
|
||||
|
||||
export type SummarizeAggregation = {
|
||||
alias: string;
|
||||
fn: SummarizeFunction;
|
||||
};
|
||||
|
||||
export type AplReference =
|
||||
| {
|
||||
kind: "field";
|
||||
field: RestrictedAplField;
|
||||
}
|
||||
| {
|
||||
kind: "identifier";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProjectColumn = {
|
||||
source: AplReference;
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
export type RestrictedAplStage =
|
||||
| { kind: "where"; expr: RestrictedAplExpr }
|
||||
| {
|
||||
kind: "orderBy";
|
||||
target: AplReference;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
| { kind: "limit"; value: number }
|
||||
| {
|
||||
kind: "summarize";
|
||||
aggregations: SummarizeAggregation[];
|
||||
by: RestrictedAplField[];
|
||||
}
|
||||
| {
|
||||
kind: "project";
|
||||
columns: ProjectColumn[];
|
||||
};
|
||||
|
||||
export type RestrictedAplAst = {
|
||||
stages: RestrictedAplStage[];
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { kind: "identifier"; value: string }
|
||||
| { kind: "string"; value: string }
|
||||
| { kind: "number"; value: number }
|
||||
| {
|
||||
kind: "symbol";
|
||||
value:
|
||||
| "|"
|
||||
| "("
|
||||
| ")"
|
||||
| ","
|
||||
| "="
|
||||
| "=="
|
||||
| "!="
|
||||
| ">"
|
||||
| ">="
|
||||
| "<"
|
||||
| "<=";
|
||||
};
|
||||
|
||||
type SymbolValue = Extract<Token, { kind: "symbol" }>["value"];
|
||||
|
||||
const textDecoder = (value: string) =>
|
||||
value.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
|
||||
|
||||
const assertNoDangerousText = (query: string) => {
|
||||
for (const { pattern, message } of RESTRICTED_APL_DANGEROUS_TEXT_PATTERNS) {
|
||||
if (pattern.test(query)) throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const tokenize = (query: string): Token[] => {
|
||||
assertNoDangerousText(query);
|
||||
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < query.length) {
|
||||
const char = query[i];
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "|") {
|
||||
tokens.push({ kind: "symbol", value: "|" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "(" || char === ")" || char === ",") {
|
||||
tokens.push({ kind: "symbol", value: char });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const two = query.slice(i, i + 2);
|
||||
if (two === "==" || two === "!=" || two === ">=" || two === "<=") {
|
||||
tokens.push({ kind: "symbol", value: two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ">" || char === "<") {
|
||||
tokens.push({ kind: "symbol", value: char });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "=") {
|
||||
tokens.push({ kind: "symbol", value: "=" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'") {
|
||||
let j = i + 1;
|
||||
let raw = "";
|
||||
while (j < query.length) {
|
||||
const current = query[j];
|
||||
if (current === "\\") {
|
||||
const next = query[j + 1];
|
||||
if (next !== "\\" && next !== "'") {
|
||||
throw new Error(
|
||||
"Only escaped quotes and backslashes are supported",
|
||||
);
|
||||
}
|
||||
raw += current + next;
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (current === "'") break;
|
||||
raw += current;
|
||||
j++;
|
||||
}
|
||||
if (j >= query.length || query[j] !== "'") {
|
||||
throw new Error("Unterminated string literal");
|
||||
}
|
||||
tokens.push({ kind: "string", value: textDecoder(raw) });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[0-9-]/.test(char)) {
|
||||
const match = query.slice(i).match(/^-?\d+(?:\.\d+)?/);
|
||||
if (!match) throw new Error("Invalid number literal");
|
||||
tokens.push({ kind: "number", value: Number(match[0]) });
|
||||
i += match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[A-Za-z_]/.test(char)) {
|
||||
const match = query.slice(i).match(/^[A-Za-z_][A-Za-z0-9_.]*/);
|
||||
if (!match) throw new Error("Invalid identifier");
|
||||
tokens.push({ kind: "identifier", value: match[0] });
|
||||
i += match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported query character: ${char}`);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const NESTED_ROOT_NAMES = Object.keys(
|
||||
RESTRICTED_APL_NESTED_ROOTS,
|
||||
) as RestrictedAplNestedRoot[];
|
||||
|
||||
const isNestedRoot = (value: string): value is RestrictedAplNestedRoot =>
|
||||
NESTED_ROOT_NAMES.includes(value as RestrictedAplNestedRoot);
|
||||
|
||||
const fieldDisplayName = (field: RestrictedAplField): string =>
|
||||
field.kind === "topLevel"
|
||||
? field.name
|
||||
: `${field.root}.${field.path.join(".")}`;
|
||||
|
||||
const resolveFieldIdentifier = (raw: string): RestrictedAplField | null => {
|
||||
const topLevel = RESTRICTED_APL_FIELD_ALIASES[raw];
|
||||
if (topLevel) return { kind: "topLevel", name: topLevel };
|
||||
|
||||
const [root, ...path] = raw.split(".");
|
||||
if (!isNestedRoot(root)) return null;
|
||||
|
||||
if (path.length === 0 || path.length > RESTRICTED_APL_MAX_NESTED_PATH_DEPTH) {
|
||||
throw new Error(
|
||||
`Nested query field must have 1-${RESTRICTED_APL_MAX_NESTED_PATH_DEPTH} path segments: ${raw}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const segment of path) {
|
||||
if (!SAFE_APL_IDENTIFIER.test(segment)) {
|
||||
throw new Error(`Unsafe nested query field segment: ${segment}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "nested", root, path };
|
||||
};
|
||||
|
||||
class Parser {
|
||||
private index = 0;
|
||||
|
||||
constructor(private readonly tokens: Token[]) {}
|
||||
|
||||
parse(): RestrictedAplAst {
|
||||
const stages: RestrictedAplStage[] = [];
|
||||
|
||||
this.consumePipeIfPresent();
|
||||
while (!this.isDone()) {
|
||||
stages.push(this.parseStage());
|
||||
if (this.isDone()) break;
|
||||
this.expectSymbol("|");
|
||||
}
|
||||
|
||||
return { stages };
|
||||
}
|
||||
|
||||
private parseStage(): RestrictedAplStage {
|
||||
const keyword = this.expectIdentifier().toLowerCase();
|
||||
switch (keyword) {
|
||||
case "where":
|
||||
return { kind: "where", expr: this.parseOrExpr() };
|
||||
case "order": {
|
||||
this.expectKeyword("by");
|
||||
const target = this.expectSafeIdentifierOrField();
|
||||
const direction = this.peekIdentifierLower();
|
||||
if (direction === "asc" || direction === "desc") {
|
||||
this.index++;
|
||||
return { kind: "orderBy", target, direction };
|
||||
}
|
||||
return { kind: "orderBy", target, direction: "desc" };
|
||||
}
|
||||
case "limit":
|
||||
case "take": {
|
||||
const value = this.expectLimit();
|
||||
return { kind: "limit", value };
|
||||
}
|
||||
case "summarize":
|
||||
return this.parseSummarize();
|
||||
case "project":
|
||||
return this.parseProject();
|
||||
default:
|
||||
throw new Error(`Unsupported query stage: ${keyword}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseSummarize(): RestrictedAplStage {
|
||||
const aggregations: SummarizeAggregation[] = [];
|
||||
|
||||
while (this.peekIdentifierLower() !== "by" && !this.isStageBoundary()) {
|
||||
const alias = this.expectSafeAlias();
|
||||
this.expectSymbol("=");
|
||||
aggregations.push({ alias, fn: this.parseSummarizeFunction() });
|
||||
|
||||
if (this.peekSymbol(",")) {
|
||||
this.index++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (aggregations.length === 0) {
|
||||
throw new Error("summarize requires at least one aggregation");
|
||||
}
|
||||
|
||||
const by: RestrictedAplField[] = [];
|
||||
if (this.peekIdentifierLower() === "by") {
|
||||
this.index++;
|
||||
while (!this.isStageBoundary()) {
|
||||
by.push(this.expectField());
|
||||
if (this.peekSymbol(",")) {
|
||||
this.index++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (by.length === 0) throw new Error("summarize by requires fields");
|
||||
}
|
||||
|
||||
return { kind: "summarize", aggregations, by };
|
||||
}
|
||||
|
||||
private parseSummarizeFunction(): SummarizeFunction {
|
||||
const name = this.expectIdentifier().toLowerCase();
|
||||
this.expectSymbol("(");
|
||||
|
||||
if (name === "count") {
|
||||
this.expectSymbol(")");
|
||||
return { kind: "count" };
|
||||
}
|
||||
|
||||
if (name === "countif") {
|
||||
const expr = this.parseOrExpr();
|
||||
this.expectSymbol(")");
|
||||
return { kind: "countif", expr };
|
||||
}
|
||||
|
||||
if (name === "avg" || name === "sum" || name === "min" || name === "max") {
|
||||
const field = this.expectNumericAggregateField();
|
||||
this.expectSymbol(")");
|
||||
return { kind: "numeric", name, field };
|
||||
}
|
||||
|
||||
if (name === "percentile") {
|
||||
const field = this.expectNumericAggregateField();
|
||||
this.expectSymbol(",");
|
||||
const percentile = this.expectNumberLiteral();
|
||||
this.expectSymbol(")");
|
||||
if (percentile <= 0 || percentile >= 100) {
|
||||
throw new Error("percentile must be between 0 and 100");
|
||||
}
|
||||
return { kind: "percentile", field, percentile };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported summarize function: ${name}`);
|
||||
}
|
||||
|
||||
private parseProject(): RestrictedAplStage {
|
||||
const columns: ProjectColumn[] = [];
|
||||
|
||||
while (!this.isStageBoundary()) {
|
||||
const first = this.expectIdentifier();
|
||||
if (this.peekSymbol("=")) {
|
||||
if (!SAFE_APL_IDENTIFIER.test(first)) {
|
||||
throw new Error(`Unsafe identifier: ${first}`);
|
||||
}
|
||||
this.index++;
|
||||
columns.push({
|
||||
alias: first,
|
||||
source: this.expectSafeIdentifierOrField(),
|
||||
});
|
||||
} else {
|
||||
columns.push({ source: this.resolveSafeIdentifierOrField(first) });
|
||||
}
|
||||
|
||||
if (this.peekSymbol(",")) {
|
||||
this.index++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (columns.length === 0) throw new Error("project requires fields");
|
||||
return { kind: "project", columns };
|
||||
}
|
||||
|
||||
private parseOrExpr(): RestrictedAplExpr {
|
||||
let expr = this.parseAndExpr();
|
||||
while (this.peekIdentifierLower() === "or") {
|
||||
this.index++;
|
||||
expr = { kind: "or", left: expr, right: this.parseAndExpr() };
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
private parseAndExpr(): RestrictedAplExpr {
|
||||
let expr = this.parsePrimaryExpr();
|
||||
while (this.peekIdentifierLower() === "and") {
|
||||
this.index++;
|
||||
expr = { kind: "and", left: expr, right: this.parsePrimaryExpr() };
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
private parsePrimaryExpr(): RestrictedAplExpr {
|
||||
if (this.peekSymbol("(")) {
|
||||
this.index++;
|
||||
const expr = this.parseOrExpr();
|
||||
this.expectSymbol(")");
|
||||
return expr;
|
||||
}
|
||||
return this.parsePredicate();
|
||||
}
|
||||
|
||||
private parsePredicate(): RestrictedAplExpr {
|
||||
const field = this.expectField();
|
||||
const opToken = this.next();
|
||||
|
||||
if (!opToken) throw new Error("Expected operator");
|
||||
|
||||
if (opToken.kind === "identifier") {
|
||||
const op = opToken.value.toLowerCase();
|
||||
if (op === "contains" || op === "startswith") {
|
||||
const value = this.expectString();
|
||||
return { kind: "stringMatch", field, op, value };
|
||||
}
|
||||
|
||||
if (op === "in") {
|
||||
this.expectSymbol("(");
|
||||
const values: LiteralValue[] = [];
|
||||
while (!this.peekSymbol(")")) {
|
||||
values.push(this.expectLiteral());
|
||||
if (this.peekSymbol(",")) {
|
||||
this.index++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.expectSymbol(")");
|
||||
if (values.length === 0)
|
||||
throw new Error("in requires at least one value");
|
||||
return { kind: "in", field, values };
|
||||
}
|
||||
}
|
||||
|
||||
if (opToken.kind === "symbol" && this.isCompareOperator(opToken.value)) {
|
||||
return {
|
||||
kind: "comparison",
|
||||
field,
|
||||
op: opToken.value,
|
||||
value: this.expectLiteral(),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unsupported predicate operator");
|
||||
}
|
||||
|
||||
private expectLimit(): number {
|
||||
const token = this.next();
|
||||
if (!token || token.kind !== "number" || !Number.isInteger(token.value)) {
|
||||
throw new Error("limit must be an integer");
|
||||
}
|
||||
if (token.value < 1 || token.value > RESTRICTED_APL_MAX_LIMIT) {
|
||||
throw new Error(
|
||||
`limit must be between 1 and ${RESTRICTED_APL_MAX_LIMIT}`,
|
||||
);
|
||||
}
|
||||
return token.value;
|
||||
}
|
||||
|
||||
private expectNumberLiteral(): number {
|
||||
const token = this.next();
|
||||
if (!token || token.kind !== "number" || !Number.isFinite(token.value)) {
|
||||
throw new Error("Expected number literal");
|
||||
}
|
||||
return token.value;
|
||||
}
|
||||
|
||||
private expectField(): RestrictedAplField {
|
||||
const raw = this.expectIdentifier();
|
||||
const field = resolveFieldIdentifier(raw);
|
||||
if (!field) throw new Error(`Unknown query field: ${raw}`);
|
||||
return field;
|
||||
}
|
||||
|
||||
private expectNumericAggregateField(): RestrictedAplField {
|
||||
const field = this.expectField();
|
||||
if (
|
||||
field.kind !== "topLevel" ||
|
||||
!RESTRICTED_APL_NUMERIC_AGGREGATE_FIELDS.has(field.name)
|
||||
) {
|
||||
throw new Error(
|
||||
`Field cannot be used in numeric aggregation: ${fieldDisplayName(field)}`,
|
||||
);
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
private expectSafeAlias(): string {
|
||||
const identifier = this.expectSafeIdentifier();
|
||||
if (resolveFieldIdentifier(identifier)) {
|
||||
throw new Error(`Aggregation alias cannot shadow field: ${identifier}`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private expectSafeIdentifier(): string {
|
||||
const identifier = this.expectIdentifier();
|
||||
if (!SAFE_APL_IDENTIFIER.test(identifier)) {
|
||||
throw new Error(`Unsafe identifier: ${identifier}`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private expectSafeIdentifierOrField(): AplReference {
|
||||
return this.resolveSafeIdentifierOrField(this.expectIdentifier());
|
||||
}
|
||||
|
||||
private resolveSafeIdentifierOrField(identifier: string): AplReference {
|
||||
const field = resolveFieldIdentifier(identifier);
|
||||
if (field) return { kind: "field", field };
|
||||
if (!SAFE_APL_IDENTIFIER.test(identifier)) {
|
||||
throw new Error(`Unsafe identifier: ${identifier}`);
|
||||
}
|
||||
return { kind: "identifier", name: identifier };
|
||||
}
|
||||
|
||||
private expectLiteral(): LiteralValue {
|
||||
const token = this.next();
|
||||
if (!token) throw new Error("Expected literal value");
|
||||
if (token.kind === "string" || token.kind === "number") return token.value;
|
||||
if (token.kind === "identifier") {
|
||||
const value = token.value.toLowerCase();
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
if (value === "null") return null;
|
||||
}
|
||||
throw new Error("Expected string, number, boolean, or null literal");
|
||||
}
|
||||
|
||||
private expectString(): string {
|
||||
const token = this.next();
|
||||
if (!token || token.kind !== "string") {
|
||||
throw new Error("Expected string literal");
|
||||
}
|
||||
return token.value;
|
||||
}
|
||||
|
||||
private expectIdentifier(): string {
|
||||
const token = this.next();
|
||||
if (!token || token.kind !== "identifier") {
|
||||
throw new Error("Expected identifier");
|
||||
}
|
||||
return token.value;
|
||||
}
|
||||
|
||||
private expectKeyword(value: string) {
|
||||
const identifier = this.expectIdentifier();
|
||||
if (identifier.toLowerCase() !== value) {
|
||||
throw new Error(`Expected ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
private expectSymbol(value: SymbolValue) {
|
||||
const token = this.next();
|
||||
if (!token || token.kind !== "symbol" || token.value !== value) {
|
||||
throw new Error(`Expected ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
private consumePipeIfPresent() {
|
||||
if (this.peekSymbol("|")) this.index++;
|
||||
}
|
||||
|
||||
private peekSymbol(value: string): boolean {
|
||||
const token = this.tokens[this.index];
|
||||
return token?.kind === "symbol" && token.value === value;
|
||||
}
|
||||
|
||||
private peekIdentifierLower(): string | null {
|
||||
const token = this.tokens[this.index];
|
||||
return token?.kind === "identifier" ? token.value.toLowerCase() : null;
|
||||
}
|
||||
|
||||
private next(): Token | undefined {
|
||||
return this.tokens[this.index++];
|
||||
}
|
||||
|
||||
private isDone(): boolean {
|
||||
return this.index >= this.tokens.length;
|
||||
}
|
||||
|
||||
private isStageBoundary(): boolean {
|
||||
return this.isDone() || this.peekSymbol("|");
|
||||
}
|
||||
|
||||
private isCompareOperator(value: string): value is CompareOperator {
|
||||
return (
|
||||
value === "==" ||
|
||||
value === "!=" ||
|
||||
value === ">" ||
|
||||
value === ">=" ||
|
||||
value === "<" ||
|
||||
value === "<="
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelFieldToApl = (field: RestrictedAplTopLevelField): string =>
|
||||
RESTRICTED_APL_TOP_LEVEL_FIELDS[field].apl;
|
||||
|
||||
const nestedFieldToApl = (
|
||||
field: Extract<RestrictedAplField, { kind: "nested" }>,
|
||||
) =>
|
||||
[
|
||||
RESTRICTED_APL_NESTED_ROOTS[field.root].apl,
|
||||
...field.path.map((segment) => `['${segment}']`),
|
||||
].join("");
|
||||
|
||||
const fieldToApl = (field: RestrictedAplField): string =>
|
||||
field.kind === "topLevel"
|
||||
? topLevelFieldToApl(field.name)
|
||||
: nestedFieldToApl(field);
|
||||
|
||||
const nestedFieldAlias = (
|
||||
field: Extract<RestrictedAplField, { kind: "nested" }>,
|
||||
) => [field.root, ...field.path].join("_");
|
||||
|
||||
const fieldToStringApl = (field: RestrictedAplField): string => {
|
||||
if (field.kind === "nested") return `tostring(${fieldToApl(field)})`;
|
||||
if (field.name === "request_body" || field.name === "response_body") {
|
||||
return `dynamic_to_json(${fieldToApl(field)})`;
|
||||
}
|
||||
return fieldToApl(field);
|
||||
};
|
||||
|
||||
const fieldToComparisonApl = (
|
||||
field: RestrictedAplField,
|
||||
value: LiteralValue,
|
||||
): string => {
|
||||
if (field.kind !== "nested") return fieldToApl(field);
|
||||
if (typeof value === "string") return `tostring(${fieldToApl(field)})`;
|
||||
if (typeof value === "number") return `todouble(${fieldToApl(field)})`;
|
||||
if (typeof value === "boolean") return `tobool(${fieldToApl(field)})`;
|
||||
return fieldToApl(field);
|
||||
};
|
||||
|
||||
const fieldToInApl = (
|
||||
field: RestrictedAplField,
|
||||
values: LiteralValue[],
|
||||
): string => {
|
||||
if (field.kind !== "nested") return fieldToApl(field);
|
||||
const nonNullValues = values.filter((value) => value !== null);
|
||||
if (nonNullValues.every((value) => typeof value === "string")) {
|
||||
return `tostring(${fieldToApl(field)})`;
|
||||
}
|
||||
if (nonNullValues.every((value) => typeof value === "number")) {
|
||||
return `todouble(${fieldToApl(field)})`;
|
||||
}
|
||||
if (nonNullValues.every((value) => typeof value === "boolean")) {
|
||||
return `tobool(${fieldToApl(field)})`;
|
||||
}
|
||||
return fieldToApl(field);
|
||||
};
|
||||
|
||||
const fieldToSummarizeByApl = (field: RestrictedAplField): string => {
|
||||
if (field.kind === "topLevel") return fieldToApl(field);
|
||||
return `${nestedFieldAlias(field)} = tostring(${fieldToApl(field)})`;
|
||||
};
|
||||
|
||||
const referenceToApl = (reference: AplReference): string => {
|
||||
if (reference.kind === "identifier") return reference.name;
|
||||
const { field } = reference;
|
||||
if (field.kind === "nested") return `tostring(${fieldToApl(field)})`;
|
||||
return fieldToApl(field);
|
||||
};
|
||||
|
||||
const referenceToProjectApl = ({ alias, source }: ProjectColumn): string => {
|
||||
if (alias) return `${alias} = ${referenceToApl(source)}`;
|
||||
if (source.kind === "field" && source.field.kind === "nested") {
|
||||
return `${nestedFieldAlias(source.field)} = ${referenceToApl(source)}`;
|
||||
}
|
||||
return referenceToApl(source);
|
||||
};
|
||||
|
||||
export const parseRestrictedApl = ({
|
||||
query,
|
||||
allowedStages,
|
||||
}: {
|
||||
query: string | undefined;
|
||||
allowedStages?: RestrictedAplStageKind[];
|
||||
}): RestrictedAplAst => {
|
||||
const trimmed = query?.trim();
|
||||
if (!trimmed) return { stages: [] };
|
||||
const ast = new Parser(tokenize(trimmed)).parse();
|
||||
const allowed = allowedStages ?? DEFAULT_RESTRICTED_APL_STAGES;
|
||||
for (const stage of ast.stages) {
|
||||
if (!allowed.includes(stage.kind)) {
|
||||
throw new Error(`Unsupported query stage: ${stage.kind}`);
|
||||
}
|
||||
}
|
||||
return ast;
|
||||
};
|
||||
|
||||
export const escapeAplString = (value: string): string =>
|
||||
value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
|
||||
const literalToApl = (value: LiteralValue): string => {
|
||||
if (typeof value === "string") return `'${escapeAplString(value)}'`;
|
||||
if (value === null) return "null";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const exprToApl = (expr: RestrictedAplExpr): string => {
|
||||
switch (expr.kind) {
|
||||
case "comparison":
|
||||
return `${fieldToComparisonApl(expr.field, expr.value)} ${expr.op} ${literalToApl(expr.value)}`;
|
||||
case "stringMatch":
|
||||
return `${fieldToStringApl(expr.field)} ${expr.op} '${escapeAplString(expr.value)}'`;
|
||||
case "in":
|
||||
return `${fieldToInApl(expr.field, expr.values)} in (${expr.values.map(literalToApl).join(", ")})`;
|
||||
case "and":
|
||||
case "or":
|
||||
return `(${exprToApl(expr.left)} ${expr.kind} ${exprToApl(expr.right)})`;
|
||||
}
|
||||
};
|
||||
|
||||
const summarizeFunctionToApl = (fn: SummarizeFunction): string => {
|
||||
switch (fn.kind) {
|
||||
case "count":
|
||||
return "count()";
|
||||
case "countif":
|
||||
return `countif(${exprToApl(fn.expr)})`;
|
||||
case "numeric":
|
||||
return `${fn.name}(${fieldToApl(fn.field)})`;
|
||||
case "percentile":
|
||||
return `percentile(${fieldToApl(fn.field)}, ${fn.percentile})`;
|
||||
}
|
||||
};
|
||||
|
||||
export const restrictedAplToApl = (ast: RestrictedAplAst): string[] =>
|
||||
ast.stages.map((stage) => {
|
||||
switch (stage.kind) {
|
||||
case "where":
|
||||
return `| where ${exprToApl(stage.expr)}`;
|
||||
case "orderBy":
|
||||
return `| order by ${referenceToApl(stage.target)} ${stage.direction}`;
|
||||
case "limit":
|
||||
return `| limit ${stage.value}`;
|
||||
case "summarize": {
|
||||
const aggregations = stage.aggregations
|
||||
.map(({ alias, fn }) => `${alias} = ${summarizeFunctionToApl(fn)}`)
|
||||
.join(", ");
|
||||
const by =
|
||||
stage.by.length > 0
|
||||
? ` by ${stage.by.map(fieldToSummarizeByApl).join(", ")}`
|
||||
: "";
|
||||
return `| summarize ${aggregations}${by}`;
|
||||
}
|
||||
case "project":
|
||||
return `| project ${stage.columns.map(referenceToProjectApl).join(", ")}`;
|
||||
}
|
||||
throw new Error("Unsupported restricted APL stage");
|
||||
});
|
||||
126
server/src/internal/logs/parser/restrictedAplConfig.ts
Normal file
126
server/src/internal/logs/parser/restrictedAplConfig.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The restricted log APL surface is intentionally smaller than Axiom APL.
|
||||
* Keep tenant-safety and query-cost controls visible here before extending it.
|
||||
*/
|
||||
export const RESTRICTED_APL_STAGE_KINDS = [
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"summarize",
|
||||
"project",
|
||||
] as const;
|
||||
|
||||
export type RestrictedAplStageKind =
|
||||
(typeof RESTRICTED_APL_STAGE_KINDS)[number];
|
||||
|
||||
/** Search/list endpoints default to filtering, ordering, and limiting only. */
|
||||
export const DEFAULT_RESTRICTED_APL_STAGES: RestrictedAplStageKind[] = [
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
];
|
||||
|
||||
/** Hard cap on user-supplied limits, independent of endpoint defaults. */
|
||||
export const RESTRICTED_APL_MAX_LIMIT = 200;
|
||||
|
||||
/** Dot-path body access stays shallow to avoid broad arbitrary object walks. */
|
||||
export const RESTRICTED_APL_MAX_NESTED_PATH_DEPTH = 4;
|
||||
|
||||
/** Identifier grammar for aliases and dot-path segments. No quoted keys in v1. */
|
||||
export const SAFE_APL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export const RESTRICTED_APL_TOP_LEVEL_FIELDS = {
|
||||
timestamp: {
|
||||
apl: "timestamp",
|
||||
aliases: ["timestamp"],
|
||||
},
|
||||
source: {
|
||||
apl: "source",
|
||||
aliases: ["source"],
|
||||
},
|
||||
status_code: {
|
||||
apl: "status_code",
|
||||
aliases: ["status_code", "statusCode"],
|
||||
},
|
||||
request_method: {
|
||||
apl: "request_method",
|
||||
aliases: ["request_method", "method", "request.method"],
|
||||
},
|
||||
request_url: {
|
||||
apl: "request_url",
|
||||
aliases: ["request_url", "request.url", "url"],
|
||||
},
|
||||
request_path: {
|
||||
apl: "request_path",
|
||||
aliases: ["request_path", "request.path", "path"],
|
||||
},
|
||||
request_body: {
|
||||
apl: "request_body",
|
||||
aliases: ["request_body"],
|
||||
},
|
||||
response_body: {
|
||||
apl: "response_body",
|
||||
aliases: ["response_body"],
|
||||
},
|
||||
org_id: {
|
||||
apl: "org_id",
|
||||
aliases: ["org_id", "context.org_id"],
|
||||
},
|
||||
customer_id: {
|
||||
apl: "customer_id",
|
||||
aliases: ["customer_id", "context.customer_id"],
|
||||
},
|
||||
entity_id: {
|
||||
apl: "entity_id",
|
||||
aliases: ["entity_id", "context.entity_id"],
|
||||
},
|
||||
stripe_event_id: {
|
||||
apl: "stripe_event_id",
|
||||
aliases: ["stripe_event_id"],
|
||||
},
|
||||
stripe_event_type: {
|
||||
apl: "stripe_event_type",
|
||||
aliases: ["stripe_event_type"],
|
||||
},
|
||||
stripe_object_id: {
|
||||
apl: "stripe_object_id",
|
||||
aliases: ["stripe_object_id"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type RestrictedAplTopLevelField =
|
||||
keyof typeof RESTRICTED_APL_TOP_LEVEL_FIELDS;
|
||||
|
||||
/** Nested map access is only allowed over projected request/response payloads. */
|
||||
export const RESTRICTED_APL_NESTED_ROOTS = {
|
||||
request_body: {
|
||||
apl: "request_body",
|
||||
},
|
||||
response_body: {
|
||||
apl: "response_body",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type RestrictedAplNestedRoot = keyof typeof RESTRICTED_APL_NESTED_ROOTS;
|
||||
|
||||
/** Numeric aggregates are restricted to fields with stable numeric types. */
|
||||
export const RESTRICTED_APL_NUMERIC_AGGREGATE_FIELDS =
|
||||
new Set<RestrictedAplTopLevelField>(["status_code"]);
|
||||
|
||||
/** Raw APL escape hatches stay blocked; the compiler emits brackets itself. */
|
||||
export const RESTRICTED_APL_DANGEROUS_TEXT_PATTERNS = [
|
||||
{
|
||||
pattern: /[;[\]{}]/,
|
||||
message: "Query contains unsupported syntax",
|
||||
},
|
||||
{
|
||||
pattern: /--|\/\/|\/\*|\*\//,
|
||||
message: "Query comments are not supported",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const RESTRICTED_APL_FIELD_ALIASES = Object.fromEntries(
|
||||
Object.entries(RESTRICTED_APL_TOP_LEVEL_FIELDS).flatMap(([field, config]) =>
|
||||
config.aliases.map((alias) => [alias, field]),
|
||||
),
|
||||
) as Record<string, RestrictedAplTopLevelField>;
|
||||
@@ -12,6 +12,7 @@ export enum RateLimitType {
|
||||
Attach = "attach",
|
||||
ListCustomers = "list_customers",
|
||||
CustomerEntitiesGet = "customer_entities_get",
|
||||
Logs = "logs",
|
||||
}
|
||||
|
||||
type RoutePattern = {
|
||||
@@ -110,6 +111,13 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [
|
||||
type: RateLimitType.CustomerEntitiesGet,
|
||||
patterns: [route({ method: "POST", url: "/v1/entities.get" })],
|
||||
},
|
||||
{
|
||||
type: RateLimitType.Logs,
|
||||
patterns: [
|
||||
route({ method: "POST", url: "/v1/logs.search" }),
|
||||
route({ method: "POST", url: "/v1/logs.query" }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getRateLimitType = (c: Context<HonoEnv>) => {
|
||||
@@ -240,4 +248,11 @@ export const RATE_LIMIT_CONFIGS: Record<RateLimitType, RateLimitConfig> = {
|
||||
notInRedis: false,
|
||||
scope: RateLimitScope.Customer,
|
||||
},
|
||||
[RateLimitType.Logs]: {
|
||||
name: "logs",
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
notInRedis: false,
|
||||
scope: RateLimitScope.Org,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,8 +119,8 @@ export const getPlanResponse = async ({
|
||||
group: product.group || null,
|
||||
version: product.version,
|
||||
|
||||
add_on: product.is_add_on,
|
||||
auto_enable: product.is_default,
|
||||
add_on: product.is_add_on ?? false,
|
||||
auto_enable: product.is_default ?? false,
|
||||
|
||||
price: basePrice,
|
||||
items: planItems ?? [],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { billingRpcRouter } from "@/internal/billing/billingRouter";
|
||||
import { entityRpcRouter } from "@/internal/entities/entityRouter";
|
||||
import { eventsRpcRouter } from "@/internal/events/eventsRouter";
|
||||
import { featureRpcRouter } from "@/internal/features/featureRouter";
|
||||
import { logsRpcRouter } from "@/internal/logs/logsRouter";
|
||||
import { migrationRpcRouter } from "@/internal/migrations/v2/migrationRouter";
|
||||
import { platformRpcRouter } from "@/internal/platform/platformBeta/platformRpcRouter";
|
||||
import { plansRpcRouter } from "@/internal/products/productRouter";
|
||||
@@ -32,5 +33,6 @@ rpcRouter.route("", eventsRpcRouter);
|
||||
rpcRouter.route("", referralRpcRouter);
|
||||
rpcRouter.route("", entityRpcRouter);
|
||||
rpcRouter.route("", featureRpcRouter);
|
||||
rpcRouter.route("", logsRpcRouter);
|
||||
rpcRouter.route("", migrationRpcRouter);
|
||||
rpcRouter.route("", platformRpcRouter);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* C. Free product with free messages
|
||||
* D. One-time plan with prepaid messages
|
||||
* E. Premium product (same as pro but with higher prices)
|
||||
* F. Customer with 2 entity users
|
||||
* F. Customer with 2 entity uasers
|
||||
*
|
||||
* Run: bun server/tests/_temp/seed-scenarios.ts
|
||||
*/
|
||||
|
||||
88
server/tests/unit/logs/requestLogs/logsRange.test.ts
Normal file
88
server/tests/unit/logs/requestLogs/logsRange.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getQueryLogsRangePolicy,
|
||||
resolveLogsRange,
|
||||
} from "@/internal/logs/handlers/logsRequestUtils.js";
|
||||
import { parseRestrictedApl } from "@/internal/logs/parser/restrictedApl.js";
|
||||
|
||||
const queryStages = [
|
||||
"where",
|
||||
"summarize",
|
||||
"project",
|
||||
"orderBy",
|
||||
"limit",
|
||||
] as const;
|
||||
const now = new Date("2026-06-06T12:00:00.000Z");
|
||||
|
||||
const parseQuery = (query: string) =>
|
||||
parseRestrictedApl({
|
||||
query,
|
||||
allowedStages: [...queryStages],
|
||||
});
|
||||
|
||||
describe("request-log ranges", () => {
|
||||
test("defaults customer-filtered aggregate queries to 30 days", () => {
|
||||
const policy = getQueryLogsRangePolicy(
|
||||
parseQuery("where customer_id == 'cus_1' | summarize requests = count()"),
|
||||
);
|
||||
|
||||
expect(resolveLogsRange({ now, ...policy })).toEqual({
|
||||
startDate: "2026-05-07T12:00:00.000Z",
|
||||
endDate: "2026-06-06T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults org-scoped aggregate queries to 15 days", () => {
|
||||
const policy = getQueryLogsRangePolicy(
|
||||
parseQuery("where status_code >= 400 | summarize requests = count()"),
|
||||
);
|
||||
|
||||
expect(resolveLogsRange({ now, ...policy })).toEqual({
|
||||
startDate: "2026-05-22T12:00:00.000Z",
|
||||
endDate: "2026-06-06T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not treat customer grouping as a customer-scoped filter", () => {
|
||||
const policy = getQueryLogsRangePolicy(
|
||||
parseQuery("summarize requests = count() by customer_id"),
|
||||
);
|
||||
|
||||
expect(resolveLogsRange({ now, ...policy }).startDate).toBe(
|
||||
"2026-05-22T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects org-scoped aggregate ranges over 15 days", () => {
|
||||
const policy = getQueryLogsRangePolicy(
|
||||
parseQuery("summarize requests = count() by request_path"),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
resolveLogsRange({
|
||||
startDate: "2026-05-21T12:00:00.000Z",
|
||||
endDate: now.toISOString(),
|
||||
...policy,
|
||||
}),
|
||||
).toThrow("Log range cannot exceed 15 days");
|
||||
});
|
||||
|
||||
test("allows customer-filtered aggregate ranges up to 30 days", () => {
|
||||
const policy = getQueryLogsRangePolicy(
|
||||
parseQuery(
|
||||
"where context.customer_id == 'cus_1' | summarize requests = count()",
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
resolveLogsRange({
|
||||
startDate: "2026-05-07T12:00:00.000Z",
|
||||
endDate: now.toISOString(),
|
||||
...policy,
|
||||
}),
|
||||
).toEqual({
|
||||
startDate: "2026-05-07T12:00:00.000Z",
|
||||
endDate: "2026-06-06T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
130
server/tests/unit/logs/requestLogs/projectRequestLog.test.ts
Normal file
130
server/tests/unit/logs/requestLogs/projectRequestLog.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
isExternalRequestLog,
|
||||
projectRequestLog,
|
||||
} from "@/internal/logs/actions/searchRequestLogs/projectRequestLog.js";
|
||||
|
||||
describe("projectRequestLog", () => {
|
||||
test("projects the public request log shape", () => {
|
||||
const log = projectRequestLog({
|
||||
_time: "2026-06-06T10:00:00Z",
|
||||
data: {
|
||||
timestamp: "2026-06-06T10:00:00Z",
|
||||
source: "api_request",
|
||||
status_code: 201,
|
||||
duration_ms: 123,
|
||||
message: "not public",
|
||||
request_method: "POST",
|
||||
request_url: "https://api.useautumn.com/v1/customers",
|
||||
request_path: "/v1/customers",
|
||||
request_body: { id: "cus_123" },
|
||||
response_body: { ok: true },
|
||||
org_id: "org_123",
|
||||
customer_id: "cus_123",
|
||||
entity_id: "ent_123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(log).toEqual({
|
||||
timestamp: "2026-06-06T10:00:00Z",
|
||||
source: "api_request",
|
||||
status_code: 201,
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://api.useautumn.com/v1/customers",
|
||||
path: "/v1/customers",
|
||||
},
|
||||
context: {
|
||||
org_id: "org_123",
|
||||
customer_id: "cus_123",
|
||||
entity_id: "ent_123",
|
||||
},
|
||||
stripe: {
|
||||
event_id: null,
|
||||
event_type: null,
|
||||
object_id: null,
|
||||
},
|
||||
request_body: { id: "cus_123" },
|
||||
response_body: { ok: true },
|
||||
});
|
||||
|
||||
expect("id" in log).toBe(false);
|
||||
expect("duration_ms" in log).toBe(false);
|
||||
expect("message" in log).toBe(false);
|
||||
expect(isExternalRequestLog(log)).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to raw fields and derives path from URL", () => {
|
||||
const log = projectRequestLog({
|
||||
_time: "2026-06-06T10:00:00Z",
|
||||
data: {
|
||||
statusCode: 500,
|
||||
"req.method": "GET",
|
||||
"req.url": "https://api.useautumn.com/v1/check?x=1",
|
||||
"req.body": null,
|
||||
res: { error: "failed" },
|
||||
"context.org_id": "org_123",
|
||||
"req.customer_id": "cus_123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(log.request.path).toBe("/v1/check");
|
||||
expect(log.context.org_id).toBe("org_123");
|
||||
expect(log.context.customer_id).toBe("cus_123");
|
||||
expect(log.response_body).toEqual({ error: "failed" });
|
||||
});
|
||||
|
||||
test("projects public-safe Stripe webhook fields", () => {
|
||||
const log = projectRequestLog({
|
||||
_time: "2026-06-06T10:00:00Z",
|
||||
data: {
|
||||
status_code: 200,
|
||||
request_method: "POST",
|
||||
request_url: "https://api.useautumn.com/webhooks/connect/live",
|
||||
request_path: "/webhooks/connect/live",
|
||||
request_body: { id: "evt_123" },
|
||||
response_body: { received: true },
|
||||
org_id: "org_123",
|
||||
customer_id: "cus_123",
|
||||
stripe_event_id: "evt_123",
|
||||
stripe_event_type: "customer.subscription.updated",
|
||||
stripe_object_id: "sub_123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(log.source).toBe("stripe_webhook");
|
||||
expect(log.request.path).toBe("/webhooks/connect/live");
|
||||
expect(log.stripe).toEqual({
|
||||
event_id: "evt_123",
|
||||
event_type: "customer.subscription.updated",
|
||||
object_id: "sub_123",
|
||||
});
|
||||
expect(isExternalRequestLog(log)).toBe(true);
|
||||
});
|
||||
|
||||
test("derives Stripe webhook source from path", () => {
|
||||
const log = projectRequestLog({
|
||||
_time: "2026-06-06T10:00:00Z",
|
||||
data: {
|
||||
status_code: 200,
|
||||
request_url: "https://api.useautumn.com/webhooks/stripe/org_123/live",
|
||||
},
|
||||
});
|
||||
|
||||
expect(log.source).toBe("stripe_webhook");
|
||||
expect("webhook_route" in log.stripe).toBe(false);
|
||||
expect(isExternalRequestLog(log)).toBe(true);
|
||||
});
|
||||
|
||||
test("filters non-v1 paths", () => {
|
||||
const log = projectRequestLog({
|
||||
_time: "2026-06-06T10:00:00Z",
|
||||
data: {
|
||||
status_code: 200,
|
||||
request_url: "https://api.useautumn.com/slack/events",
|
||||
},
|
||||
});
|
||||
|
||||
expect(isExternalRequestLog(log)).toBe(false);
|
||||
});
|
||||
});
|
||||
265
server/tests/unit/logs/requestLogs/restrictedApl.test.ts
Normal file
265
server/tests/unit/logs/requestLogs/restrictedApl.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { buildRequestLogsApl } from "@/internal/logs/actions/searchRequestLogs/buildRequestLogsApl.js";
|
||||
import {
|
||||
parseRestrictedApl,
|
||||
restrictedAplToApl,
|
||||
} from "@/internal/logs/parser/restrictedApl.js";
|
||||
|
||||
const normalize = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||
|
||||
describe("restricted request-log APL", () => {
|
||||
test("parses where, order, and limit stages over projected fields", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"| where request_body contains 'price_id' and status_code >= 400 | order by timestamp desc | limit 50",
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| where (dynamic_to_json(request_body) contains 'price_id' and status_code >= 400)",
|
||||
"| order by timestamp desc",
|
||||
"| limit 50",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses public-safe source and Stripe webhook fields", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"where source == 'stripe_webhook' and stripe_event_type == 'customer.subscription.updated' and stripe_object_id == 'sub_123' | order by timestamp desc | limit 25",
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| where ((source == 'stripe_webhook' and stripe_event_type == 'customer.subscription.updated') and stripe_object_id == 'sub_123')",
|
||||
"| order by timestamp desc",
|
||||
"| limit 25",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses nested request and response body predicates", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"where request_body.feature_id == 'credits' and response_body.allowed == false and response_body.balance.remaining > 0",
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| where ((tostring(request_body['feature_id']) == 'credits' and tobool(response_body['allowed']) == false) and todouble(response_body['balance']['remaining']) > 0)",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses nested body fields in aggregate queries", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"where customer_id == 'cus_123' and request_body.event_name in ('credits', 'tokens') | summarize requests = count(), denied = countif(response_body.allowed == false) by request_body.event_name | project event_name = request_body_event_name, requests, denied | order by requests desc | limit 20",
|
||||
allowedStages: ["where", "summarize", "project", "orderBy", "limit"],
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| where (customer_id == 'cus_123' and tostring(request_body['event_name']) in ('credits', 'tokens'))",
|
||||
"| summarize requests = count(), denied = countif(tobool(response_body['allowed']) == false) by request_body_event_name = tostring(request_body['event_name'])",
|
||||
"| project event_name = request_body_event_name, requests, denied",
|
||||
"| order by requests desc",
|
||||
"| limit 20",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses nested body fields in project stages", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"project feature = request_body.feature_id, response_body.balance.remaining",
|
||||
allowedStages: ["project"],
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| project feature = tostring(request_body['feature_id']), response_body_balance_remaining = tostring(response_body['balance']['remaining'])",
|
||||
]);
|
||||
});
|
||||
|
||||
test("escapes strings when compiling back to APL", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query: "where request_body contains 'it\\'s ok'",
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| where dynamic_to_json(request_body) contains 'it\\'s ok'",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects dataset sources and raw APL field syntax", () => {
|
||||
expect(() =>
|
||||
parseRestrictedApl({ query: "['express'] | limit 10" }),
|
||||
).toThrow("unsupported syntax");
|
||||
expect(() =>
|
||||
parseRestrictedApl({ query: "where ['req.url'] contains '/v1'" }),
|
||||
).toThrow("unsupported syntax");
|
||||
});
|
||||
|
||||
test("rejects unsupported stages and comments", () => {
|
||||
expect(() => parseRestrictedApl({ query: "project request_body" })).toThrow(
|
||||
"Unsupported query stage",
|
||||
);
|
||||
expect(() =>
|
||||
parseRestrictedApl({ query: "where status_code == 200 // test" }),
|
||||
).toThrow("comments are not supported");
|
||||
});
|
||||
|
||||
test("rejects unknown fields and unsafe limits", () => {
|
||||
expect(() =>
|
||||
parseRestrictedApl({ query: "where secret contains 'x'" }),
|
||||
).toThrow("Unknown query field");
|
||||
expect(() =>
|
||||
parseRestrictedApl({ query: "where extras contains 'x'" }),
|
||||
).toThrow("Unknown query field");
|
||||
expect(() =>
|
||||
parseRestrictedApl({ query: "where workflow contains 'x'" }),
|
||||
).toThrow("Unknown query field");
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "where stripe_webhook_route == 'connect'",
|
||||
}),
|
||||
).toThrow("Unknown query field");
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query:
|
||||
"where request_body.feature_id.value.extra.too_deep.really_too_deep == 'x'",
|
||||
}),
|
||||
).toThrow("Nested query field must have 1-4 path segments");
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "where response_body.balances.api-calls.remaining == 1",
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() => parseRestrictedApl({ query: "limit 500" })).toThrow(
|
||||
"limit must be between 1 and 200",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects user-authored raw body access and parsing functions", () => {
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "where request_body['feature_id'] == 'credits'",
|
||||
}),
|
||||
).toThrow("unsupported syntax");
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "where parse_json(request_body).feature_id == 'credits'",
|
||||
}),
|
||||
).toThrow("Unsupported query character");
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "where todynamic(response_body).allowed == false",
|
||||
}),
|
||||
).toThrow("Unsupported query character");
|
||||
});
|
||||
|
||||
test("parses aggregate query stages", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"where status_code >= 400 | summarize errors = count(), failures = countif(status_code >= 500) by request_path | order by errors desc | limit 10",
|
||||
allowedStages: ["where", "summarize", "orderBy", "limit"],
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| where status_code >= 400",
|
||||
"| summarize errors = count(), failures = countif(status_code >= 500) by request_path",
|
||||
"| order by errors desc",
|
||||
"| limit 10",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses project stages over safe result aliases", () => {
|
||||
const ast = parseRestrictedApl({
|
||||
query:
|
||||
"summarize total = count() by request_method | project method = request_method, total",
|
||||
allowedStages: ["summarize", "project"],
|
||||
});
|
||||
|
||||
expect(restrictedAplToApl(ast)).toEqual([
|
||||
"| summarize total = count() by request_method",
|
||||
"| project method = request_method, total",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects aggregate stages when caller disallows them", () => {
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "summarize total = count() by request_path",
|
||||
allowedStages: ["where", "orderBy", "limit"],
|
||||
}),
|
||||
).toThrow("Unsupported query stage: summarize");
|
||||
});
|
||||
|
||||
test("rejects unsupported aggregate functions", () => {
|
||||
expect(() =>
|
||||
parseRestrictedApl({
|
||||
query: "summarize total = dcount(customer_id) by request_path",
|
||||
allowedStages: ["summarize"],
|
||||
}),
|
||||
).toThrow("Unsupported summarize function: dcount");
|
||||
});
|
||||
|
||||
test("builds tenant-projected APL before appending user stages", () => {
|
||||
const apl = buildRequestLogsApl({
|
||||
ctx: {
|
||||
org: { id: "org_123", slug: "acme" },
|
||||
env: AppEnv.Sandbox,
|
||||
},
|
||||
query: "where response_body contains 'checkout'",
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(normalize(apl)).toContain("['express'] | where");
|
||||
expect(apl).toContain("['context.org_id'] == 'org_123'");
|
||||
expect(apl).toContain("['context.org_slug'] == 'acme'");
|
||||
expect(apl).toContain("['context.env'] == 'sandbox'");
|
||||
expect(apl).not.toContain("context.orgId");
|
||||
expect(apl).not.toContain("context.orgSlug");
|
||||
expect(apl).toContain(
|
||||
"request_path = tostring(parse_url(['req.url']).path)",
|
||||
);
|
||||
expect(apl).toContain(
|
||||
"source = case(request_path startswith '/v1', 'api_request'",
|
||||
);
|
||||
expect(apl).not.toContain("stripe_webhook_route");
|
||||
expect(apl).toContain(
|
||||
"| where source in ('api_request', 'stripe_webhook')",
|
||||
);
|
||||
expect(apl).toContain(
|
||||
"| project timestamp = _time, source = source, status_code = statusCode",
|
||||
);
|
||||
expect(apl).toContain("stripe_event_id = ['stripe_event.id']");
|
||||
expect(apl).toContain(
|
||||
"| where dynamic_to_json(response_body) contains 'checkout'",
|
||||
);
|
||||
expect(apl).toContain("| limit 25");
|
||||
});
|
||||
|
||||
test("can omit default timestamp ordering for aggregate queries", () => {
|
||||
const apl = buildRequestLogsApl({
|
||||
ctx: {
|
||||
org: { id: "org_123", slug: "acme" },
|
||||
env: AppEnv.Sandbox,
|
||||
},
|
||||
query: "summarize total = count() by request_path",
|
||||
limit: 25,
|
||||
allowedStages: ["summarize"],
|
||||
appendDefaultOrder: false,
|
||||
});
|
||||
|
||||
expect(apl).toContain("| summarize total = count() by request_path");
|
||||
expect(apl).not.toContain("| order by timestamp desc");
|
||||
expect(apl).toContain("| limit 25");
|
||||
});
|
||||
|
||||
test("escapes tenant values in generated APL", () => {
|
||||
const apl = buildRequestLogsApl({
|
||||
ctx: {
|
||||
org: { id: "org_'x", slug: "slug\\x" },
|
||||
env: AppEnv.Live,
|
||||
},
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(apl).toContain("org_\\'x");
|
||||
expect(apl).toContain("slug\\\\x");
|
||||
});
|
||||
});
|
||||
40
server/tests/unit/products/get-plan-response.test.ts
Normal file
40
server/tests/unit/products/get-plan-response.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppEnv, type FullProduct } from "@autumn/shared";
|
||||
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
|
||||
|
||||
const baseProduct = {
|
||||
id: "legacy-plan",
|
||||
name: "Legacy Plan",
|
||||
description: null,
|
||||
group: "",
|
||||
version: 1,
|
||||
env: AppEnv.Sandbox,
|
||||
internal_id: "prod_internal",
|
||||
org_id: "org_123",
|
||||
created_at: 1,
|
||||
processor: null,
|
||||
base_variant_id: null,
|
||||
archived: false,
|
||||
config: { ignore_past_due: false },
|
||||
prices: [],
|
||||
entitlements: [],
|
||||
free_trial: null,
|
||||
free_trials: [],
|
||||
free_trial_ids: [],
|
||||
} satisfies Omit<FullProduct, "is_add_on" | "is_default">;
|
||||
|
||||
describe("getPlanResponse", () => {
|
||||
test("normalizes null product booleans to DB defaults", async () => {
|
||||
const response = await getPlanResponse({
|
||||
product: {
|
||||
...baseProduct,
|
||||
is_add_on: null,
|
||||
is_default: null,
|
||||
} as unknown as FullProduct,
|
||||
features: [],
|
||||
});
|
||||
|
||||
expect(response.add_on).toBe(false);
|
||||
expect(response.auto_enable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type { Context } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import {
|
||||
getRateLimitType,
|
||||
RATE_LIMIT_CONFIGS,
|
||||
RateLimitScope,
|
||||
RateLimitType,
|
||||
} from "@/internal/misc/rateLimiter/rateLimitConfigs.js";
|
||||
|
||||
@@ -110,6 +112,26 @@ describe("getRateLimitType", () => {
|
||||
).toBe(RateLimitType.CustomerEntitiesGet);
|
||||
});
|
||||
|
||||
test("classifies log endpoints into their org-scoped logs bucket", () => {
|
||||
expect(
|
||||
getRateLimitType(
|
||||
createContext({ method: "POST", path: "/v1/logs.search" }),
|
||||
),
|
||||
).toBe(RateLimitType.Logs);
|
||||
expect(
|
||||
getRateLimitType(
|
||||
createContext({ method: "POST", path: "/v1/logs.query" }),
|
||||
),
|
||||
).toBe(RateLimitType.Logs);
|
||||
|
||||
expect(RATE_LIMIT_CONFIGS[RateLimitType.Logs]).toMatchObject({
|
||||
name: "logs",
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
scope: RateLimitScope.Org,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to the general bucket for uncategorized routes", () => {
|
||||
expect(
|
||||
getRateLimitType(createContext({ method: "GET", path: "/v1/products" })),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
export { CreateBalanceParamsV0Schema } from "./balances/create/createBalanceParams.js";
|
||||
export { AttachParamsV1Schema } from "./billing/attachV2/attachParamsV1.js";
|
||||
export {
|
||||
CreateScheduleParamsV0Schema,
|
||||
CreateSchedulePhaseSchema,
|
||||
} from "./billing/createSchedule/createScheduleParamsV0.js";
|
||||
export { UpdateSubscriptionV1ParamsSchema } from "./billing/updateSubscription/updateSubscriptionV1Params.js";
|
||||
export { CreateBalanceParamsV0Schema } from "./balances/create/createBalanceParams.js";
|
||||
export { CreateCustomerParamsV1Schema } from "./customers/crud/createCustomerParams.js";
|
||||
export { GetCustomerParamsV1Schema } from "./customers/crud/getCustomerParams.js";
|
||||
export { ListCustomersV2_3ParamsSchema } from "./customers/crud/listCustomersParamsV2_3.js";
|
||||
export { UpdateCustomerParamsV1Schema } from "./customers/crud/updateCustomerParams.js";
|
||||
export { CreatePlanParamsV2Schema } from "./products/crud/createPlanParamsV1.js";
|
||||
export { GetPlanParamsV0Schema } from "./products/crud/getPlanParamsV0.js";
|
||||
export { ListPlanParamsSchema } from "./products/crud/listPlanParams.js";
|
||||
|
||||
Reference in New Issue
Block a user