Merge remote-tracking branch 'origin/dev' into uw-1-storage

# Conflicts:
#	.github/workflows/build.yml
#	ai
#	shared/drizzle/meta/0005_snapshot.json
#	shared/drizzle/meta/_journal.json
This commit is contained in:
Owen Greenhalgh
2026-06-08 12:23:06 +01:00
368 changed files with 52908 additions and 3756 deletions

View File

@@ -26,7 +26,7 @@ env:
# staging repo (autumn-staging) -> us-east-1
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
# Add short-lived PR branches here when you need staging without merging to dev.
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup uw-1-storage
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset uw-1-storage
jobs:
checks:

View File

@@ -39,11 +39,11 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.3.14
- name: Install dependencies
run: bun install
- name: Run unit tests
run: bun test tests/unit
run: bun test --isolate tests/unit
working-directory: server

2
ai

Submodule ai updated: 794c164aed...0e52f71fbd

View File

@@ -0,0 +1,79 @@
---
title: "Get Revenue Cat Keys"
openapi: "openapi POST /v1/platform.get_revenuecat_keys"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="organization_slug" type="string" required />
<DynamicParamField body="env" type="'test' | 'sandbox' | 'live'" required>
"test" and "sandbox" both target the sandbox environment
</DynamicParamField>
### Response
<DynamicResponseField name="apps" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="app_id" type="string" />
<DynamicResponseField name="app_type" type="string">
RevenueCat store type, e.g. test_store / app_store / play_store
</DynamicResponseField>
<DynamicResponseField name="name" type="string" />
<DynamicResponseField name="api_keys" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="id" type="string" />
<DynamicResponseField name="key" type="string">
The public SDK API key value
</DynamicResponseField>
<DynamicResponseField name="environment" type="string | null">
e.g. "production" / "sandbox"
</DynamicResponseField>
<DynamicResponseField name="app_id" type="string | null" />
<DynamicResponseField name="created_at" type="number" />
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="oauth_access_token" type="string | null">
Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.
</DynamicResponseField>
<ResponseExample>
```json 200
{
"apps": [
{
"app_id": "app1a2b3c4d",
"app_type": "test_store",
"name": "Acme (Test Store)",
"api_keys": [
{
"id": "apikey12345",
"key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ",
"environment": "production",
"app_id": "app1a2b3c4"
}
]
}
],
"oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"
}
```
</ResponseExample>

View File

@@ -0,0 +1,32 @@
---
title: "Link Revenue Cat"
openapi: "openapi POST /v1/platform.link_revenuecat"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="organization_slug" type="string" required />
<DynamicParamField body="env" type="'test' | 'live'" required />
<DynamicParamField body="project_name" type="string" required />
<DynamicParamField body="redirect_url" type="string" required />
### Response
<DynamicResponseField name="oauth_url" type="string" />
<ResponseExample>
```json 200
{
"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"
}
```
</ResponseExample>

View File

@@ -0,0 +1,77 @@
---
title: "Sync Revenue Cat"
openapi: "openapi POST /v1/platform.sync_revenuecat"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="organization_slug" type="string" required />
<DynamicParamField body="env" type="'test' | 'sandbox' | 'live'" required>
"test" and "sandbox" both target the sandbox environment
</DynamicParamField>
<DynamicParamField body="product_ids" type="string[]">
Plans to push. Omit to sync every plan in the org/env.
</DynamicParamField>
### Response
<DynamicResponseField name="results" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="plan_id" type="string" />
<DynamicResponseField name="status" type="'synced' | 'skipped' | 'error'" />
<DynamicResponseField name="store_identifier" type="string" />
<DynamicResponseField name="apps" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="app_id" type="string" />
<DynamicResponseField name="app_type" type="string" />
<DynamicResponseField name="product" type="'created' | 'updated' | 'exists'" />
<DynamicResponseField name="store_push" type="'pushed' | 'failed' | 'skipped'" />
<DynamicResponseField name="price" type="'set' | 'skipped' | 'failed'" />
<DynamicResponseField name="message" type="string" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="message" type="string" />
</Expandable>
</DynamicResponseField>
<ResponseExample>
```json 200
{
"results": [
{
"plan_id": "pro",
"status": "synced",
"store_identifier": "autumn.sandbox.org_123.pro",
"apps": [
{
"app_id": "app_test",
"app_type": "test_store",
"product": "created",
"store_push": "skipped",
"price": "set"
}
]
}
]
}
```
</ResponseExample>

View File

@@ -19744,6 +19744,384 @@ paths:
code="REWARD10",
customer_id="cus_456",
)
/v1/platform.link_revenuecat:
post:
operationId: linkRevenueCat
description: Generate a RevenueCat OAuth URL for linking a project to an organization.
tags:
- platform
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
organization_slug:
type: string
minLength: 1
env:
enum:
- test
- live
type: string
project_name:
type: string
minLength: 1
maxLength: 255
redirect_url:
type: string
format: uri
required:
- organization_slug
- env
- project_name
- redirect_url
title: LinkRevenueCatParams
examples:
- &a77
organization_slug: acme
env: test
project_name: acme-mobile
redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat
example: *a77
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
oauth_url:
type: string
required:
- oauth_url
title: LinkRevenueCatResponse
examples:
- &a78
oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write
example: *a78
x-speakeasy-name-override: linkRevenueCat
parameters:
- *a5
x-codeSamples:
- lang: typescript
label: Typescript (SDK)
source: |-
import { Autumn } from 'autumn-js'
const autumn = new Autumn()
const result = await autumn.platform.linkRevenueCat({
organizationSlug: "acme",
env: "test",
projectName: "acme-mobile",
redirectUrl: "https://dashboard.useautumn.com/dev?tab=revenuecat",
});
- lang: python
label: Python (SDK)
source: >-
from autumn_sdk import Autumn
autumn = Autumn(secret_key="am_sk_test...")
res = autumn.platform.link_revenue_cat(
organization_slug="acme",
env="test",
project_name="acme-mobile",
redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat",
)
/v1/platform.sync_revenuecat:
post:
operationId: syncRevenueCat
description: Push an organization's plans into RevenueCat as products (creating
or renaming them across the project's apps) and set test-store prices
from each plan's price. Requires the org to have linked RevenueCat via
OAuth.
tags:
- platform
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
organization_slug:
type: string
minLength: 1
env:
enum:
- test
- sandbox
- live
type: string
description: '"test" and "sandbox" both target the sandbox environment'
product_ids:
type: array
items:
type: string
description: Plans to push. Omit to sync every plan in the org/env.
required:
- organization_slug
- env
title: SyncRevenueCatParams
examples:
- &a79
organization_slug: acme
env: test
product_ids:
- pro
- premium
example: *a79
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
plan_id:
type: string
status:
enum:
- synced
- skipped
- error
type: string
store_identifier:
type: string
apps:
type: array
items:
type: object
properties:
app_id:
type: string
app_type:
type: string
product:
enum:
- created
- updated
- exists
type: string
store_push:
enum:
- pushed
- failed
- skipped
type: string
price:
enum:
- set
- skipped
- failed
type: string
message:
type: string
required:
- app_id
- app_type
- product
message:
type: string
required:
- plan_id
- status
required:
- results
title: SyncRevenueCatResponse
examples:
- &a80
results:
- plan_id: pro
status: synced
store_identifier: autumn.sandbox.org_123.pro
apps:
- app_id: app_test
app_type: test_store
product: created
store_push: skipped
price: set
example: *a80
x-speakeasy-name-override: syncRevenueCat
parameters:
- *a5
x-codeSamples:
- lang: typescript
label: Typescript (SDK)
source: |-
import { Autumn } from 'autumn-js'
const autumn = new Autumn()
const result = await autumn.platform.syncRevenueCat({
organizationSlug: "acme",
env: "test",
productIds: [
"pro",
"premium",
],
});
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
autumn = Autumn(secret_key="am_sk_test...")
res = autumn.platform.sync_revenue_cat(
organization_slug="acme",
env="test",
product_ids=[
"pro",
"premium",
],
)
/v1/platform.get_revenuecat_keys:
post:
operationId: getRevenueCatKeys
description: Retrieve a managed organization's RevenueCat public (SDK) API keys,
grouped by app — for the test store, App Store, and Google Play Store.
Use these to configure the RevenueCat SDK in the org's mobile app.
tags:
- platform
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
organization_slug:
type: string
minLength: 1
env:
enum:
- test
- sandbox
- live
type: string
description: '"test" and "sandbox" both target the sandbox environment'
required:
- organization_slug
- env
title: GetRevenueCatKeysParams
examples:
- &a81
organization_slug: acme
env: test
example: *a81
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
apps:
type: array
items:
type: object
properties:
app_id:
type: string
app_type:
type: string
description: RevenueCat store type, e.g. test_store / app_store / play_store
name:
type: string
api_keys:
type: array
items:
type: object
properties:
id:
type: string
key:
type: string
description: The public SDK API key value
environment:
anyOf:
- type: string
- type: "null"
description: e.g. "production" / "sandbox"
app_id:
anyOf:
- type: string
- type: "null"
created_at:
type: number
required:
- id
- key
additionalProperties: {}
required:
- app_id
- app_type
- name
- api_keys
oauth_access_token:
anyOf:
- type: string
- type: "null"
description: Freshly-refreshed RevenueCat OAuth access token for the org (null
for api-key orgs). The refresh token is never exposed —
call this endpoint again for a new access token.
required:
- apps
- oauth_access_token
title: GetRevenueCatKeysResponse
examples:
- &a82
apps:
- app_id: app1a2b3c4d
app_type: test_store
name: Acme (Test Store)
api_keys:
- id: apikey12345
key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ
environment: production
app_id: app1a2b3c4
oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ
example: *a82
x-speakeasy-name-override: getRevenueCatKeys
parameters:
- *a5
x-codeSamples:
- lang: typescript
label: Typescript (SDK)
source: |-
import { Autumn } from 'autumn-js'
const autumn = new Autumn()
const result = await autumn.platform.getRevenueCatKeys({
organizationSlug: "acme",
env: "test",
});
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
autumn = Autumn(secret_key="am_sk_test...")
res = autumn.platform.get_revenue_cat_keys(
organization_slug="acme",
env="test",
)
security:
- secretKey: []
x-speakeasy-globals:

View File

@@ -4,6 +4,54 @@ mode: "center"
description: "Some new things we've shipped at Autumn HQ"
---
<Update label="May 29th 2026">
## Autumn MCP server
Autumn now ships an official **Model Context Protocol (MCP) server**, so AI assistants like Claude Desktop and Cursor can read and act on your Autumn data directly. The server is generated against the public API and respects your existing API key scopes (`customers:read`, `plans:read`, `billing:read`, `billing:write`) — no new permission model to learn.
Install it as a Claude Desktop extension or wire it into Cursor with a single deeplink. Use it to look up customers, inspect plans, preview attaches, and run billing updates from your assistant.
## Org-level usage alerts
Usage alerts can now be configured **once at the org level** and applied across every customer, instead of being attached plan by plan. Set a threshold and channel from settings, and Autumn fires alerts whenever any customer crosses the bar. The [`balances.usage_alert.triggered` webhook](/api-reference/webhooks/balancesUsageAlertTriggered) fires for both per-plan and org-level alerts.
## Vercel Marketplace: invoice mode and resource logs
The Vercel integration now supports **invoice-mode billing** end to end and surfaces **per-resource logs** in the dashboard, so you can debug a Vercel customer's provisioning, auto top-ups, and invoice flow without leaving Autumn. The frontend was rebuilt around the new resource model, with fallbacks for identity edge cases.
See the [Vercel Marketplace guide](/documentation/external-providers/vercel-marketplace).
<AccordionGroup>
<Accordion title="Improvements">
- Official Autumn MCP server for Claude Desktop, Cursor, and other MCP clients — [#1715](https://github.com/useautumn/autumn/pull/1715), [#1740](https://github.com/useautumn/autumn/pull/1740), [#1741](https://github.com/useautumn/autumn/pull/1741)
- Org-level usage alerts with shared thresholds across customers — [#1707](https://github.com/useautumn/autumn/pull/1707)
- Vercel Marketplace invoice mode, resource logs, and refreshed frontend — [#1711](https://github.com/useautumn/autumn/pull/1711)
- Preflight tax-address check before charging customers in taxable regions — [#1699](https://github.com/useautumn/autumn/pull/1699)
- Automatically void uncollectible Stripe invoices instead of leaving them open — [#1732](https://github.com/useautumn/autumn/pull/1732)
- New plan-version filters for [custom-plan migrations](/documentation/customers/custom-plans) — [#1718](https://github.com/useautumn/autumn/pull/1718)
- Faster customer detail loads and smoother large entity lists — [#1712](https://github.com/useautumn/autumn/pull/1712)
- Customer list filter by entity ID — [#1688](https://github.com/useautumn/autumn/pull/1688)
- Refreshed dashboard theme and appearance controls — [#1679](https://github.com/useautumn/autumn/pull/1679)
- Optimised product counts in the customer table — [#1690](https://github.com/useautumn/autumn/pull/1690)
- Mobile fixes for the customer list view — [#1689](https://github.com/useautumn/autumn/pull/1689)
</Accordion>
<Accordion title="Bug fixes">
- Stripe checkout no longer drops carry-over balances on plan changes — [#1708](https://github.com/useautumn/autumn/pull/1708)
- Trial grants are now excluded from reward eligibility — [#1730](https://github.com/useautumn/autumn/pull/1730)
- RevenueCat sync no longer fails with "entities not found" — [#1729](https://github.com/useautumn/autumn/pull/1729)
- Tax IDs now render correctly in the attach preview — [#1728](https://github.com/useautumn/autumn/pull/1728)
- Invalid email addresses are now rejected with a clearer error — [#1722](https://github.com/useautumn/autumn/pull/1722)
- Sandbox usage alerts now respect their configured threshold — [#1706](https://github.com/useautumn/autumn/pull/1706)
- Proration toggle now shows even when the customer has a past trial — [#1704](https://github.com/useautumn/autumn/pull/1704)
- One-off prepaid items upgrade correctly without double carry-over — [#1676](https://github.com/useautumn/autumn/pull/1676), [#1695](https://github.com/useautumn/autumn/pull/1695)
- Subscription metadata is preserved through checkout — [#1719](https://github.com/useautumn/autumn/pull/1719)
- Price dropdown no longer loses its selected value when reopened — [#1720](https://github.com/useautumn/autumn/pull/1720)
- Vercel auto top-ups no longer fail to process the resulting invoice — multiple fixes ([#1725](https://github.com/useautumn/autumn/pull/1725))
</Accordion>
</AccordionGroup>
</Update>
<Update label="May 28th 2026">
## Batch track and async track

View File

@@ -1,6 +1,6 @@
# Autumn Leaf
Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/http.ts`).
Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/mcpRouter.ts`).
Local Slack testing uses a normal Slack app in a development workspace. Keep the app undistributed while testing.
@@ -15,12 +15,20 @@ bun run chat:tunnel
2. Start Autumn with the same public URL:
```sh
CHAT_URL=https://c.autumn.ngrok.app SLACK_BOT_URL=https://c.autumn.ngrok.app bun d
NGROK_URL=https://c.autumn.ngrok.app bun d
```
`bun d` derives `CHAT_URL`, `SLACK_BOT_URL`, and `SLACK_REDIRECT_URI` from
`NGROK_URL`, so the Slack OAuth redirect becomes
`https://c.autumn.ngrok.app/slack/oauth/callback`. This exact URL must be in
the Slack app's OAuth redirect URLs.
The chat SDK stores its own subscriptions, locks, and queues in Postgres. By
default it uses the same `DATABASE_URL` host with the database name changed to
`chat`; set `CHAT_STATE_DATABASE_URL` to override this.
`chat`; set `CHAT_STATE_DATABASE_URL` to override this. `bun dev:services up`
creates the local `chat` database. The `@chat-adapter/state-pg` package creates
its state tables automatically on connect, so there is no separate migration
command for the chat state database.
3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`.

View File

@@ -10,6 +10,8 @@
"ts": "tsc --noEmit"
},
"dependencies": {
"@autumn/auth": "workspace:*",
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",

View File

@@ -1,13 +1,12 @@
import type { AutumnLogger } from "@autumn/logging";
import { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import {
createAutumnMcpClient,
getAutumnMcpTools,
} from "./mcp.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { env as chatEnv } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { ChatContextMessage } from "../types.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
const docs = [
"autumn://docs/tool-composition",
@@ -37,15 +36,25 @@ const recentMessageContext = (messages: ChatContextMessage[] = []) =>
}));
export const selectChatEnv = async ({
logger = rootLogger,
message,
recentMessages,
select,
}: {
logger?: AutumnLogger;
message: string;
recentMessages?: ChatContextMessage[];
select?: () => Promise<unknown> | unknown;
}) => {
if (select) return envSelectionSchema.parse(await select()).env;
if (select) {
const env = envSelectionSchema.parse(await select()).env;
logger.debug("Selected chat environment from override", {
event: "leaf.chat_env_selected",
context: { env },
data: { source: "override" },
});
return env;
}
const agent = new Agent({
id: "autumn-chat-env",
@@ -61,9 +70,12 @@ export const selectChatEnv = async ({
instructions:
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
},
context: [
...recentMessageContext(recentMessages),
],
context: [...recentMessageContext(recentMessages)],
});
logger.debug("Selected chat environment from model", {
event: "leaf.chat_env_selected",
context: { env: output.object.env },
data: { source: "model" },
});
return output.object.env;
};
@@ -84,8 +96,9 @@ const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
};
export const runChatAgent = async ({
apiKey,
token,
env,
logger = rootLogger,
message,
threadId,
resourceId,
@@ -93,8 +106,9 @@ export const runChatAgent = async ({
provider,
recentMessages,
}: {
apiKey: string;
token: string;
env: AppEnv;
logger?: AutumnLogger;
message: string;
onAction?: (message: string) => Promise<void> | void;
threadId: string;
@@ -102,7 +116,11 @@ export const runChatAgent = async ({
provider: string;
recentMessages?: ChatContextMessage[];
}) => {
const mcp = createAutumnMcpClient(apiKey, { requireApproval: true });
const mcp = createAutumnMcpClient({
token,
appEnv: env,
options: { requireApproval: true },
});
let previewApproval:
| {
toolName: string;
@@ -111,13 +129,28 @@ export const runChatAgent = async ({
}
| undefined;
try {
logger.info("Starting chat agent", {
event: "leaf.agent_started",
context: {
env,
org_id: resourceId,
provider,
},
data: {
thread_id: threadId,
},
});
await onAction?.("Loading Autumn tools and guidance");
const [tools, docsText] = await Promise.all([
getAutumnMcpTools(mcp, {
applyApprovalPolicy: true,
onToolCall: onAction,
onPreview: (approval) => {
previewApproval = approval;
getAutumnMcpTools({
mcp,
options: {
applyApprovalPolicy: true,
logger,
onToolCall: onAction,
onPreview: (approval) => {
previewApproval = approval;
},
},
}),
readDocs(mcp),
@@ -150,8 +183,19 @@ export const runChatAgent = async ({
...recentMessageContext(recentMessages),
],
});
logger.info("Completed chat agent", {
event: "leaf.agent_completed",
context: { env },
data: {
finish_reason: output.finishReason,
run_id: output.runId,
},
});
return { ...output, env, previewApproval };
} finally {
await mcp.disconnect();
logger.debug("Disconnected Autumn MCP client", {
event: "leaf.mcp_client_disconnected",
});
}
};

View File

@@ -1,6 +1,10 @@
import { isSecretKeyPrefix } from "@autumn/auth";
import type { AutumnLogger } from "@autumn/logging";
import type { AppEnv } from "@autumn/shared";
import { MCPClient } from "@mastra/mcp";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
import { env } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
type AutumnTool = {
execute?: (
@@ -14,6 +18,7 @@ type AutumnTool = {
type ToolOptions = {
applyApprovalPolicy?: boolean;
logger?: AutumnLogger;
onToolCall?: (message: string) => Promise<void> | void;
onPreview?: (approval: {
toolName: string;
@@ -23,29 +28,41 @@ type ToolOptions = {
};
const withAuthFetch =
(apiKey: string) => (input: RequestInfo | URL, init?: RequestInit) => {
({ appEnv, token }: { appEnv: AppEnv; token: string }) =>
(input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("secret-key", apiKey);
headers.set("Authorization", `Bearer ${token}`);
headers.set("x-autumn-environment", appEnv);
if (isSecretKeyPrefix({ token })) {
headers.set("secret-key", token);
}
return fetch(input, { ...init, headers });
};
export const createAutumnMcpClient = (
apiKey: string,
options: { requireApproval?: boolean } = {},
) => {
const fetchWithAuth = withAuthFetch(apiKey);
export const createAutumnMcpClient = ({
token,
appEnv,
options = {},
}: {
token: string;
appEnv: AppEnv;
options?: { requireApproval?: boolean };
}) => {
const fetchWithAuth = withAuthFetch({ appEnv, token });
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"x-autumn-environment": appEnv,
};
if (isSecretKeyPrefix({ token })) {
headers["secret-key"] = token;
}
return new MCPClient({
id: `autumn-${apiKey.slice(0, 14)}`,
id: `autumn-${token.slice(0, 14)}`,
servers: {
autumn: {
url: new URL("/mcp", env.MCP_SERVER_URL),
requestInit: {
headers: {
Authorization: `Bearer ${apiKey}`,
"secret-key": apiKey,
},
},
requestInit: { headers },
eventSourceInit: { fetch: fetchWithAuth },
fetch: fetchWithAuth,
requireToolApproval: options.requireApproval
@@ -56,7 +73,13 @@ export const createAutumnMcpClient = (
});
};
const formatToolAction = (toolName: string, args: Record<string, unknown>) => {
const formatToolAction = ({
toolName,
args,
}: {
toolName: string;
args: Record<string, unknown>;
}) => {
const request =
args.request && typeof args.request === "object"
? (args.request as Record<string, unknown>)
@@ -73,16 +96,32 @@ const formatToolAction = (toolName: string, args: Record<string, unknown>) => {
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
};
export const getAutumnMcpTools = async (
mcp: MCPClient,
options: ToolOptions = {},
) => {
export const getAutumnMcpTools = async ({
mcp,
options = {},
}: {
mcp: MCPClient;
options?: ToolOptions;
}) => {
const logger = options.logger ?? rootLogger;
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
if (Object.keys(errors).length) {
throw new Error(`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`);
logger.error("Could not load Autumn MCP tools", {
event: "leaf.mcp_tools_load_failed",
data: { errors },
});
throw new Error(
`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`,
);
}
const tools = (toolsets.autumn ?? {}) as Record<string, AutumnTool>;
logger.info("Loaded Autumn MCP tools", {
event: "leaf.mcp_tools_loaded",
data: {
tool_count: Object.keys(tools).length,
},
});
for (const [toolName, tool] of Object.entries(tools)) {
if (options.applyApprovalPolicy) {
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
@@ -91,10 +130,21 @@ export const getAutumnMcpTools = async (
if (tool.execute && (options.onToolCall || options.onPreview)) {
const execute = tool.execute.bind(tool);
tool.execute = async (args, ...rest) => {
await options.onToolCall?.(formatToolAction(toolName, args));
logger.info("Calling Autumn MCP tool", {
event: "leaf.mcp_tool_called",
tool: toolName,
});
await options.onToolCall?.(formatToolAction({ toolName, args }));
const result = await execute(args, ...rest);
const writeTool = getWriteToolForPreview(toolName);
if (writeTool) {
logger.info("Captured Autumn MCP preview", {
event: "leaf.mcp_preview_captured",
tool: writeTool,
data: {
preview_tool: toolName,
},
});
options.onPreview?.({
toolName: writeTool,
toolArgs: args,
@@ -109,17 +159,19 @@ export const getAutumnMcpTools = async (
};
export const executeAutumnMcpTool = async ({
apiKey,
env,
token,
toolName,
args,
}: {
apiKey: string;
env: AppEnv;
token: string;
toolName: string;
args: Record<string, unknown>;
}) => {
const mcp = createAutumnMcpClient(apiKey);
const mcp = createAutumnMcpClient({ token, appEnv: env });
try {
const tools = await getAutumnMcpTools(mcp);
const tools = await getAutumnMcpTools({ mcp });
const tool = tools[toolName.replace(/^autumn_/, "")];
if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`);
return await tool.execute(args);

View File

@@ -1,6 +1,7 @@
import { runChatAgent, selectChatEnv } from "./agent.js";
import { getInstallationKey } from "../providers/slack/installations.js";
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
import { logger as rootLogger } from "../lib/logger.js";
import { agentOutputSchema, type BotMessage } from "../types.js";
import { runChatAgent, selectChatEnv } from "./agent.js";
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
new Promise<T>((resolve, reject) => {
@@ -13,6 +14,7 @@ const withTimeout = <T>(promise: Promise<T>, ms: number) =>
export const runMessage = async ({
installation,
logger = rootLogger,
onAction,
recentMessages,
text,
@@ -23,11 +25,25 @@ export const runMessage = async ({
const env = await selectChatEnv({
message: text,
recentMessages,
logger,
});
logger.info("Selected chat environment", {
event: "leaf.chat_env_selected",
context: {
env,
org_id: installation.org_id,
provider: installation.provider,
},
});
const token = await getInstallationOAuthAccessToken({
installation,
env,
});
return agentOutputSchema.parse(
await runChatAgent({
apiKey: getInstallationKey(installation, env),
token,
env,
logger,
message: text,
onAction,
threadId,

View File

@@ -1,5 +1,16 @@
import type { AutumnLogger } from "@autumn/logging";
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
import type { ActionEvent } from "chat";
import { toolLabel } from "../agent/toolPolicy.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { AgentOutput } from "../types.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { approvalRequestFromOutput } from "./request.js";
import {
approveAndRun,
cancelApproval,
@@ -7,21 +18,13 @@ import {
getApproval,
isErrorResult,
} from "./store.js";
import { approvalRequestFromOutput } from "./request.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { toolLabel } from "../agent/toolPolicy.js";
import type { AgentOutput } from "../types.js";
export const postApprovalRequest = async ({
channelId,
installation,
loading,
logAction,
logger = rootLogger,
output,
providerUserId,
target,
@@ -30,6 +33,7 @@ export const postApprovalRequest = async ({
installation: ChatInstallation;
loading: LoadingState;
logAction: (message: string) => Promise<void> | void;
logger?: AutumnLogger;
output: AgentOutput;
providerUserId: string;
target: ReplyTarget;
@@ -47,6 +51,15 @@ export const postApprovalRequest = async ({
});
await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`);
logger.info("Created approval request", {
event: "leaf.approval_created",
context: {
env: approval.env,
org_id: installation.org_id,
},
approval_id: approvalId,
tool: approval.toolName,
});
await finishLoading(target, loading, "Preview ready.");
await target.post(
approvalCard({
@@ -92,10 +105,22 @@ export const handleApprovalAction = async (event: ActionEvent) => {
if (!event.value) return;
try {
rootLogger.info("Received approval action", {
event: "leaf.approval_action_received",
approval_id: event.value,
action: event.actionId,
data: {
provider_user_id: event.user.userId,
},
});
const details = await approvalDetails(event.value);
if (event.actionId === "cancel_billing_action") {
const cancelled = await cancelApproval(event.value, event.user.userId);
if (!cancelled) {
rootLogger.warn("Approval cancellation ignored", {
event: "leaf.approval_cancel_ignored",
approval_id: event.value,
});
const current = await getApproval(event.value);
await editActionMessage(
event,
@@ -110,6 +135,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
event,
approvalStatusCard({ status: "cancelled", ...details }),
);
rootLogger.info("Cancelled approval", {
event: "leaf.approval_cancelled",
approval_id: event.value,
tool: details.toolName,
});
return;
}
@@ -118,6 +148,12 @@ export const handleApprovalAction = async (event: ActionEvent) => {
approvalStatusCard({ status: "running", ...details }),
);
const result = await approveAndRun(event.value, event.user.userId);
rootLogger.info("Completed approval action", {
event: "leaf.approval_completed",
approval_id: event.value,
status: isErrorResult(result) ? "failed" : "approved",
tool: details.toolName,
});
await editActionMessage(
event,
approvalStatusCard({
@@ -127,7 +163,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
}),
);
} catch (error) {
console.error("[chat] Approval action failed", error);
rootLogger.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(
event,

View File

@@ -1,15 +1,15 @@
import crypto from "node:crypto";
import {
AppEnv,
type AppEnv,
type ChatProvider,
chatApprovals,
chatInstallations,
} from "@autumn/shared";
import { addMinutes, isPast } from "date-fns";
import { and, eq, gt } from "drizzle-orm";
import { decrypt } from "../lib/crypto.js";
import { db } from "../lib/db.js";
import { executeAutumnMcpTool } from "../agent/mcp.js";
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
import { db } from "../lib/db.js";
export const normalizeToolName = (toolName: string) =>
toolName.replace(/^autumn_/, "");
@@ -120,14 +120,14 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
});
if (!installation) throw new Error("Chat installation not found");
const encryptedKey =
claimed.env === AppEnv.Live
? installation.live_api_key
: installation.sandbox_api_key;
if (!encryptedKey) throw new Error(`Missing ${claimed.env} API key`);
const token = await getInstallationOAuthAccessToken({
installation,
env: claimed.env,
});
const result = await executeAutumnMcpTool({
apiKey: decrypt(encryptedKey),
token,
env: claimed.env,
toolName: claimed.tool_name,
args: claimed.tool_args,
});

View File

@@ -2,12 +2,19 @@ import { createSlackAdapter } from "@chat-adapter/slack";
import { createPostgresState } from "@chat-adapter/state-pg";
import type { Message, Thread } from "chat";
import { Chat } from "chat";
import { runMessage } from "./agent/messages.js";
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { decrypt } from "./lib/crypto.js";
import { env } from "./lib/env.js";
import {
addLeafContext,
createLeafSessionContext,
logger as rootLogger,
} from "./lib/logger.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { findInstallation } from "./providers/slack/installations.js";
import { runMessage } from "./agent/messages.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
import {
createActionLogger,
finishLoading,
@@ -15,8 +22,6 @@ import {
type ReplyTarget,
startLoading,
} from "./ui/progress.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
export const chatAdapterNames = ["slack"];
@@ -66,15 +71,46 @@ const runAndReply = async ({
threadId: string;
}) => {
let loading: LoadingState = null;
let logger = rootLogger;
try {
const workspaceId = getSlackWorkspaceId(raw);
const session = createLeafSessionContext({
channelId,
provider: "slack",
providerUserId,
threadId,
workspaceId,
});
logger = addLeafContext(rootLogger, {
...session.context,
agent_run_id: session.agentRunId,
});
logger.info("Received Slack message", {
event: "leaf.slack_message_received",
data: {
text_length: text.length,
},
});
const installation = await findInstallation("slack", workspaceId);
if (!installation || !text.trim()) return;
if (!installation) {
logger.warn("Slack installation not found", {
event: "leaf.slack_installation_missing",
});
return;
}
if (!text.trim()) {
logger.info("Skipping empty Slack message", {
event: "leaf.slack_message_skipped",
data: { reason: "empty" },
});
return;
}
loading = await startLoading(target);
const logAction = createActionLogger(loading);
const output = await runMessage({
installation,
logger,
onAction: logAction,
recentMessages,
text,
@@ -86,6 +122,7 @@ const runAndReply = async ({
installation,
loading,
logAction,
logger,
output,
providerUserId,
target,
@@ -94,8 +131,16 @@ const runAndReply = async ({
await finishLoading(target, loading, "Done.");
await target.post({ markdown: output.text || "Done." });
logger.info("Posted Slack response", {
event: "leaf.slack_response_posted",
data: {
has_text: Boolean(output.text),
},
});
} catch (error) {
console.error("[chat] Message failed", error);
logger.error("[chat] Message failed", error, {
event: "leaf.slack_message_failed",
});
await finishLoading(target, loading, "Request failed.");
await target.post({
markdown: "I could not complete that request. Please try again.",

View File

@@ -0,0 +1,83 @@
import type { AppEnv, ChatInstallation } from "@autumn/shared";
import { decrypt, encrypt } from "../../../lib/crypto.js";
import { db } from "../../../lib/db.js";
import { env as leafEnv } from "../../../lib/env.js";
import {
getChatOAuthCredentialByInstallationEnv,
updateChatOAuthCredentialTokens,
} from "../repos/chatOAuthCredentialsRepo.js";
import {
parseOAuthScopeString,
parseOAuthTokenResponse,
} from "../utils/oauthTokenResponse.js";
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const getTokenEndpoint = () =>
new URL("/api/auth/oauth2/token", leafEnv.BETTER_AUTH_URL).href;
const getDefaultExpiresAt = () => Date.now() + 60 * 60 * 1000;
export const getInstallationOAuthAccessToken = async ({
installation,
env,
}: {
installation: ChatInstallation;
env: AppEnv;
}) => {
const credential = await getChatOAuthCredentialByInstallationEnv({
db,
chatInstallationId: installation.id,
env,
});
if (!credential) {
throw new Error(
`Missing ${env} Autumn OAuth credentials for Slack install`,
);
}
if (credential.access_token_expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
return decrypt(credential.access_token);
}
const refreshToken = decrypt(credential.refresh_token);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: credential.oauth_client_id,
});
const response = await fetch(getTokenEndpoint(), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
throw new Error(
`Could not refresh ${env} Autumn OAuth token for Slack install`,
);
}
const parsed = parseOAuthTokenResponse({ body: await response.json() });
const accessTokenExpiresAt = parsed.expires_in
? Date.now() + parsed.expires_in * 1000
: getDefaultExpiresAt();
const nextRefreshToken = parsed.refresh_token ?? refreshToken;
const scopes = parseOAuthScopeString({ scope: parsed.scope });
await updateChatOAuthCredentialTokens({
db,
id: credential.id,
accessToken: encrypt(parsed.access_token),
refreshToken: encrypt(nextRefreshToken),
accessTokenExpiresAt,
scopes: scopes.length > 0 ? scopes : credential.scopes,
updatedAt: Date.now(),
});
return parsed.access_token;
};

View File

@@ -0,0 +1,218 @@
import crypto from "node:crypto";
import { prefixOAuthToken } from "@autumn/auth";
import {
AppEnv,
type ChatInstallation,
chatOAuthCredentials,
oauthAccessToken,
oauthClient,
oauthConsent,
oauthRefreshToken,
} from "@autumn/shared";
import { ALL_SCOPES } from "@autumn/shared/utils/scopeDefinitions";
import { and, eq } from "drizzle-orm";
import { encrypt } from "../../../lib/crypto.js";
import type { db } from "../../../lib/db.js";
import { AUTUMN_SLACK_OAUTH_CLIENT_ID } from "./upsertInstallationOAuthCredential.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
const REFRESH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const tokenHash = ({ token }: { token: string }) => {
const hash = crypto.createHash("sha256").update(token).digest();
return hash
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
const generateToken = () => crypto.randomBytes(48).toString("base64url");
const ensureSlackMcpOAuthClient = async ({ tx }: { tx: ChatTransaction }) => {
const now = new Date();
await tx
.insert(oauthClient)
.values({
id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
name: "Slack",
redirectUris: ["slack://autumn-chat"],
scopes: [...ALL_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata: {
kind: "mcp_client",
mcpClientType: "slack",
},
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: oauthClient.clientId,
set: {
name: "Slack",
scopes: [...ALL_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata: {
kind: "mcp_client",
mcpClientType: "slack",
},
updatedAt: now,
},
});
};
const upsertOAuthConsent = async ({
tx,
env,
orgId,
userId,
}: {
tx: ChatTransaction;
env: AppEnv;
orgId: string;
userId: string;
}) => {
const now = new Date();
const [existingConsent] = await tx
.select({ id: oauthConsent.id })
.from(oauthConsent)
.where(
and(
eq(oauthConsent.clientId, AUTUMN_SLACK_OAUTH_CLIENT_ID),
eq(oauthConsent.userId, userId),
eq(oauthConsent.referenceId, orgId),
eq(oauthConsent.env, env),
),
)
.limit(1);
if (existingConsent) {
await tx
.update(oauthConsent)
.set({
scopes: [...ALL_SCOPES],
updatedAt: now,
})
.where(eq(oauthConsent.id, existingConsent.id));
return existingConsent.id;
}
const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`;
await tx.insert(oauthConsent).values({
id: consentId,
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: [...ALL_SCOPES],
env,
redirectUri: "slack://autumn-chat",
createdAt: now,
updatedAt: now,
});
return consentId;
};
const createCredentialForEnv = async ({
tx,
installation,
env,
userId,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
env: AppEnv;
userId: string;
}) => {
const now = Date.now();
const nowDate = new Date(now);
const rawAccessToken = generateToken();
const rawRefreshToken = generateToken();
const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS;
const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS;
const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`;
const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`;
const consentId = await upsertOAuthConsent({
tx,
env,
orgId: installation.org_id,
userId,
});
await tx.insert(oauthRefreshToken).values({
id: refreshTokenId,
token: tokenHash({ token: rawRefreshToken }),
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
userId,
referenceId: installation.org_id,
expiresAt: new Date(refreshTokenExpiresAt),
createdAt: nowDate,
authTime: nowDate,
scopes: [...ALL_SCOPES],
});
await tx.insert(oauthAccessToken).values({
id: accessTokenId,
token: tokenHash({ token: rawAccessToken }),
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
userId,
referenceId: installation.org_id,
refreshId: refreshTokenId,
expiresAt: new Date(accessTokenExpiresAt),
createdAt: nowDate,
scopes: [...ALL_SCOPES],
});
await tx.insert(chatOAuthCredentials).values({
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: AUTUMN_SLACK_OAUTH_CLIENT_ID,
oauth_consent_id: consentId,
access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
refresh_token: encrypt(rawRefreshToken),
access_token_expires_at: accessTokenExpiresAt,
scopes: [...ALL_SCOPES],
created_at: now,
updated_at: now,
});
};
export const replaceInstallationOAuthCredentials = async ({
tx,
installation,
userId,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
userId: string;
}) => {
if (!userId) {
throw new Error("Missing user id for Slack MCP OAuth credentials");
}
await ensureSlackMcpOAuthClient({ tx });
await createCredentialForEnv({
tx,
installation,
env: AppEnv.Sandbox,
userId,
});
await createCredentialForEnv({
tx,
installation,
env: AppEnv.Live,
userId,
});
};

View File

@@ -0,0 +1,47 @@
import crypto from "node:crypto";
import type { AppEnv, ChatInstallation } from "@autumn/shared";
import { encrypt } from "../../../lib/crypto.js";
import { db } from "../../../lib/db.js";
import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js";
export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack";
export const upsertInstallationOAuthCredential = async ({
installation,
env,
accessToken,
refreshToken,
accessTokenExpiresAt,
scopes,
oauthClientId = AUTUMN_SLACK_OAUTH_CLIENT_ID,
oauthConsentId,
}: {
installation: ChatInstallation;
env: AppEnv;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
scopes: string[];
oauthClientId?: string;
oauthConsentId?: string | null;
}) => {
const now = Date.now();
return upsertChatOAuthCredential({
db,
credential: {
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: oauthClientId,
oauth_consent_id: oauthConsentId ?? null,
access_token: encrypt(accessToken),
refresh_token: encrypt(refreshToken),
access_token_expires_at: accessTokenExpiresAt,
scopes,
created_at: now,
updated_at: now,
},
});
};

View File

@@ -0,0 +1,89 @@
import {
type AppEnv,
type ChatOAuthCredential,
chatOAuthCredentials,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { ChatDb } from "../../../lib/db.js";
export type ChatOAuthCredentialInsert =
typeof chatOAuthCredentials.$inferInsert;
export const getChatOAuthCredentialByInstallationEnv = async ({
db,
chatInstallationId,
env,
}: {
db: ChatDb;
chatInstallationId: string;
env: AppEnv;
}) =>
db.query.chatOAuthCredentials.findFirst({
where: and(
eq(chatOAuthCredentials.chat_installation_id, chatInstallationId),
eq(chatOAuthCredentials.env, env),
),
});
export const upsertChatOAuthCredential = async ({
db,
credential,
}: {
db: ChatDb;
credential: ChatOAuthCredentialInsert;
}) => {
const [row] = await db
.insert(chatOAuthCredentials)
.values(credential)
.onConflictDoUpdate({
target: [
chatOAuthCredentials.chat_installation_id,
chatOAuthCredentials.env,
],
set: {
org_id: credential.org_id,
oauth_client_id: credential.oauth_client_id,
oauth_consent_id: credential.oauth_consent_id,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
access_token_expires_at: credential.access_token_expires_at,
scopes: credential.scopes,
updated_at: credential.updated_at,
},
})
.returning();
return row as ChatOAuthCredential;
};
export const updateChatOAuthCredentialTokens = async ({
db,
id,
accessToken,
refreshToken,
accessTokenExpiresAt,
scopes,
updatedAt,
}: {
db: ChatDb;
id: string;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
scopes: string[];
updatedAt: number;
}) => {
const [row] = await db
.update(chatOAuthCredentials)
.set({
access_token: accessToken,
refresh_token: refreshToken,
access_token_expires_at: accessTokenExpiresAt,
scopes,
updated_at: updatedAt,
})
.where(eq(chatOAuthCredentials.id, id))
.returning();
return row as ChatOAuthCredential | undefined;
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
const oauthTokenPayloadSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1).optional(),
expires_in: z.number().optional(),
scope: z.string().optional(),
});
const oauthTokenResponseSchema = z.preprocess((value) => {
if (value && typeof value === "object" && "response" in value) {
return (value as { response?: unknown }).response;
}
return value;
}, oauthTokenPayloadSchema);
export const parseOAuthTokenResponse = ({ body }: { body: unknown }) =>
oauthTokenResponseSchema.parse(body);
export const parseOAuthScopeString = ({ scope }: { scope?: string }) =>
scope?.split(/\s+/).filter(Boolean) ?? [];

View File

@@ -0,0 +1,60 @@
import {
type AutumnLogger,
createAppLogger,
createSessionId,
createTraceId,
} from "@autumn/logging";
export const logger = createAppLogger({
service: "leaf",
dataset: process.env.LEAF_LOG_DATASET ?? "leaf",
preset: "default",
});
export const createLeafSessionContext = ({
channelId,
provider,
providerUserId,
threadId,
workspaceId,
}: {
channelId: string;
provider: string;
providerUserId: string;
threadId: string;
workspaceId: string;
}) => {
const traceId = createTraceId();
const sessionId = createSessionId({
parts: {
channelId,
provider,
threadId,
workspaceId,
},
});
return {
agentRunId: createTraceId(),
sessionId,
traceId,
context: {
provider,
provider_user_id: providerUserId,
session_id: sessionId,
trace_id: traceId,
slack_channel_id: channelId,
slack_thread_id: threadId,
slack_workspace_id: workspaceId,
},
};
};
export const addLeafContext = (
baseLogger: AutumnLogger,
context: Record<string, unknown>,
): AutumnLogger =>
baseLogger.child({
context: {
context,
},
});

View File

@@ -1,10 +1,10 @@
import { createConsoleLogger } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { chatAdapterNames } from "./bot.js";
import { env } from "./lib/env.js";
import { registerMcpRoutes } from "./mcp/http.js";
import { logger } from "./lib/logger.js";
import { createMcpRouter } from "./mcp/mcpRouter.js";
import { slackRoutes } from "./providers/slack/routes.js";
const app = new Hono<{ Bindings: HttpBindings }>();
@@ -18,12 +18,16 @@ app.use("*", async (c, next) => {
app.get("/health", (c) => c.json({ ok: true }));
registerMcpRoutes(app, {
"oauth-enabled": true,
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
"server-url": env.BETTER_AUTH_URL,
logger: createConsoleLogger("info"),
});
app.route(
"",
createMcpRouter({
"oauth-enabled": true,
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
"server-url": env.BETTER_AUTH_URL,
logger,
resourceUrl: new URL("/mcp", env.MCP_SERVER_URL).href,
}),
);
app.route("/slack", slackRoutes);
@@ -34,9 +38,12 @@ serve(
port: env.PORT,
},
({ address, port }) => {
console.log("Chat listening", {
host: `${address}:${port}`,
adapters: chatAdapterNames,
logger.info("Chat listening", {
event: "leaf.server_started",
data: {
host: `${address}:${port}`,
adapters: chatAdapterNames,
},
});
},
);

View File

@@ -0,0 +1,34 @@
import {
getOAuthIssuerUrl,
} from "@autumn/auth/oauth";
import {
DEFAULT_AUTUMN_API_URL,
MCP_OAUTH_SCOPES,
} from "@autumn/mcp";
export class OAuthHttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly error = "invalid_token",
readonly wwwAuthenticate?: string,
) {
super(message);
}
}
export const getProtectedResourceMetadata = ({
resourceUrl,
serverURL,
}: {
resourceUrl: string;
serverURL?: string;
}) => ({
resource: resourceUrl,
authorization_servers: [
getOAuthIssuerUrl({ baseUrl: serverURL ?? DEFAULT_AUTUMN_API_URL }),
],
scopes_supported: [...MCP_OAUTH_SCOPES],
bearer_methods_supported: ["header"],
resource_name: "Autumn MCP",
});

View File

@@ -0,0 +1,184 @@
import { createHash } from "node:crypto";
import { getBearerToken, isOAuthToken, isSecretKeyPrefix } from "@autumn/auth";
import {
getProtectedResourceMetadataUrl,
getWwwAuthenticateHeader,
} from "@autumn/auth/oauth";
import {
type AutumnMcpAuth,
DEFAULT_API_VERSION,
environmentSchema,
MCP_OAUTH_SCOPES,
type MCPServerFlags,
type OAuthEnvironment,
} from "@autumn/mcp";
import * as z from "zod/v4";
import { OAuthHttpError } from "./protectedResourceMetadata.js";
type AuthLogger = {
warning: (message: string, data?: Record<string, unknown>) => void;
};
export interface MCPOAuthFlags extends MCPServerFlags {
readonly "oauth-enabled"?: boolean | undefined;
readonly "oauth-environment"?: OAuthEnvironment | undefined;
}
const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION);
const secretKeySchema = z.string().min(1).optional();
const failOpenSchema = z
.union([
z.boolean(),
z.enum(["true", "false"]).transform((v) => v === "true"),
])
.default(true);
const parseRequestOption = <T>({
value,
schema,
message,
}: {
value: unknown;
schema: z.ZodType<T>;
message: string;
}): T => {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(400, message, "invalid_request");
};
const getEnvironment = ({
headers,
flags,
}: {
headers: Headers;
flags: MCPOAuthFlags;
}): OAuthEnvironment =>
parseRequestOption({
value:
headers.get("x-autumn-environment") ??
flags["oauth-environment"] ??
"sandbox",
schema: environmentSchema,
message: "Invalid x-autumn-environment",
});
const getStaticApiKey = ({
headers,
flags,
}: {
headers: Headers;
flags: MCPOAuthFlags;
}): string | undefined => {
const secretKey = headers.get("secret-key");
if (secretKey && isSecretKeyPrefix({ token: secretKey })) return secretKey;
const bearer = getBearerToken({ headers });
if (bearer && isSecretKeyPrefix({ token: bearer })) return bearer;
const fallbackSecretKey = flags["secret-key"];
if (
!flags["oauth-enabled"] &&
fallbackSecretKey &&
isSecretKeyPrefix({ token: fallbackSecretKey })
) {
return fallbackSecretKey;
}
return undefined;
};
const principalFromSecret = ({
kind,
value,
}: {
kind: string;
value: string;
}) => {
const digest = createHash("sha256").update(value).digest("hex").slice(0, 32);
return `${kind}:${digest}`;
};
export const buildAuthForRequest = async ({
headers,
flags,
logger,
resourceUrl,
}: {
headers: Headers;
flags: MCPOAuthFlags;
logger: AuthLogger;
resourceUrl: string;
}): Promise<AutumnMcpAuth> => {
const env = getEnvironment({ headers, flags });
const xApiVersion = parseRequestOption({
value: headers.get("x-api-version") ?? flags["x-api-version"],
schema: xApiVersionSchema,
message: "Invalid x-api-version",
});
const failOpen = parseRequestOption({
value: headers.get("fail-open") ?? flags["fail-open"],
schema: failOpenSchema,
message: "Invalid fail-open",
});
const apiKey = parseRequestOption({
value: getStaticApiKey({ headers, flags }),
schema: secretKeySchema,
message: "Invalid secret-key",
});
if (apiKey) {
return {
apiKey,
authMethod: "secret-key",
env,
resource: resourceUrl,
principalId: principalFromSecret({ kind: "secret-key", value: apiKey }),
scopes: [...MCP_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
const bearer = getBearerToken({ headers });
if (bearer && isOAuthToken({ token: bearer })) {
return {
apiKey: bearer,
authMethod: "oauth",
env,
resource: resourceUrl,
principalId: "oauth:unverified",
scopes: [...MCP_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
if (bearer) {
throw new OAuthHttpError(
401,
"Invalid OAuth token prefix",
"invalid_token",
);
}
if (flags["oauth-enabled"]) {
throw new OAuthHttpError(
401,
"Missing Autumn API key bearer token",
"invalid_token",
getWwwAuthenticateHeader({
resourceMetadataUrl: getProtectedResourceMetadataUrl({
resourceUrl,
}),
error: "invalid_token",
}),
);
}
logger.warning("Missing secret-key for MCP request");
throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
};

View File

@@ -0,0 +1,3 @@
export const MCP_PATH = "/mcp" as const;
export const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource/mcp";

View File

@@ -0,0 +1,71 @@
import { randomUUID } from "node:crypto";
import {
type createAutumnOperationsMCPServer,
} from "@autumn/mcp";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import {
buildAuthForRequest,
} from "../auth/resolveRequestAuth.js";
import { OAuthHttpError } from "../auth/protectedResourceMetadata.js";
import type { LeafMcpContext, McpRouteOptions } from "../types.js";
type McpServer = ReturnType<typeof createAutumnOperationsMCPServer>;
type McpAuth = Awaited<ReturnType<typeof buildAuthForRequest>>;
const setIncomingAuth = ({
c,
auth,
}: {
c: LeafMcpContext;
auth: McpAuth;
}) => {
(c.env.incoming as typeof c.env.incoming & { auth?: McpAuth }).auth = auth;
};
const oauthErrorResponse = (c: LeafMcpContext, error: OAuthHttpError) => {
if (error.wwwAuthenticate) {
c.header("WWW-Authenticate", error.wwwAuthenticate);
}
return c.json(
{ error: error.error, error_description: error.message },
{ status: error.status as 400 | 401 | 403 },
);
};
export const createHandleMcp =
({
options,
path,
server,
}: {
options: McpRouteOptions;
path: string;
server: McpServer;
}) =>
async (c: LeafMcpContext) => {
let auth: McpAuth;
try {
auth = await buildAuthForRequest({
headers: c.req.raw.headers,
flags: options,
logger: options.logger,
resourceUrl: options.resourceUrl,
});
} catch (error) {
if (error instanceof OAuthHttpError) {
return oauthErrorResponse(c, error);
}
throw error;
}
setIncomingAuth({ c, auth });
await server.startHTTP({
url: new URL(c.req.url),
httpPath: path,
req: c.env.incoming,
res: c.env.outgoing,
options: { sessionIdGenerator: randomUUID },
});
return RESPONSE_ALREADY_SENT;
};

View File

@@ -0,0 +1,12 @@
import { getProtectedResourceMetadata } from "../auth/protectedResourceMetadata.js";
import type { LeafMcpContext, McpRouteOptions } from "../types.js";
export const createHandleProtectedResourceMetadata =
({ options }: { options: McpRouteOptions }) =>
(c: LeafMcpContext) =>
c.json(
getProtectedResourceMetadata({
resourceUrl: options.resourceUrl,
serverURL: options["server-url"],
}),
);

View File

@@ -1,87 +0,0 @@
import {
buildAuthForRequest,
type ConsoleLogger,
createAskAutumnMCPServer,
createAutumnOperationsMCPServer,
getAuthorizationServerMetadata,
getProtectedResourceMetadata,
type MCPServerFlags,
type OAuthEnvironment,
OAuthHttpError,
} from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import type { Context, Hono } from "hono";
export interface McpRouteOptions extends MCPServerFlags {
readonly "oauth-enabled": boolean;
readonly "oauth-environment": OAuthEnvironment;
readonly logger: ConsoleLogger;
}
type AppContext = Context<{ Bindings: HttpBindings }>;
type McpPath = "/mcp" | "/internal/mcp";
type McpApp = Hono<{ Bindings: HttpBindings }>;
export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")),
);
app.get("/.well-known/oauth-protected-resource/internal/mcp", (c) =>
c.json(
getProtectedResourceMetadata(c.req.raw.headers, options, "/internal/mcp"),
),
);
app.get("/.well-known/oauth-authorization-server", (c) =>
c.json(getAuthorizationServerMetadata(options)),
);
const handleMcp = async (
c: AppContext,
path: McpPath,
server: ReturnType<typeof createAskAutumnMCPServer>,
) => {
let auth: Awaited<ReturnType<typeof buildAuthForRequest>>;
try {
auth = await buildAuthForRequest(
c.req.raw.headers,
options,
options.logger,
path,
);
} catch (error) {
if (error instanceof OAuthHttpError) {
if (error.wwwAuthenticate) {
c.header("WWW-Authenticate", error.wwwAuthenticate);
}
return c.json(
{ error: error.error, error_description: error.message },
{ status: error.status as 401 | 403 },
);
}
throw error;
}
(c.env.incoming as typeof c.env.incoming & { auth?: typeof auth }).auth =
auth;
await server.startHTTP({
url: new URL(c.req.url),
httpPath: path,
req: c.env.incoming,
res: c.env.outgoing,
options: { serverless: true },
});
return RESPONSE_ALREADY_SENT;
};
app.all("/mcp", (c) =>
handleMcp(c, "/mcp", createAutumnOperationsMCPServer()),
);
app.all("/internal/mcp", (c) =>
handleMcp(c, "/internal/mcp", createAskAutumnMCPServer()),
);
return app;
}

View File

@@ -0,0 +1,29 @@
import { createAutumnOperationsMCPServer } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { Hono } from "hono";
import { MCP_PATH, PROTECTED_RESOURCE_METADATA_PATH } from "./constants.js";
import { createHandleMcp } from "./handlers/handleMcp.js";
import { createHandleProtectedResourceMetadata } from "./handlers/handleProtectedResourceMetadata.js";
import type { McpRouteOptions } from "./types.js";
export const createMcpRouter = (options: McpRouteOptions) => {
const router = new Hono<{ Bindings: HttpBindings }>();
const mcpServer = createAutumnOperationsMCPServer();
router.get(
PROTECTED_RESOURCE_METADATA_PATH,
createHandleProtectedResourceMetadata({
options,
}),
);
router.all(
MCP_PATH,
createHandleMcp({
options,
path: MCP_PATH,
server: mcpServer,
}),
);
return router;
};

View File

@@ -0,0 +1,15 @@
import type { AutumnLogger } from "@autumn/logging";
import type { MCPServerFlags, OAuthEnvironment } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import type { Context, Hono } from "hono";
export interface McpRouteOptions extends MCPServerFlags {
readonly "oauth-enabled": boolean;
readonly "oauth-environment": OAuthEnvironment;
readonly logger: AutumnLogger;
readonly resourceUrl: string;
}
export type LeafMcpContext = Context<{ Bindings: HttpBindings }>;
export type LeafMcpRouter = Hono<{ Bindings: HttpBindings }>;
export type { MCPOAuthFlags } from "./auth/resolveRequestAuth.js";

View File

@@ -5,29 +5,16 @@ import {
type ChatInstallation,
type ChatProvider,
chatInstallations,
Scopes,
} from "@autumn/shared";
import type { ChatInstallState } from "@autumn/shared/utils/chatState";
import { and, eq, or } from "drizzle-orm";
import { replaceInstallationOAuthCredentials } from "../../internal/installations/actions/replaceInstallationOAuthCredentials.js";
import { decrypt, encrypt } from "../../lib/crypto.js";
import { db } from "../../lib/db.js";
import { env } from "../../lib/env.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const apiKeyScopes = [
Scopes.Customers.Read,
Scopes.Customers.Write,
Scopes.Plans.Read,
Scopes.Plans.Write,
Scopes.Billing.Read,
Scopes.Billing.Write,
Scopes.Balances.Write,
];
const apiKeyPrefix = (env: AppEnv) =>
env === AppEnv.Live ? "am_sk_live" : "am_sk_test";
export const getStateSecret = () => env.CHAT_STATE_SECRET;
export const findInstallation = (provider: ChatProvider, workspaceId: string) =>
@@ -50,33 +37,6 @@ export const getInstallationKey = (
return decrypt(key);
};
const buildApiKey = ({
orgId,
userId,
env,
provider,
}: {
orgId: string;
userId: string;
env: AppEnv;
provider: ChatProvider;
}) => {
const secret = `${apiKeyPrefix(env)}_${crypto.randomBytes(32).toString("base64url")}`;
const key = {
id: `key_${crypto.randomUUID().replace(/-/g, "")}`,
org_id: orgId,
user_id: userId,
name: `Chat MCP (${provider})`,
prefix: secret.substring(0, 14),
created_at: Date.now(),
env,
hashed_key: crypto.createHash("sha256").update(secret).digest("hex"),
meta: { created_via: "chat", provider },
scopes: apiKeyScopes,
};
return { key, secret };
};
const deleteInstallationApiKeys = async (
tx: ChatTransaction,
installation: ChatInstallation,
@@ -111,19 +71,6 @@ export const replaceInstallation = async ({
scopes: string[];
installedByProviderUserId?: string;
}) => {
const sandbox = buildApiKey({
orgId: state.orgId,
userId: state.userId,
env: AppEnv.Sandbox,
provider,
});
const live = buildApiKey({
orgId: state.orgId,
userId: state.userId,
env: AppEnv.Live,
provider,
});
const sameOrg = and(
eq(chatInstallations.org_id, state.orgId),
eq(chatInstallations.provider, provider),
@@ -134,8 +81,6 @@ export const replaceInstallation = async ({
);
await db.transaction(async (tx) => {
await tx.insert(apiKeys).values([sandbox.key, live.key]);
const existingInstallations = await tx.query.chatInstallations.findMany({
where: or(sameOrg, sameWorkspace),
});
@@ -144,24 +89,29 @@ export const replaceInstallation = async ({
}
await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace));
await tx.insert(chatInstallations).values({
id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
org_id: state.orgId,
provider,
workspace_id: workspaceId,
workspace_name: workspaceName,
bot_user_id: botUserId,
bot_access_token: encrypt(botAccessToken),
scopes,
default_env: state.env,
sandbox_api_key_id: sandbox.key.id,
sandbox_api_key: encrypt(sandbox.secret),
live_api_key_id: live.key.id,
live_api_key: encrypt(live.secret),
installed_by_user_id: state.userId,
installed_by_provider_user_id: installedByProviderUserId,
created_at: Date.now(),
updated_at: Date.now(),
const [installation] = await tx
.insert(chatInstallations)
.values({
id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
org_id: state.orgId,
provider,
workspace_id: workspaceId,
workspace_name: workspaceName,
bot_user_id: botUserId,
bot_access_token: encrypt(botAccessToken),
scopes,
default_env: state.env,
installed_by_user_id: state.userId,
installed_by_provider_user_id: installedByProviderUserId,
created_at: Date.now(),
updated_at: Date.now(),
})
.returning();
await replaceInstallationOAuthCredentials({
tx,
installation,
userId: state.userId,
});
});
};

View File

@@ -2,6 +2,7 @@ import { verifyChatInstallState } from "@autumn/shared/utils/chatState";
import { Hono } from "hono";
import { z } from "zod";
import { bot } from "../../bot.js";
import { logger } from "../../lib/logger.js";
import { getStateSecret, replaceInstallation } from "./installations.js";
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
@@ -36,25 +37,38 @@ slackRoutes.get("/oauth/callback", async (c) => {
.filter(Boolean),
installedByProviderUserId: oauth.authed_user?.id,
});
console.info("[chat:slack] Installed", {
orgId: parsedState.orgId,
workspaceId: oauth.team.id,
workspaceName: oauth.team.name,
logger.info("[chat:slack] Installed", {
event: "leaf.slack_installed",
context: {
org_id: parsedState.orgId,
slack_workspace_id: oauth.team.id,
},
data: {
workspace_name: oauth.team.name,
},
});
return c.redirect(slackSuccessUrl());
} catch (error) {
console.error("[chat:slack] OAuth callback failed", error);
logger.error("[chat:slack] OAuth callback failed", error, {
event: "leaf.slack_oauth_failed",
});
return c.redirect(slackErrorUrl("Slack install failed"));
}
});
slackRoutes.post("/events", (c) => {
logger.debug("Received Slack events request", {
event: "leaf.slack_events_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});
slackRoutes.post("/interactions", (c) => {
logger.debug("Received Slack interactions request", {
event: "leaf.slack_interactions_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});

View File

@@ -1,4 +1,5 @@
import { AppEnv, type ChatInstallation } from "@autumn/shared";
import type { AutumnLogger } from "@autumn/logging";
import { z } from "zod";
export const agentOutputSchema = z.preprocess(
@@ -62,6 +63,7 @@ export type SignatureArgs = {
export type BotMessage = {
installation: ChatInstallation;
logger?: AutumnLogger;
onAction?: (message: string) => Promise<void> | void;
recentMessages?: ChatContextMessage[];
text: string;

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { createLeafSessionContext } from "../../../src/lib/logger.js";
describe("Leaf logger context", () => {
test("creates stable session ids and distinct trace ids", () => {
const first = createLeafSessionContext({
channelId: "C1",
provider: "slack",
providerUserId: "U1",
threadId: "T1",
workspaceId: "W1",
});
const second = createLeafSessionContext({
channelId: "C1",
provider: "slack",
providerUserId: "U2",
threadId: "T1",
workspaceId: "W1",
});
expect(first.sessionId).toBe(second.sessionId);
expect(first.traceId).not.toBe(second.traceId);
expect(first.context).toMatchObject({
provider: "slack",
session_id: first.sessionId,
trace_id: first.traceId,
slack_channel_id: "C1",
slack_thread_id: "T1",
slack_workspace_id: "W1",
});
});
});

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { MCP_OAUTH_SCOPES } from "@autumn/mcp";
import { Scopes } from "@autumn/shared/scopeDefinitions";
import {
buildAuthForRequest,
type MCPOAuthFlags,
} from "../../../src/mcp/auth/resolveRequestAuth.js";
import {
getProtectedResourceMetadata,
type OAuthHttpError,
} from "../../../src/mcp/auth/protectedResourceMetadata.js";
const flags = {
"oauth-enabled": true,
"oauth-environment": "sandbox",
"server-url": "http://localhost:8080",
} satisfies Partial<MCPOAuthFlags>;
const logger = {
warning: () => {},
} as never;
const resourceUrl = "http://localhost:2718/mcp";
const internalResourceUrl = "http://localhost:2718/internal/mcp";
describe("MCP OAuth auth resolution", () => {
test("requests scopes required by public write tools", () => {
expect(MCP_OAUTH_SCOPES).toEqual(
expect.arrayContaining([
Scopes.Customers.Write,
Scopes.Plans.Write,
Scopes.Billing.Write,
Scopes.Balances.Write,
]),
);
});
test("returns a WWW-Authenticate challenge without a bearer token", async () => {
await expect(
buildAuthForRequest({
headers: new Headers(),
flags: flags as MCPOAuthFlags,
logger,
resourceUrl,
}),
).rejects.toMatchObject({
status: 401,
error: "invalid_token",
wwwAuthenticate:
'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp", error="invalid_token"',
} satisfies Partial<OAuthHttpError>);
});
test("returns an internal MCP resource challenge", async () => {
await expect(
buildAuthForRequest({
headers: new Headers(),
flags: flags as MCPOAuthFlags,
logger,
resourceUrl: internalResourceUrl,
}),
).rejects.toMatchObject({
status: 401,
error: "invalid_token",
wwwAuthenticate:
'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp", error="invalid_token"',
} satisfies Partial<OAuthHttpError>);
});
test("passes OAuth bearer tokens through without local verification", async () => {
const originalFetch = globalThis.fetch;
let fetchCalled = false;
const mockFetch = (async () => {
fetchCalled = true;
return Response.json({});
}) as unknown as typeof fetch;
globalThis.fetch = mockFetch;
try {
const auth = await buildAuthForRequest({
headers: new Headers({
authorization: "Bearer oauth_token",
}),
flags: flags as MCPOAuthFlags,
logger,
resourceUrl,
});
expect(auth).toMatchObject({
apiKey: "oauth_token",
authMethod: "oauth",
env: "sandbox",
principalId: "oauth:unverified",
resource: "http://localhost:2718/mcp",
serverURL: "http://localhost:8080",
});
expect(fetchCalled).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
test("accepts a static secret-key when OAuth is enabled", async () => {
const auth = await buildAuthForRequest({
headers: new Headers({
"secret-key": "am_sk_test_chat",
}),
flags: flags as MCPOAuthFlags,
logger,
resourceUrl,
});
expect(auth.apiKey).toBe("am_sk_test_chat");
expect(auth.principalId).toStartWith("secret-key:");
expect(auth.resource).toBe("http://localhost:2718/mcp");
});
test("accepts an Autumn API key bearer token when OAuth is enabled", async () => {
const auth = await buildAuthForRequest({
headers: new Headers({
authorization: "Bearer am_sk_test_chat",
}),
flags: flags as MCPOAuthFlags,
logger,
resourceUrl,
});
expect(auth.apiKey).toBe("am_sk_test_chat");
expect(auth.principalId).toStartWith("secret-key:");
});
test("uses route-specific resource URLs", async () => {
const auth = await buildAuthForRequest({
headers: new Headers({
authorization: "Bearer am_sk_test_chat",
}),
flags: flags as MCPOAuthFlags,
logger,
resourceUrl: internalResourceUrl,
});
expect(auth.resource).toBe("http://localhost:2718/internal/mcp");
expect(
getProtectedResourceMetadata({
resourceUrl: internalResourceUrl,
serverURL: flags["server-url"],
}).resource,
).toBe("http://localhost:2718/internal/mcp");
});
test("missing static secret-key returns the auth error path", async () => {
await expect(
buildAuthForRequest({
headers: new Headers(),
flags: {
...flags,
"oauth-enabled": false,
} as MCPOAuthFlags,
logger,
resourceUrl,
}),
).rejects.toMatchObject({
status: 401,
error: "invalid_token",
} satisfies Partial<OAuthHttpError>);
});
});

View File

@@ -12,6 +12,7 @@
"paths": {
"@autumn/shared": ["../../shared/index.ts"],
"@autumn/shared/*": ["../../shared/*"],
"@autumn/logging": ["../../packages/logging/src/index.ts"],
"@autumn/mcp/*": ["../../packages/mcp/*"],
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],

View File

@@ -88,6 +88,8 @@
"name": "@autumn/leaf",
"version": "0.0.1",
"dependencies": {
"@autumn/auth": "workspace:*",
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",
@@ -241,6 +243,15 @@
"typescript": "^5",
},
},
"packages/auth": {
"name": "@autumn/auth",
"version": "0.0.1",
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3",
},
},
"packages/autumn-js": {
"name": "autumn-js",
"version": "1.2.17",
@@ -284,10 +295,25 @@
"name": "@autumn/ksuid",
"version": "1.0.0",
},
"packages/logging": {
"name": "@autumn/logging",
"version": "0.0.1",
"dependencies": {
"@axiomhq/pino": "^1.3.1",
"pino": "^9.6.0",
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3",
},
},
"packages/mcp": {
"name": "@autumn/mcp",
"version": "0.0.1",
"dependencies": {
"@autumn/auth": "workspace:*",
"@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",
@@ -381,6 +407,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.9",
"@anthropic-ai/sdk": "^0.32.1",
"@autumn/auth": "workspace:*",
"@autumn/ksuid": "workspace:*",
"@autumn/shared": "workspace:*",
"@autumn/stripe-sync": "workspace:*",
@@ -716,12 +743,16 @@
"@asyncapi/specs": ["@asyncapi/specs@6.8.1", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA=="],
"@autumn/auth": ["@autumn/auth@workspace:packages/auth"],
"@autumn/docs": ["@autumn/docs@workspace:apps/docs"],
"@autumn/ksuid": ["@autumn/ksuid@workspace:packages/ksuid"],
"@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"],
"@autumn/logging": ["@autumn/logging@workspace:packages/logging"],
"@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"],
"@autumn/openapi": ["@autumn/openapi@workspace:packages/openapi"],
@@ -5708,7 +5739,7 @@
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="],
@@ -5998,14 +6029,20 @@
"@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
"@autumn/auth/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/leaf/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@autumn/leaf/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@autumn/logging/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/mcp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/mcp/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@autumn/openapi/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"@autumn/scripts/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@autumn/server/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="],
@@ -6014,8 +6051,12 @@
"@autumn/server/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
"@autumn/server/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
"@autumn/shared/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@autumn/vite/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
@@ -6214,6 +6255,8 @@
"@infisical/sdk/@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.600.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.600.0", "@aws-sdk/client-sso": "3.598.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/credential-provider-cognito-identity": "3.600.0", "@aws-sdk/credential-provider-env": "3.598.0", "@aws-sdk/credential-provider-http": "3.598.0", "@aws-sdk/credential-provider-ini": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/credential-provider-process": "3.598.0", "@aws-sdk/credential-provider-sso": "3.598.0", "@aws-sdk/credential-provider-web-identity": "3.598.0", "@aws-sdk/types": "3.598.0", "@smithy/credential-provider-imds": "^3.1.1", "@smithy/property-provider": "^3.1.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-cC9uqmX0rgx1efiJGqeR+i0EXr8RQ5SAzH7M45WNBZpYiLEe6reWgIYJY9hmOxuaoMdWSi8kekuN3IjTIORRjw=="],
"@infisical/sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -6926,8 +6969,6 @@
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -6988,6 +7029,8 @@
"atmn/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"atmn/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"atmn/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"autumn-js/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
@@ -6998,6 +7041,8 @@
"autumn-js/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"autumn-js/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"autumn-js/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"ava/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
@@ -7062,6 +7107,8 @@
"checkout/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"checkout/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -7524,6 +7571,8 @@
"sdk-test/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"sdk-test/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -7606,6 +7655,8 @@
"ts-to-zod/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"ts-to-zod/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ts-to-zod/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"tsc-alias/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
@@ -7614,6 +7665,8 @@
"tshy/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"tshy/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"tsutils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
@@ -7768,8 +7821,12 @@
"@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@autumn/auth/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/leaf/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@autumn/logging/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/mcp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/server/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],

View File

@@ -27,8 +27,10 @@ COPY apps/sdk-test/package.json apps/sdk-test/
COPY apps/website/package.json apps/website/
COPY packages/atmn/package.json packages/atmn/
COPY packages/atmn-tests/package.json packages/atmn-tests/
COPY packages/auth/package.json packages/auth/
COPY packages/autumn-js/package.json packages/autumn-js/
COPY packages/ksuid/package.json packages/ksuid/
COPY packages/logging/package.json packages/logging/
COPY packages/mcp/package.json packages/mcp/
COPY packages/openapi/package.json packages/openapi/
COPY packages/sdk/package.json packages/sdk/

View File

@@ -1,45 +0,0 @@
# Multi-stage Dockerfile for Autumn development
FROM oven/bun:latest AS base
WORKDIR /app
# Skip Puppeteer Chromium download to speed up install
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_SKIP_DOWNLOAD=true
COPY package.json ./
COPY bun.lock ./
COPY shared/package*.json ./shared/
COPY server/package*.json ./server/
COPY vite/package*.json ./vite/
RUN bun install
# Stage 1: /localtunnel
FROM base AS localtunnel
WORKDIR /app
COPY localtunnel-start.sh ./
CMD ["sh", "localtunnel-start.sh"]
# Stage 2: /vite
FROM base AS vite
COPY shared/ ./shared/
WORKDIR /app/vite
COPY vite/ ./
EXPOSE 3000
CMD ["bun", "dev"]
# Stage 3: /server
FROM base AS server
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 8080
CMD ["bun", "dev"]
# Stage 4: Workers
FROM base AS workers
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
CMD ["bun", "workers:dev"]

View File

@@ -773,6 +773,63 @@ actions:
"interval": "month",
}, create_in_stripe=True, archived=False)
# Handle response
print(res)
- target: $["paths"]["/v1/platform.get_revenuecat_keys"]["post"]
update:
x-codeSamples:
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
with Autumn(
x_api_version="2.3.0",
secret_key="<YOUR_BEARER_TOKEN_HERE>",
) as autumn:
res = autumn.platform.get_revenue_cat_keys(organization_slug="acme", env="test")
# Handle response
print(res)
- target: $["paths"]["/v1/platform.link_revenuecat"]["post"]
update:
x-codeSamples:
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
with Autumn(
x_api_version="2.3.0",
secret_key="<YOUR_BEARER_TOKEN_HERE>",
) as autumn:
res = autumn.platform.link_revenue_cat(organization_slug="acme", env="test", project_name="acme-mobile", redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat")
# Handle response
print(res)
- target: $["paths"]["/v1/platform.sync_revenuecat"]["post"]
update:
x-codeSamples:
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
with Autumn(
x_api_version="2.3.0",
secret_key="<YOUR_BEARER_TOKEN_HERE>",
) as autumn:
res = autumn.platform.sync_revenue_cat(organization_slug="acme", env="test", product_ids=[
"pro",
"premium",
])
# Handle response
print(res)
- target: $["paths"]["/v1/referrals.create_code"]["post"]

View File

@@ -1,19 +1,20 @@
lockVersion: 2.0.0
id: 05940b80-1ef8-40f4-9878-822fb2792070
management:
docChecksum: 3b9e3c6fa63e6d5faadf490512c769a8
docChecksum: 69c7a38357ec2752bfddc3d3fe3c8217
docVersion: 2.3.0
speakeasyVersion: 1.762.0
generationVersion: 2.882.0
releaseVersion: 0.4.18
configChecksum: 2263d20254e354a1792248274002f650
persistentEdits:
generation_id: b518426a-4c9e-4984-92d3-43bc77843949
pristine_commit_hash: 7a491e8dfc11b8aa551892f546a7bd3ec03f6613
pristine_tree_hash: 9d6dc1b3ba101c63df68d2cc7daab4b190b03a7b
generation_id: eeb45171-4135-499a-b86f-e278f1f1d8ab
pristine_commit_hash: 8339a7c4802bb87e0ebf10bdc7cab95b507d4599
pristine_tree_hash: 7a4a651f3fd98b3d736fe7bb6dbc87d904b8af0c
features:
python:
additionalDependencies: 1.0.0
additionalProperties: 1.0.1
constsAndDefaults: 1.0.7
core: 6.0.21
defaultEnabledRetries: 0.2.0
@@ -67,6 +68,10 @@ trackedFiles:
id: b597cfc651ae
last_write_checksum: sha1:641c0d27fe8e8242429da6d8083fe10ab70b570c
pristine_git_object: 2fd7b5fb0f426ec942cb43aa30d3a85287ef724a
docs/models/apikey.md:
id: 3cd1b4235d4f
last_write_checksum: sha1:5c30cc7199e1f3886bcea8052365d549277e1795
pristine_git_object: 673e261bea89e033c21752bddec54413cae7d856
docs/models/attachaction.md:
id: 984e5ad5e280
last_write_checksum: sha1:162c4a0771f000b3835c06765eafdd843fb8d2a7
@@ -639,6 +644,14 @@ trackedFiles:
id: 41de438d57cd
last_write_checksum: sha1:ab7c54bfe0c657851a59fedd447c99bb3acdb4c2
pristine_git_object: 7848c33fdbffc71c09fadd8ad859214182f00e0a
docs/models/checkproduct1.md:
id: 22c131b2d914
last_write_checksum: sha1:a156ce217c8853244160cd4dc347ef660a4b4688
pristine_git_object: 8d8fe115ad5a73bcd5b21e22700ebc537105d36f
docs/models/checkproduct2.md:
id: f002dbaf8c20
last_write_checksum: sha1:3140655b5efa528c4d839550d3d17a04de6f3f43
pristine_git_object: 340e33a4d0afb0f7433a7b6f30d0329bd9f4015c
docs/models/checkresponse.md:
id: b988b0f4b781
last_write_checksum: sha1:9522d4ecea7631ce3ae3d80b341a068ccb2dcca4
@@ -1895,6 +1908,26 @@ trackedFiles:
id: afbdf26f0f4b
last_write_checksum: sha1:6d5dfd04c5d95c235f56eb23e31f497c9ed91247
pristine_git_object: 7390b496108568e19aba42d36c5c3b508e3d42b3
docs/models/getrevenuecatkeysapp.md:
id: b47525a2f9de
last_write_checksum: sha1:1de286cbb4c475583313a05f5d89c8cdc90fcbf1
pristine_git_object: 18eb7cb3f4c2077bbe2e19015f6098703a65ce03
docs/models/getrevenuecatkeysenv.md:
id: a9081dfb1e62
last_write_checksum: sha1:f44bbe9fd99bac368b4e679a7a05c4d22f030259
pristine_git_object: 5c9236f2a8fb8a3c6f762a47e8b2c40a3f7c39cc
docs/models/getrevenuecatkeysglobals.md:
id: 3e0c18c79f36
last_write_checksum: sha1:a264613009362faa9a1497f94403cf9fe1a9f869
pristine_git_object: 412e603c39750e0d782e772d72d7172779298506
docs/models/getrevenuecatkeysparams.md:
id: 76cd38034f68
last_write_checksum: sha1:99ff618181876cae820a5f42b6e06beb255c248c
pristine_git_object: f5c96fdacda480ae30ebc28d71826d5dffe80cef
docs/models/getrevenuecatkeysresponse.md:
id: 4b23c9d5ea78
last_write_checksum: sha1:4eb6f4db2d614bbb53dfb512b73030035dbb6d86
pristine_git_object: be7a493d4f7d1670084a8882a2ab4b60a8b367ad
docs/models/includedusage1.md:
id: 7ceb62e48016
last_write_checksum: sha1:9a7a940ec67dc041a2fb2064f8e5b433c9cb12ed
@@ -1919,6 +1952,22 @@ trackedFiles:
id: 40dd7473ab87
last_write_checksum: sha1:76b0d2926283b9d9cb79c92fc0c1baec458f06af
pristine_git_object: cfe1e33316af7bc0ca8cca5ecb59e72e5f3182bd
docs/models/linkrevenuecatenv.md:
id: 57a0ac8d952a
last_write_checksum: sha1:6a2280c9c13ebd82da37f3398be0d4427950bf3c
pristine_git_object: 419027171edab8fd89e8bcddb02e374d8b263649
docs/models/linkrevenuecatglobals.md:
id: 3a3c92d9666d
last_write_checksum: sha1:bd4c5dae75935cfffe3496713fcdcbad66a84fba
pristine_git_object: dfab4e4be248fa1f702de0f92f898dd41de56c09
docs/models/linkrevenuecatparams.md:
id: fdd3864d636f
last_write_checksum: sha1:aa4605a7153b9e78f325afc477a091b17bae9b98
pristine_git_object: 6e209832568861dae3b88067bc70762132db5595
docs/models/linkrevenuecatresponse.md:
id: a598227583fb
last_write_checksum: sha1:8481a4f7b9efb12da31da74469889eafb03cedb5
pristine_git_object: ea11c719bceb2bd254286e7f8f0c1ba5161d15b8
docs/models/listcustomersautotopup.md:
id: 43e50adc0195
last_write_checksum: sha1:36e4e209d3743d6ff7911104da4f54ebd484f923
@@ -2589,12 +2638,12 @@ trackedFiles:
pristine_git_object: 5add1a3cab3d01077452a4df8d9037c6279da319
docs/models/preview1.md:
id: 203e34d3c393
last_write_checksum: sha1:b9b1ce18639c5e83d9eb6bd83e1d99bd683291cd
pristine_git_object: ac258d817302c6d7f841b006ff7a7931cfe13df7
last_write_checksum: sha1:116b6aa31a514f220d270164fdc3572eacb16be8
pristine_git_object: d82654857e15b247088aa29b1edc997bfbaa2df5
docs/models/preview2.md:
id: 96d6fae57a72
last_write_checksum: sha1:4839487a437486dbc9567dec57a136234230ddc9
pristine_git_object: 9ad954e94d0ec60a5def86f162fe804fad115518
last_write_checksum: sha1:9654ce4f91d0c8ff6409117c1e6a12f6f6789802
pristine_git_object: 5080785d6528a0cd52d1f86d217efe31cbbbfa3c
docs/models/previewattachadditembillingmethod.md:
id: 341766b0f910
last_write_checksum: sha1:a8357c2f1f1f0e394f9ca1aabf6c19c360496d57
@@ -3323,14 +3372,6 @@ trackedFiles:
id: 446cf8386114
last_write_checksum: sha1:c917a956f1af010a60b2e7d43bad09cf54e44274
pristine_git_object: c42de1ea48789d1cd09dcce226e3673f29b2a747
docs/models/product1.md:
id: 880ca8ae9886
last_write_checksum: sha1:d6c959243c6b293682d7faed288627aca8b42712
pristine_git_object: 555d0ec5c226b413aebb505ac316c7a14cf1fc4b
docs/models/product2.md:
id: 6262b044d234
last_write_checksum: sha1:d650f24a1fee7d7b93f1f3d1f0c3a9b727b5f111
pristine_git_object: c1b81dedf250c04788acef9878b406b03b3747cc
docs/models/productdisplay1.md:
id: b5bdefcde7af
last_write_checksum: sha1:4cbda567685f39d23b4a83d5a9bc20c4bf2fa21b
@@ -3411,6 +3452,10 @@ trackedFiles:
id: a15f5440d48c
last_write_checksum: sha1:792ae2d550cfbb56888ee2fe6342a0666c83d219
pristine_git_object: 49346d4858b332cd3d0e59f46c4af0116346bf14
docs/models/result.md:
id: b850437752c3
last_write_checksum: sha1:3ee32bdd6689dc5dda87df145ffe124a1e78c80a
pristine_git_object: 1718a5b0274ab83f20f2fd21d269934722c64f01
docs/models/revenuecat.md:
id: 5418b6373a80
last_write_checksum: sha1:3cfb5781a5762c564d5b48ef1af6cba5db229435
@@ -3615,6 +3660,10 @@ trackedFiles:
id: a0fe36809906
last_write_checksum: sha1:0506cb5fd620fb73affa03da445160a245e94fd6
pristine_git_object: caa4f8813f21dd5e60c7f1fd5e7591340247fb1f
docs/models/storepush.md:
id: 0bff2a8f0cc7
last_write_checksum: sha1:220f7b38e22cb56d9ac3c5b9f2adfd2da41c2842
pristine_git_object: 2a58923b830a788b0746bea3e55670ab79c1fb6e
docs/models/stripe.md:
id: ef8fa4c7fedd
last_write_checksum: sha1:304cbcf780ff0569d9ea00886e26043a8ad2cf88
@@ -3627,6 +3676,38 @@ trackedFiles:
id: 87bc56fd272b
last_write_checksum: sha1:d5bd8497d81e3928c11257ad6db7bd0df6ac2b91
pristine_git_object: d2281ec892b2bb9f1210643cf0efcf4166106876
docs/models/syncrevenuecatapp.md:
id: 73d2264f26a6
last_write_checksum: sha1:64526bb1b58191cb623c4f4f696eff4c80848a1c
pristine_git_object: eafb5e618296dc86ee5b3fda114a3686da4ef8e1
docs/models/syncrevenuecatenv.md:
id: 012dc6089488
last_write_checksum: sha1:f81a04a3c0f08c817f0da7dc2401a3fd7a9a94e9
pristine_git_object: 9b2eedb9eb3836bd27951ad04a006d88bca67db7
docs/models/syncrevenuecatglobals.md:
id: 5c889c9a0be8
last_write_checksum: sha1:6b865a42d969203431455ffed358fb72f586ee7c
pristine_git_object: ddab5b6ab7d0f08bb6618eadfa9744603f75e0a5
docs/models/syncrevenuecatparams.md:
id: 9ab677e7cf8f
last_write_checksum: sha1:8d1b3cde4fc5b3a03019107eb0f65620e53a8285
pristine_git_object: 5a65e517e6beef0c6afc7adfbcd450c979f4804a
docs/models/syncrevenuecatprice.md:
id: 9a5d5482c4e0
last_write_checksum: sha1:98d655ac8f6bfde40101ea7069c9c29a25bd27c8
pristine_git_object: f1f2e2af312104537761410fe1da470cc67466d0
docs/models/syncrevenuecatproduct.md:
id: 1f316b101f9a
last_write_checksum: sha1:4cd1e623d4f8789444f7c6b03835ced8bf00421c
pristine_git_object: def6ce10cef765494237dacc1b5add17c84a2be1
docs/models/syncrevenuecatresponse.md:
id: 7a818b200e7d
last_write_checksum: sha1:9e9b2f8c540bd84a81597d1be89b1b74d9a05719
pristine_git_object: ec12b07addf1937d6b6b95352233907ada3de1ce
docs/models/syncrevenuecatstatus.md:
id: a4921afbf845
last_write_checksum: sha1:5aaa82e010e6dc5f35a20e11a644b7a2e6afd252
pristine_git_object: d56a56f2bd0f2d6f9a847acbcc03c41079ab1d41
docs/models/total.md:
id: f4060c3b4657
last_write_checksum: sha1:08c2c14481fcae1bcc1c550d6e2c95f14bb1efb0
@@ -4239,6 +4320,10 @@ trackedFiles:
id: 2d8c741fff57
last_write_checksum: sha1:a956c9b35832ad1b4b988220bbf133aa87c73301
pristine_git_object: 25a47ac0fdb8bc31110b3d96d2656bf744969f37
docs/sdks/platform/README.md:
id: b66219e9cd4d
last_write_checksum: sha1:e18d4e05e801ff5b52414fc51e98e10f33cfbb04
pristine_git_object: 68bfa16724239e249cfa4263dd4e029e148d9af8
docs/sdks/referrals/README.md:
id: 50b71f597f20
last_write_checksum: sha1:de99a40759f0d5c8850f2f59613d20d646161c53
@@ -4337,8 +4422,8 @@ trackedFiles:
pristine_git_object: 89560b566073785535643e694c112bedbd3db13d
src/autumn_sdk/models/__init__.py:
id: bcf3802243ff
last_write_checksum: sha1:2340350b2b45b845b17559c2d6148cbe798cd19c
pristine_git_object: 52418f1a7720d58ebd5f6d74de74baca5fdc6e5e
last_write_checksum: sha1:0300fd72a06e0e2bb6fdf06654e88e670fd575bb
pristine_git_object: 923db4da1df1e544bfa79c2fc5b55c90a1a3fb5e
src/autumn_sdk/models/aggregateeventsop.py:
id: 01321099f2a5
last_write_checksum: sha1:bbaf78080f665b38e531d2427c787e40ca0635a7
@@ -4361,8 +4446,8 @@ trackedFiles:
pristine_git_object: ac6c4e6ec0e3ab0c7990440e6d6f04fbef840bdb
src/autumn_sdk/models/checkop.py:
id: 31c2f84723c6
last_write_checksum: sha1:7cb0cbbf11f480372aa2c5a3466b07f78e301226
pristine_git_object: b165ecfba6d2d2d1ccf9a21029d6fa87db67b470
last_write_checksum: sha1:8711ca8b987fad934aea167a38b5d614836bcdb9
pristine_git_object: 6c98274a990207b1690198e94541c51531e01c4e
src/autumn_sdk/models/createbalanceop.py:
id: 27daf4da75bf
last_write_checksum: sha1:f034893074f11952de2b174c8f2991232e512858
@@ -4443,6 +4528,10 @@ trackedFiles:
id: 590fb77ac88d
last_write_checksum: sha1:4b545b24018986ba9d93107df7840f39fb1f861c
pristine_git_object: e619e84014109d0ac73df596b4926a8f6dc92ee3
src/autumn_sdk/models/getrevenuecatkeysop.py:
id: 015155862a71
last_write_checksum: sha1:6b3f88a48b721093562ec59db3f6dae2540fa139
pristine_git_object: a51f5d26227a36d16cce914b32e92363eb5a95ff
src/autumn_sdk/models/internal/__init__.py:
id: 2906fe7f2cde
last_write_checksum: sha1:1905b58b74ecc52346d8f5c24ded2b6d6e1dad4a
@@ -4451,6 +4540,10 @@ trackedFiles:
id: 4e33eb99f463
last_write_checksum: sha1:b12bb60e74b8678b0fd0543223c09cc3d77dcc8b
pristine_git_object: 2614675c993545d5df1e516d043649e50281c2a3
src/autumn_sdk/models/linkrevenuecatop.py:
id: 8dd3e355d8d5
last_write_checksum: sha1:f5d06c40f8031e1791bd515fdad831f539a6ecaf
pristine_git_object: 2a117515b5cac185ba0b7eb21d3b2b4d8260d194
src/autumn_sdk/models/listcustomersop.py:
id: d7074740b8b0
last_write_checksum: sha1:64e98dae9bc3719c50a81e306ce270ad59d2e216
@@ -4511,6 +4604,10 @@ trackedFiles:
id: 603339ee67e3
last_write_checksum: sha1:46900f03adbb063677a4190d461c0460c470618e
pristine_git_object: 417d43b99b409c1d4e003c593097ac05ec5da795
src/autumn_sdk/models/syncrevenuecatop.py:
id: faddfbfd1214
last_write_checksum: sha1:a2b90222b09b4ca95bc78d3573f5b17d4d50208b
pristine_git_object: 7d12a3d78b0ab1e19f8d66fe3ed367066c145fc2
src/autumn_sdk/models/trackop.py:
id: 2a744315e781
last_write_checksum: sha1:216a03a195bb90ad24e42f62294a3de606b29142
@@ -4539,6 +4636,10 @@ trackedFiles:
id: cf1ebabb687c
last_write_checksum: sha1:8cca1565af6b67ab6947b016d0ab8f7a431fa824
pristine_git_object: 4c299d37ebbc9a9185bb496926a5a326334dcbe7
src/autumn_sdk/platform.py:
id: aee79240c441
last_write_checksum: sha1:2e80cdba550e5487a2ac16c063e24caf3f2b265f
pristine_git_object: 9a76e9815155fa2a01056e20ea2d7ec640718cd9
src/autumn_sdk/py.typed:
id: 9b75cee1c007
last_write_checksum: sha1:8efc425ffe830805ffcc0f3055871bdcdc542c60
@@ -4553,8 +4654,8 @@ trackedFiles:
pristine_git_object: c52c86dd77e753356560ebcf0ee10f8dc46de593
src/autumn_sdk/sdk.py:
id: 9e733b372628
last_write_checksum: sha1:a51e0822250581aeecd610abcb8207849cd3c73c
pristine_git_object: 19d77062eea9e5010e454d71cb64f137f7001314
last_write_checksum: sha1:da2019a4fd1bc539f99076623e758d53baec25b7
pristine_git_object: 8feede30ce5471864788d9ce7e6779ab9be5157c
src/autumn_sdk/sdkconfiguration.py:
id: e65df2e44fc0
last_write_checksum: sha1:233b710dff940202f00e389e0c8fa6a33f6ae7b4
@@ -5232,4 +5333,34 @@ examples:
responses:
"202":
application/json: {"success": true}
linkRevenueCat:
speakeasy-default-link-revenue-cat:
parameters:
header:
x-api-version: "2.3.0"
requestBody:
application/json: {"organization_slug": "acme", "env": "test", "project_name": "acme-mobile", "redirect_url": "https://dashboard.useautumn.com/dev?tab=revenuecat"}
responses:
"200":
application/json: {"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"}
syncRevenueCat:
speakeasy-default-sync-revenue-cat:
parameters:
header:
x-api-version: "2.3.0"
requestBody:
application/json: {"organization_slug": "acme", "env": "test", "product_ids": ["pro", "premium"]}
responses:
"200":
application/json: {"results": [{"plan_id": "pro", "status": "synced", "store_identifier": "autumn.sandbox.org_123.pro", "apps": [{"app_id": "app_test", "app_type": "test_store", "product": "created", "store_push": "skipped", "price": "set"}]}]}
getRevenueCatKeys:
speakeasy-default-get-revenue-cat-keys:
parameters:
header:
x-api-version: "2.3.0"
requestBody:
application/json: {"organization_slug": "acme", "env": "test"}
responses:
"200":
application/json: {"apps": [{"app_id": "app1a2b3c4d", "app_type": "test_store", "name": "Acme (Test Store)", "api_keys": [{"id": "apikey12345", "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", "environment": "production", "app_id": "app1a2b3c4"}]}], "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"}
examplesVersion: 1.0.2

View File

@@ -296,6 +296,12 @@ Use this to permanently remove a feature. Note: features that are used in produc
* [update](docs/sdks/plans/README.md#update) - Update a plan
* [delete](docs/sdks/plans/README.md#delete) - Delete a plan
### [Platform](docs/sdks/platform/README.md)
* [link_revenue_cat](docs/sdks/platform/README.md#link_revenue_cat) - Generate a RevenueCat OAuth URL for linking a project to an organization.
* [sync_revenue_cat](docs/sdks/platform/README.md#sync_revenue_cat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
* [get_revenue_cat_keys](docs/sdks/platform/README.md#get_revenue_cat_keys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
### [Referrals](docs/sdks/referrals/README.md)
* [create_code](docs/sdks/referrals/README.md#create_code) - Create or fetch a referral code for a customer in a referral program.

View File

@@ -256,6 +256,10 @@ if TYPE_CHECKING:
CheckOnIncrease2,
CheckParams,
CheckParamsTypedDict,
CheckProduct1,
CheckProduct1TypedDict,
CheckProduct2,
CheckProduct2TypedDict,
CheckResponse,
CheckResponseBody1,
CheckResponseBody1TypedDict,
@@ -292,10 +296,6 @@ if TYPE_CHECKING:
Preview1TypedDict,
Preview2,
Preview2TypedDict,
Product1,
Product1TypedDict,
Product2,
Product2TypedDict,
ProductDisplay1,
ProductDisplay1TypedDict,
ProductDisplay2,
@@ -862,6 +862,28 @@ if TYPE_CHECKING:
GetPlanTierBehavior,
GetPlanType,
)
from .getrevenuecatkeysop import (
APIKey,
APIKeyTypedDict,
GetRevenueCatKeysApp,
GetRevenueCatKeysAppTypedDict,
GetRevenueCatKeysEnv,
GetRevenueCatKeysGlobals,
GetRevenueCatKeysGlobalsTypedDict,
GetRevenueCatKeysParams,
GetRevenueCatKeysParamsTypedDict,
GetRevenueCatKeysResponse,
GetRevenueCatKeysResponseTypedDict,
)
from .linkrevenuecatop import (
LinkRevenueCatEnv,
LinkRevenueCatGlobals,
LinkRevenueCatGlobalsTypedDict,
LinkRevenueCatParams,
LinkRevenueCatParamsTypedDict,
LinkRevenueCatResponse,
LinkRevenueCatResponseTypedDict,
)
from .listcustomersop import (
ListCustomersAutoTopup,
ListCustomersAutoTopupTypedDict,
@@ -1561,6 +1583,23 @@ if TYPE_CHECKING:
SetupPaymentResponse,
SetupPaymentResponseTypedDict,
)
from .syncrevenuecatop import (
Result,
ResultTypedDict,
StorePush,
SyncRevenueCatApp,
SyncRevenueCatAppTypedDict,
SyncRevenueCatEnv,
SyncRevenueCatGlobals,
SyncRevenueCatGlobalsTypedDict,
SyncRevenueCatParams,
SyncRevenueCatParamsTypedDict,
SyncRevenueCatPrice,
SyncRevenueCatProduct,
SyncRevenueCatResponse,
SyncRevenueCatResponseTypedDict,
SyncRevenueCatStatus,
)
from .trackop import (
Deduction1,
Deduction1TypedDict,
@@ -1811,6 +1850,8 @@ if TYPE_CHECKING:
from . import internal
__all__ = [
"APIKey",
"APIKeyTypedDict",
"AggregateEventsCustomRange",
"AggregateEventsCustomRangeTypedDict",
"AggregateEventsFeatureID",
@@ -2044,6 +2085,10 @@ __all__ = [
"CheckOnIncrease2",
"CheckParams",
"CheckParamsTypedDict",
"CheckProduct1",
"CheckProduct1TypedDict",
"CheckProduct2",
"CheckProduct2TypedDict",
"CheckResponse",
"CheckResponseBody1",
"CheckResponseBody1TypedDict",
@@ -2568,6 +2613,15 @@ __all__ = [
"GetPlanStatus",
"GetPlanTierBehavior",
"GetPlanType",
"GetRevenueCatKeysApp",
"GetRevenueCatKeysAppTypedDict",
"GetRevenueCatKeysEnv",
"GetRevenueCatKeysGlobals",
"GetRevenueCatKeysGlobalsTypedDict",
"GetRevenueCatKeysParams",
"GetRevenueCatKeysParamsTypedDict",
"GetRevenueCatKeysResponse",
"GetRevenueCatKeysResponseTypedDict",
"IncludedUsage1",
"IncludedUsage1TypedDict",
"IncludedUsage2",
@@ -2577,6 +2631,13 @@ __all__ = [
"InvoiceTypedDict",
"Item",
"ItemTypedDict",
"LinkRevenueCatEnv",
"LinkRevenueCatGlobals",
"LinkRevenueCatGlobalsTypedDict",
"LinkRevenueCatParams",
"LinkRevenueCatParamsTypedDict",
"LinkRevenueCatResponse",
"LinkRevenueCatResponseTypedDict",
"ListCustomersAutoTopup",
"ListCustomersAutoTopupTypedDict",
"ListCustomersBillingControls",
@@ -3161,10 +3222,6 @@ __all__ = [
"ProcessorType",
"Processors",
"ProcessorsTypedDict",
"Product1",
"Product1TypedDict",
"Product2",
"Product2TypedDict",
"ProductDisplay1",
"ProductDisplay1TypedDict",
"ProductDisplay2",
@@ -3199,6 +3256,8 @@ __all__ = [
"ReferralTypedDict",
"RequestBody",
"RequestBodyTypedDict",
"Result",
"ResultTypedDict",
"Revenuecat",
"RevenuecatTypedDict",
"Rewards",
@@ -3279,11 +3338,24 @@ __all__ = [
"SetupPaymentRemoveItemInterval",
"SetupPaymentResponse",
"SetupPaymentResponseTypedDict",
"StorePush",
"Stripe",
"StripeTypedDict",
"Subscription",
"SubscriptionScope",
"SubscriptionTypedDict",
"SyncRevenueCatApp",
"SyncRevenueCatAppTypedDict",
"SyncRevenueCatEnv",
"SyncRevenueCatGlobals",
"SyncRevenueCatGlobalsTypedDict",
"SyncRevenueCatParams",
"SyncRevenueCatParamsTypedDict",
"SyncRevenueCatPrice",
"SyncRevenueCatProduct",
"SyncRevenueCatResponse",
"SyncRevenueCatResponseTypedDict",
"SyncRevenueCatStatus",
"Total",
"TotalTypedDict",
"TrackGlobals",
@@ -3768,6 +3840,10 @@ _dynamic_imports: dict[str, str] = {
"CheckOnIncrease2": ".checkop",
"CheckParams": ".checkop",
"CheckParamsTypedDict": ".checkop",
"CheckProduct1": ".checkop",
"CheckProduct1TypedDict": ".checkop",
"CheckProduct2": ".checkop",
"CheckProduct2TypedDict": ".checkop",
"CheckResponse": ".checkop",
"CheckResponseBody1": ".checkop",
"CheckResponseBody1TypedDict": ".checkop",
@@ -3804,10 +3880,6 @@ _dynamic_imports: dict[str, str] = {
"Preview1TypedDict": ".checkop",
"Preview2": ".checkop",
"Preview2TypedDict": ".checkop",
"Product1": ".checkop",
"Product1TypedDict": ".checkop",
"Product2": ".checkop",
"Product2TypedDict": ".checkop",
"ProductDisplay1": ".checkop",
"ProductDisplay1TypedDict": ".checkop",
"ProductDisplay2": ".checkop",
@@ -4335,6 +4407,24 @@ _dynamic_imports: dict[str, str] = {
"GetPlanStatus": ".getplanop",
"GetPlanTierBehavior": ".getplanop",
"GetPlanType": ".getplanop",
"APIKey": ".getrevenuecatkeysop",
"APIKeyTypedDict": ".getrevenuecatkeysop",
"GetRevenueCatKeysApp": ".getrevenuecatkeysop",
"GetRevenueCatKeysAppTypedDict": ".getrevenuecatkeysop",
"GetRevenueCatKeysEnv": ".getrevenuecatkeysop",
"GetRevenueCatKeysGlobals": ".getrevenuecatkeysop",
"GetRevenueCatKeysGlobalsTypedDict": ".getrevenuecatkeysop",
"GetRevenueCatKeysParams": ".getrevenuecatkeysop",
"GetRevenueCatKeysParamsTypedDict": ".getrevenuecatkeysop",
"GetRevenueCatKeysResponse": ".getrevenuecatkeysop",
"GetRevenueCatKeysResponseTypedDict": ".getrevenuecatkeysop",
"LinkRevenueCatEnv": ".linkrevenuecatop",
"LinkRevenueCatGlobals": ".linkrevenuecatop",
"LinkRevenueCatGlobalsTypedDict": ".linkrevenuecatop",
"LinkRevenueCatParams": ".linkrevenuecatop",
"LinkRevenueCatParamsTypedDict": ".linkrevenuecatop",
"LinkRevenueCatResponse": ".linkrevenuecatop",
"LinkRevenueCatResponseTypedDict": ".linkrevenuecatop",
"ListCustomersAutoTopup": ".listcustomersop",
"ListCustomersAutoTopupTypedDict": ".listcustomersop",
"ListCustomersBillingControls": ".listcustomersop",
@@ -5007,6 +5097,21 @@ _dynamic_imports: dict[str, str] = {
"SetupPaymentRemoveItemInterval": ".setuppaymentop",
"SetupPaymentResponse": ".setuppaymentop",
"SetupPaymentResponseTypedDict": ".setuppaymentop",
"Result": ".syncrevenuecatop",
"ResultTypedDict": ".syncrevenuecatop",
"StorePush": ".syncrevenuecatop",
"SyncRevenueCatApp": ".syncrevenuecatop",
"SyncRevenueCatAppTypedDict": ".syncrevenuecatop",
"SyncRevenueCatEnv": ".syncrevenuecatop",
"SyncRevenueCatGlobals": ".syncrevenuecatop",
"SyncRevenueCatGlobalsTypedDict": ".syncrevenuecatop",
"SyncRevenueCatParams": ".syncrevenuecatop",
"SyncRevenueCatParamsTypedDict": ".syncrevenuecatop",
"SyncRevenueCatPrice": ".syncrevenuecatop",
"SyncRevenueCatProduct": ".syncrevenuecatop",
"SyncRevenueCatResponse": ".syncrevenuecatop",
"SyncRevenueCatResponseTypedDict": ".syncrevenuecatop",
"SyncRevenueCatStatus": ".syncrevenuecatop",
"Deduction1": ".trackop",
"Deduction1TypedDict": ".trackop",
"Deduction2": ".trackop",

View File

@@ -890,7 +890,7 @@ class Properties2(BaseModel):
return m
class Product2TypedDict(TypedDict):
class CheckProduct2TypedDict(TypedDict):
id: str
r"""The ID of the product you set when creating the product"""
name: str
@@ -920,7 +920,7 @@ class Product2TypedDict(TypedDict):
properties: NotRequired[Properties2TypedDict]
class Product2(BaseModel):
class CheckProduct2(BaseModel):
id: str
r"""The ID of the product you set when creating the product"""
@@ -1001,7 +1001,7 @@ class Preview2TypedDict(TypedDict):
r"""The ID of the feature that was checked."""
feature_name: str
r"""The display name of the feature."""
products: List[Product2TypedDict]
products: List[CheckProduct2TypedDict]
r"""Products that would grant access to this feature. Use to display upgrade options."""
@@ -1023,7 +1023,7 @@ class Preview2(BaseModel):
feature_name: str
r"""The display name of the feature."""
products: List[Product2]
products: List[CheckProduct2]
r"""Products that would grant access to this feature. Use to display upgrade options."""
@@ -1832,7 +1832,7 @@ class Properties1(BaseModel):
return m
class Product1TypedDict(TypedDict):
class CheckProduct1TypedDict(TypedDict):
id: str
r"""The ID of the product you set when creating the product"""
name: str
@@ -1862,7 +1862,7 @@ class Product1TypedDict(TypedDict):
properties: NotRequired[Properties1TypedDict]
class Product1(BaseModel):
class CheckProduct1(BaseModel):
id: str
r"""The ID of the product you set when creating the product"""
@@ -1943,7 +1943,7 @@ class Preview1TypedDict(TypedDict):
r"""The ID of the feature that was checked."""
feature_name: str
r"""The display name of the feature."""
products: List[Product1TypedDict]
products: List[CheckProduct1TypedDict]
r"""Products that would grant access to this feature. Use to display upgrade options."""
@@ -1965,7 +1965,7 @@ class Preview1(BaseModel):
feature_name: str
r"""The display name of the feature."""
products: List[Product1]
products: List[CheckProduct1]
r"""Products that would grant access to this feature. Use to display upgrade options."""

View File

@@ -0,0 +1,179 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
from __future__ import annotations
from autumn_sdk.types import (
BaseModel,
Nullable,
OptionalNullable,
UNSET,
UNSET_SENTINEL,
)
from autumn_sdk.utils import FieldMetadata, HeaderMetadata
import pydantic
from pydantic import ConfigDict, model_serializer
from typing import Any, Dict, List, Literal, Optional
from typing_extensions import Annotated, NotRequired, TypedDict
class GetRevenueCatKeysGlobalsTypedDict(TypedDict):
x_api_version: NotRequired[str]
class GetRevenueCatKeysGlobals(BaseModel):
x_api_version: Annotated[
Optional[str],
pydantic.Field(alias="x-api-version"),
FieldMetadata(header=HeaderMetadata(style="simple", explode=False)),
] = "2.3.0"
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["x-api-version"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
if val is not None or k not in optional_fields:
m[k] = val
return m
GetRevenueCatKeysEnv = Literal[
"test",
"sandbox",
"live",
]
r"""\"test\" and \"sandbox\" both target the sandbox environment"""
class GetRevenueCatKeysParamsTypedDict(TypedDict):
organization_slug: str
env: GetRevenueCatKeysEnv
r"""\"test\" and \"sandbox\" both target the sandbox environment"""
class GetRevenueCatKeysParams(BaseModel):
organization_slug: str
env: GetRevenueCatKeysEnv
r"""\"test\" and \"sandbox\" both target the sandbox environment"""
class APIKeyTypedDict(TypedDict):
id: str
key: str
r"""The public SDK API key value"""
environment: NotRequired[Nullable[str]]
r"""e.g. \"production\" / \"sandbox\" """
app_id: NotRequired[Nullable[str]]
created_at: NotRequired[float]
class APIKey(BaseModel):
model_config = ConfigDict(
populate_by_name=True, arbitrary_types_allowed=True, extra="allow"
)
__pydantic_extra__: Dict[str, Any] = pydantic.Field(init=False)
id: str
key: str
r"""The public SDK API key value"""
environment: OptionalNullable[str] = UNSET
r"""e.g. \"production\" / \"sandbox\" """
app_id: OptionalNullable[str] = UNSET
created_at: Optional[float] = None
@property
def additional_properties(self):
return self.__pydantic_extra__
@additional_properties.setter
def additional_properties(self, value):
self.__pydantic_extra__ = value # pyright: ignore[reportIncompatibleVariableOverride]
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["environment", "app_id", "created_at"])
nullable_fields = set(["environment", "app_id"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
serialized.pop(k, serialized.pop(n, None))
is_nullable_and_explicitly_set = (
k in nullable_fields
and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member
)
if val != UNSET_SENTINEL:
if (
val is not None
or k not in optional_fields
or is_nullable_and_explicitly_set
):
m[k] = val
for k, v in serialized.items():
m[k] = v
return m
class GetRevenueCatKeysAppTypedDict(TypedDict):
app_id: str
app_type: str
r"""RevenueCat store type, e.g. test_store / app_store / play_store"""
name: str
api_keys: List[APIKeyTypedDict]
class GetRevenueCatKeysApp(BaseModel):
app_id: str
app_type: str
r"""RevenueCat store type, e.g. test_store / app_store / play_store"""
name: str
api_keys: List[APIKey]
class GetRevenueCatKeysResponseTypedDict(TypedDict):
r"""OK"""
apps: List[GetRevenueCatKeysAppTypedDict]
oauth_access_token: Nullable[str]
r"""Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token."""
class GetRevenueCatKeysResponse(BaseModel):
r"""OK"""
apps: List[GetRevenueCatKeysApp]
oauth_access_token: Nullable[str]
r"""Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token."""
@model_serializer(mode="wrap")
def serialize_model(self, handler):
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
m[k] = val
return m

View File

@@ -0,0 +1,72 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
from __future__ import annotations
from autumn_sdk.types import BaseModel, UNSET_SENTINEL
from autumn_sdk.utils import FieldMetadata, HeaderMetadata
import pydantic
from pydantic import model_serializer
from typing import Literal, Optional
from typing_extensions import Annotated, NotRequired, TypedDict
class LinkRevenueCatGlobalsTypedDict(TypedDict):
x_api_version: NotRequired[str]
class LinkRevenueCatGlobals(BaseModel):
x_api_version: Annotated[
Optional[str],
pydantic.Field(alias="x-api-version"),
FieldMetadata(header=HeaderMetadata(style="simple", explode=False)),
] = "2.3.0"
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["x-api-version"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
if val is not None or k not in optional_fields:
m[k] = val
return m
LinkRevenueCatEnv = Literal[
"test",
"live",
]
class LinkRevenueCatParamsTypedDict(TypedDict):
organization_slug: str
env: LinkRevenueCatEnv
project_name: str
redirect_url: str
class LinkRevenueCatParams(BaseModel):
organization_slug: str
env: LinkRevenueCatEnv
project_name: str
redirect_url: str
class LinkRevenueCatResponseTypedDict(TypedDict):
r"""OK"""
oauth_url: str
class LinkRevenueCatResponse(BaseModel):
r"""OK"""
oauth_url: str

View File

@@ -0,0 +1,206 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
from __future__ import annotations
from autumn_sdk.types import BaseModel, UNSET_SENTINEL, UnrecognizedStr
from autumn_sdk.utils import FieldMetadata, HeaderMetadata
import pydantic
from pydantic import model_serializer
from typing import List, Literal, Optional, Union
from typing_extensions import Annotated, NotRequired, TypedDict
class SyncRevenueCatGlobalsTypedDict(TypedDict):
x_api_version: NotRequired[str]
class SyncRevenueCatGlobals(BaseModel):
x_api_version: Annotated[
Optional[str],
pydantic.Field(alias="x-api-version"),
FieldMetadata(header=HeaderMetadata(style="simple", explode=False)),
] = "2.3.0"
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["x-api-version"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
if val is not None or k not in optional_fields:
m[k] = val
return m
SyncRevenueCatEnv = Literal[
"test",
"sandbox",
"live",
]
r"""\"test\" and \"sandbox\" both target the sandbox environment"""
class SyncRevenueCatParamsTypedDict(TypedDict):
organization_slug: str
env: SyncRevenueCatEnv
r"""\"test\" and \"sandbox\" both target the sandbox environment"""
product_ids: NotRequired[List[str]]
r"""Plans to push. Omit to sync every plan in the org/env."""
class SyncRevenueCatParams(BaseModel):
organization_slug: str
env: SyncRevenueCatEnv
r"""\"test\" and \"sandbox\" both target the sandbox environment"""
product_ids: Optional[List[str]] = None
r"""Plans to push. Omit to sync every plan in the org/env."""
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["product_ids"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
if val is not None or k not in optional_fields:
m[k] = val
return m
SyncRevenueCatStatus = Union[
Literal[
"synced",
"skipped",
"error",
],
UnrecognizedStr,
]
SyncRevenueCatProduct = Union[
Literal[
"created",
"updated",
"exists",
],
UnrecognizedStr,
]
StorePush = Union[
Literal[
"pushed",
"failed",
"skipped",
],
UnrecognizedStr,
]
SyncRevenueCatPrice = Union[
Literal[
"set",
"skipped",
"failed",
],
UnrecognizedStr,
]
class SyncRevenueCatAppTypedDict(TypedDict):
app_id: str
app_type: str
product: SyncRevenueCatProduct
store_push: NotRequired[StorePush]
price: NotRequired[SyncRevenueCatPrice]
message: NotRequired[str]
class SyncRevenueCatApp(BaseModel):
app_id: str
app_type: str
product: SyncRevenueCatProduct
store_push: Optional[StorePush] = None
price: Optional[SyncRevenueCatPrice] = None
message: Optional[str] = None
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["store_push", "price", "message"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
if val is not None or k not in optional_fields:
m[k] = val
return m
class ResultTypedDict(TypedDict):
plan_id: str
status: SyncRevenueCatStatus
store_identifier: NotRequired[str]
apps: NotRequired[List[SyncRevenueCatAppTypedDict]]
message: NotRequired[str]
class Result(BaseModel):
plan_id: str
status: SyncRevenueCatStatus
store_identifier: Optional[str] = None
apps: Optional[List[SyncRevenueCatApp]] = None
message: Optional[str] = None
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(["store_identifier", "apps", "message"])
serialized = handler(self)
m = {}
for n, f in type(self).model_fields.items():
k = f.alias or n
val = serialized.get(k, serialized.get(n))
if val != UNSET_SENTINEL:
if val is not None or k not in optional_fields:
m[k] = val
return m
class SyncRevenueCatResponseTypedDict(TypedDict):
r"""OK"""
results: List[ResultTypedDict]
class SyncRevenueCatResponse(BaseModel):
r"""OK"""
results: List[Result]

View File

@@ -0,0 +1,586 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
from .basesdk import BaseSDK
from autumn_sdk import errors, models, utils
from autumn_sdk._hooks import HookContext
from autumn_sdk.types import OptionalNullable, UNSET
from autumn_sdk.utils.unmarshal_json_response import unmarshal_json_response
from typing import List, Mapping, Optional
class Platform(BaseSDK):
def link_revenue_cat(
self,
*,
organization_slug: str,
env: models.LinkRevenueCatEnv,
project_name: str,
redirect_url: str,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
http_headers: Optional[Mapping[str, str]] = None,
) -> models.LinkRevenueCatResponse:
r"""Generate a RevenueCat OAuth URL for linking a project to an organization.
:param organization_slug:
:param env:
:param project_name:
:param redirect_url:
:param retries: Override the default retry configuration for this method
:param server_url: Override the default server URL for this method
:param timeout_ms: Override the default request timeout configuration for this method in milliseconds
:param http_headers: Additional headers to set or replace on requests.
"""
base_url = None
url_variables = None
if timeout_ms is None:
timeout_ms = self.sdk_configuration.timeout_ms
if server_url is not None:
base_url = server_url
else:
base_url = self._get_url(base_url, url_variables)
request = models.LinkRevenueCatParams(
organization_slug=organization_slug,
env=env,
project_name=project_name,
redirect_url=redirect_url,
)
req = self._build_request(
method="POST",
path="/v1/platform.link_revenuecat",
base_url=base_url,
url_variables=url_variables,
request=request,
request_body_required=True,
request_has_path_params=False,
request_has_query_params=True,
user_agent_header="user-agent",
accept_header_value="application/json",
http_headers=http_headers,
_globals=models.LinkRevenueCatGlobals(
x_api_version=self.sdk_configuration.globals.x_api_version,
),
security=self.sdk_configuration.security,
get_serialized_body=lambda: utils.serialize_request_body(
request, False, False, "json", models.LinkRevenueCatParams
),
allow_empty_value=None,
timeout_ms=timeout_ms,
)
if retries == UNSET:
if self.sdk_configuration.retry_config is not UNSET:
retries = self.sdk_configuration.retry_config
retry_config = None
if isinstance(retries, utils.RetryConfig):
retry_config = (retries, ["429", "500", "502", "503", "504"])
http_res = self.do_request(
hook_ctx=HookContext(
config=self.sdk_configuration,
base_url=base_url or "",
operation_id="linkRevenueCat",
oauth2_scopes=None,
security_source=self.sdk_configuration.security,
),
request=req,
is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
retry_config=retry_config,
)
if utils.match_response(http_res, "200", "application/json"):
return unmarshal_json_response(models.LinkRevenueCatResponse, http_res)
if utils.match_response(http_res, "4XX", "*"):
http_res_text = utils.stream_to_text(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
if utils.match_response(http_res, "5XX", "*"):
http_res_text = utils.stream_to_text(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
raise errors.AutumnDefaultError("Unexpected response received", http_res)
async def link_revenue_cat_async(
self,
*,
organization_slug: str,
env: models.LinkRevenueCatEnv,
project_name: str,
redirect_url: str,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
http_headers: Optional[Mapping[str, str]] = None,
) -> models.LinkRevenueCatResponse:
r"""Generate a RevenueCat OAuth URL for linking a project to an organization.
:param organization_slug:
:param env:
:param project_name:
:param redirect_url:
:param retries: Override the default retry configuration for this method
:param server_url: Override the default server URL for this method
:param timeout_ms: Override the default request timeout configuration for this method in milliseconds
:param http_headers: Additional headers to set or replace on requests.
"""
base_url = None
url_variables = None
if timeout_ms is None:
timeout_ms = self.sdk_configuration.timeout_ms
if server_url is not None:
base_url = server_url
else:
base_url = self._get_url(base_url, url_variables)
request = models.LinkRevenueCatParams(
organization_slug=organization_slug,
env=env,
project_name=project_name,
redirect_url=redirect_url,
)
req = self._build_request_async(
method="POST",
path="/v1/platform.link_revenuecat",
base_url=base_url,
url_variables=url_variables,
request=request,
request_body_required=True,
request_has_path_params=False,
request_has_query_params=True,
user_agent_header="user-agent",
accept_header_value="application/json",
http_headers=http_headers,
_globals=models.LinkRevenueCatGlobals(
x_api_version=self.sdk_configuration.globals.x_api_version,
),
security=self.sdk_configuration.security,
get_serialized_body=lambda: utils.serialize_request_body(
request, False, False, "json", models.LinkRevenueCatParams
),
allow_empty_value=None,
timeout_ms=timeout_ms,
)
if retries == UNSET:
if self.sdk_configuration.retry_config is not UNSET:
retries = self.sdk_configuration.retry_config
retry_config = None
if isinstance(retries, utils.RetryConfig):
retry_config = (retries, ["429", "500", "502", "503", "504"])
http_res = await self.do_request_async(
hook_ctx=HookContext(
config=self.sdk_configuration,
base_url=base_url or "",
operation_id="linkRevenueCat",
oauth2_scopes=None,
security_source=self.sdk_configuration.security,
),
request=req,
is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
retry_config=retry_config,
)
if utils.match_response(http_res, "200", "application/json"):
return unmarshal_json_response(models.LinkRevenueCatResponse, http_res)
if utils.match_response(http_res, "4XX", "*"):
http_res_text = await utils.stream_to_text_async(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
if utils.match_response(http_res, "5XX", "*"):
http_res_text = await utils.stream_to_text_async(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
raise errors.AutumnDefaultError("Unexpected response received", http_res)
def sync_revenue_cat(
self,
*,
organization_slug: str,
env: models.SyncRevenueCatEnv,
product_ids: Optional[List[str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
http_headers: Optional[Mapping[str, str]] = None,
) -> models.SyncRevenueCatResponse:
r"""Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
:param organization_slug:
:param env: \"test\" and \"sandbox\" both target the sandbox environment
:param product_ids: Plans to push. Omit to sync every plan in the org/env.
:param retries: Override the default retry configuration for this method
:param server_url: Override the default server URL for this method
:param timeout_ms: Override the default request timeout configuration for this method in milliseconds
:param http_headers: Additional headers to set or replace on requests.
"""
base_url = None
url_variables = None
if timeout_ms is None:
timeout_ms = self.sdk_configuration.timeout_ms
if server_url is not None:
base_url = server_url
else:
base_url = self._get_url(base_url, url_variables)
request = models.SyncRevenueCatParams(
organization_slug=organization_slug,
env=env,
product_ids=product_ids,
)
req = self._build_request(
method="POST",
path="/v1/platform.sync_revenuecat",
base_url=base_url,
url_variables=url_variables,
request=request,
request_body_required=True,
request_has_path_params=False,
request_has_query_params=True,
user_agent_header="user-agent",
accept_header_value="application/json",
http_headers=http_headers,
_globals=models.SyncRevenueCatGlobals(
x_api_version=self.sdk_configuration.globals.x_api_version,
),
security=self.sdk_configuration.security,
get_serialized_body=lambda: utils.serialize_request_body(
request, False, False, "json", models.SyncRevenueCatParams
),
allow_empty_value=None,
timeout_ms=timeout_ms,
)
if retries == UNSET:
if self.sdk_configuration.retry_config is not UNSET:
retries = self.sdk_configuration.retry_config
retry_config = None
if isinstance(retries, utils.RetryConfig):
retry_config = (retries, ["429", "500", "502", "503", "504"])
http_res = self.do_request(
hook_ctx=HookContext(
config=self.sdk_configuration,
base_url=base_url or "",
operation_id="syncRevenueCat",
oauth2_scopes=None,
security_source=self.sdk_configuration.security,
),
request=req,
is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
retry_config=retry_config,
)
if utils.match_response(http_res, "200", "application/json"):
return unmarshal_json_response(models.SyncRevenueCatResponse, http_res)
if utils.match_response(http_res, "4XX", "*"):
http_res_text = utils.stream_to_text(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
if utils.match_response(http_res, "5XX", "*"):
http_res_text = utils.stream_to_text(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
raise errors.AutumnDefaultError("Unexpected response received", http_res)
async def sync_revenue_cat_async(
self,
*,
organization_slug: str,
env: models.SyncRevenueCatEnv,
product_ids: Optional[List[str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
http_headers: Optional[Mapping[str, str]] = None,
) -> models.SyncRevenueCatResponse:
r"""Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
:param organization_slug:
:param env: \"test\" and \"sandbox\" both target the sandbox environment
:param product_ids: Plans to push. Omit to sync every plan in the org/env.
:param retries: Override the default retry configuration for this method
:param server_url: Override the default server URL for this method
:param timeout_ms: Override the default request timeout configuration for this method in milliseconds
:param http_headers: Additional headers to set or replace on requests.
"""
base_url = None
url_variables = None
if timeout_ms is None:
timeout_ms = self.sdk_configuration.timeout_ms
if server_url is not None:
base_url = server_url
else:
base_url = self._get_url(base_url, url_variables)
request = models.SyncRevenueCatParams(
organization_slug=organization_slug,
env=env,
product_ids=product_ids,
)
req = self._build_request_async(
method="POST",
path="/v1/platform.sync_revenuecat",
base_url=base_url,
url_variables=url_variables,
request=request,
request_body_required=True,
request_has_path_params=False,
request_has_query_params=True,
user_agent_header="user-agent",
accept_header_value="application/json",
http_headers=http_headers,
_globals=models.SyncRevenueCatGlobals(
x_api_version=self.sdk_configuration.globals.x_api_version,
),
security=self.sdk_configuration.security,
get_serialized_body=lambda: utils.serialize_request_body(
request, False, False, "json", models.SyncRevenueCatParams
),
allow_empty_value=None,
timeout_ms=timeout_ms,
)
if retries == UNSET:
if self.sdk_configuration.retry_config is not UNSET:
retries = self.sdk_configuration.retry_config
retry_config = None
if isinstance(retries, utils.RetryConfig):
retry_config = (retries, ["429", "500", "502", "503", "504"])
http_res = await self.do_request_async(
hook_ctx=HookContext(
config=self.sdk_configuration,
base_url=base_url or "",
operation_id="syncRevenueCat",
oauth2_scopes=None,
security_source=self.sdk_configuration.security,
),
request=req,
is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
retry_config=retry_config,
)
if utils.match_response(http_res, "200", "application/json"):
return unmarshal_json_response(models.SyncRevenueCatResponse, http_res)
if utils.match_response(http_res, "4XX", "*"):
http_res_text = await utils.stream_to_text_async(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
if utils.match_response(http_res, "5XX", "*"):
http_res_text = await utils.stream_to_text_async(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
raise errors.AutumnDefaultError("Unexpected response received", http_res)
def get_revenue_cat_keys(
self,
*,
organization_slug: str,
env: models.GetRevenueCatKeysEnv,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
http_headers: Optional[Mapping[str, str]] = None,
) -> models.GetRevenueCatKeysResponse:
r"""Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
:param organization_slug:
:param env: \"test\" and \"sandbox\" both target the sandbox environment
:param retries: Override the default retry configuration for this method
:param server_url: Override the default server URL for this method
:param timeout_ms: Override the default request timeout configuration for this method in milliseconds
:param http_headers: Additional headers to set or replace on requests.
"""
base_url = None
url_variables = None
if timeout_ms is None:
timeout_ms = self.sdk_configuration.timeout_ms
if server_url is not None:
base_url = server_url
else:
base_url = self._get_url(base_url, url_variables)
request = models.GetRevenueCatKeysParams(
organization_slug=organization_slug,
env=env,
)
req = self._build_request(
method="POST",
path="/v1/platform.get_revenuecat_keys",
base_url=base_url,
url_variables=url_variables,
request=request,
request_body_required=True,
request_has_path_params=False,
request_has_query_params=True,
user_agent_header="user-agent",
accept_header_value="application/json",
http_headers=http_headers,
_globals=models.GetRevenueCatKeysGlobals(
x_api_version=self.sdk_configuration.globals.x_api_version,
),
security=self.sdk_configuration.security,
get_serialized_body=lambda: utils.serialize_request_body(
request, False, False, "json", models.GetRevenueCatKeysParams
),
allow_empty_value=None,
timeout_ms=timeout_ms,
)
if retries == UNSET:
if self.sdk_configuration.retry_config is not UNSET:
retries = self.sdk_configuration.retry_config
retry_config = None
if isinstance(retries, utils.RetryConfig):
retry_config = (retries, ["429", "500", "502", "503", "504"])
http_res = self.do_request(
hook_ctx=HookContext(
config=self.sdk_configuration,
base_url=base_url or "",
operation_id="getRevenueCatKeys",
oauth2_scopes=None,
security_source=self.sdk_configuration.security,
),
request=req,
is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
retry_config=retry_config,
)
if utils.match_response(http_res, "200", "application/json"):
return unmarshal_json_response(models.GetRevenueCatKeysResponse, http_res)
if utils.match_response(http_res, "4XX", "*"):
http_res_text = utils.stream_to_text(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
if utils.match_response(http_res, "5XX", "*"):
http_res_text = utils.stream_to_text(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
raise errors.AutumnDefaultError("Unexpected response received", http_res)
async def get_revenue_cat_keys_async(
self,
*,
organization_slug: str,
env: models.GetRevenueCatKeysEnv,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
http_headers: Optional[Mapping[str, str]] = None,
) -> models.GetRevenueCatKeysResponse:
r"""Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
:param organization_slug:
:param env: \"test\" and \"sandbox\" both target the sandbox environment
:param retries: Override the default retry configuration for this method
:param server_url: Override the default server URL for this method
:param timeout_ms: Override the default request timeout configuration for this method in milliseconds
:param http_headers: Additional headers to set or replace on requests.
"""
base_url = None
url_variables = None
if timeout_ms is None:
timeout_ms = self.sdk_configuration.timeout_ms
if server_url is not None:
base_url = server_url
else:
base_url = self._get_url(base_url, url_variables)
request = models.GetRevenueCatKeysParams(
organization_slug=organization_slug,
env=env,
)
req = self._build_request_async(
method="POST",
path="/v1/platform.get_revenuecat_keys",
base_url=base_url,
url_variables=url_variables,
request=request,
request_body_required=True,
request_has_path_params=False,
request_has_query_params=True,
user_agent_header="user-agent",
accept_header_value="application/json",
http_headers=http_headers,
_globals=models.GetRevenueCatKeysGlobals(
x_api_version=self.sdk_configuration.globals.x_api_version,
),
security=self.sdk_configuration.security,
get_serialized_body=lambda: utils.serialize_request_body(
request, False, False, "json", models.GetRevenueCatKeysParams
),
allow_empty_value=None,
timeout_ms=timeout_ms,
)
if retries == UNSET:
if self.sdk_configuration.retry_config is not UNSET:
retries = self.sdk_configuration.retry_config
retry_config = None
if isinstance(retries, utils.RetryConfig):
retry_config = (retries, ["429", "500", "502", "503", "504"])
http_res = await self.do_request_async(
hook_ctx=HookContext(
config=self.sdk_configuration,
base_url=base_url or "",
operation_id="getRevenueCatKeys",
oauth2_scopes=None,
security_source=self.sdk_configuration.security,
),
request=req,
is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
retry_config=retry_config,
)
if utils.match_response(http_res, "200", "application/json"):
return unmarshal_json_response(models.GetRevenueCatKeysResponse, http_res)
if utils.match_response(http_res, "4XX", "*"):
http_res_text = await utils.stream_to_text_async(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
if utils.match_response(http_res, "5XX", "*"):
http_res_text = await utils.stream_to_text_async(http_res)
raise errors.AutumnDefaultError(
"API error occurred", http_res, http_res_text
)
raise errors.AutumnDefaultError("Unexpected response received", http_res)

View File

@@ -34,6 +34,7 @@ if TYPE_CHECKING:
from autumn_sdk.events import Events
from autumn_sdk.features import Features
from autumn_sdk.plans import Plans
from autumn_sdk.platform import Platform
from autumn_sdk.referrals import Referrals
from autumn_sdk.rewards_sdk import RewardsSDK
@@ -48,6 +49,7 @@ class Autumn(BaseSDK):
entities: "Entities"
referrals: "Referrals"
rewards: "RewardsSDK"
platform: "Platform"
_sub_sdk_map = {
"customers": ("autumn_sdk.customers", "Customers"),
"plans": ("autumn_sdk.plans", "Plans"),
@@ -58,6 +60,7 @@ class Autumn(BaseSDK):
"entities": ("autumn_sdk.entities", "Entities"),
"referrals": ("autumn_sdk.referrals", "Referrals"),
"rewards": ("autumn_sdk.rewards_sdk", "RewardsSDK"),
"platform": ("autumn_sdk.platform", "Platform"),
}
def __init__(

View File

@@ -15,6 +15,8 @@
"apps/sdk-test",
"packages/atmn",
"packages/atmn-tests",
"packages/auth",
"packages/logging",
"packages/mcp",
"packages/sdk",
"packages/autumn-js",
@@ -113,6 +115,9 @@
"tb": "bun scripts/tinybird/index.ts",
"tb:prod": "bun scripts/tinybird/index.ts prod",
"tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy",
"axiom": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/axiom/cli.ts",
"axiom:prod": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/axiom/cli.ts",
"add-mcp": "bun scripts/mcp/addMcp.ts",
"trigger:deploy": "bunx trigger.dev deploy",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",
@@ -133,7 +138,7 @@
"site": "cd apps/website && bun dev && cd ../..",
"docs": "bun -F @autumn/docs dev",
"docs:build": "bun -F @autumn/docs build",
"ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/mcp --filter=@autumn/leaf",
"ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf",
"kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done",
"atmn:build": "bun -F atmn build",
"openapi:ts": "bun -F @autumn/openapi ts",

View File

@@ -1,9 +1,10 @@
// OAuth constants for CLI authentication
/** The OAuth client ID for the CLI (public client) */
// export const CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN"; (local i think)
// export const CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk" (dev i think)
export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr"; // (prod i think)
// Historical Better Auth OAuth clients for atmn CLI environments.
// Server auth should identify atmn from oauth_client metadata/name instead.
export const LOCAL_CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN";
export const DEV_CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk";
export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr";
/** Base port for the local OAuth callback server */
export const OAUTH_PORT_BASE = 31448;

View File

@@ -0,0 +1,36 @@
{
"name": "@autumn/auth",
"version": "0.0.1",
"author": "Autumn",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"./utils": {
"types": "./src/utils/index.ts",
"import": "./src/utils/index.ts",
"default": "./src/utils/index.ts"
},
"./oauth": {
"types": "./src/oauth/index.ts",
"import": "./src/oauth/index.ts",
"default": "./src/oauth/index.ts"
}
},
"files": ["src"],
"scripts": {
"build": "tsc",
"ts": "tsc --noEmit",
"prepack": "bun run build",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3"
}
}

View File

@@ -0,0 +1,2 @@
export * from "./oauth/index.js";
export * from "./utils/index.js";

View File

@@ -0,0 +1 @@
export * from "./oauthUrls.js";

View File

@@ -0,0 +1,32 @@
const trimTrailingSlash = (url: string) =>
url.endsWith("/") ? url.slice(0, -1) : url;
export const getOAuthIssuerUrl = ({
authPath = "/api/auth",
baseUrl,
}: {
authPath?: string;
baseUrl: string;
}): string => trimTrailingSlash(new URL(authPath, baseUrl).href);
export const getProtectedResourceMetadataUrl = ({
resourceUrl,
}: {
resourceUrl: string;
}): string => {
const url = new URL(resourceUrl);
const path = url.pathname === "/" ? "" : url.pathname;
return new URL(`/.well-known/oauth-protected-resource${path}`, url).href;
};
export const getWwwAuthenticateHeader = ({
error,
resourceMetadataUrl,
}: {
error?: string;
resourceMetadataUrl: string;
}): string => {
const params = [`resource_metadata="${resourceMetadataUrl}"`];
if (error) params.push(`error="${error}"`);
return `Bearer ${params.join(", ")}`;
};

View File

@@ -0,0 +1,23 @@
const AUTUMN_SECRET_KEY_PREFIX = "am_sk";
const AUTUMN_PUBLISHABLE_KEY_PREFIX = "am_pk";
const AUTUMN_OAUTH_TOKEN_PREFIX = "am_oauth_";
export const isSecretKeyPrefix = ({ token }: { token: string }) =>
token.startsWith(AUTUMN_SECRET_KEY_PREFIX);
export const isPublishableKeyPrefix = ({ token }: { token: string }) =>
token.startsWith(AUTUMN_PUBLISHABLE_KEY_PREFIX);
export const isAutumnApiKey = ({ token }: { token: string }) =>
isSecretKeyPrefix({ token }) || isPublishableKeyPrefix({ token });
export const isOAuthToken = ({ token }: { token: string }) =>
token.startsWith(AUTUMN_OAUTH_TOKEN_PREFIX);
export const prefixOAuthToken = ({ token }: { token: string }) =>
isOAuthToken({ token }) ? token : `${AUTUMN_OAUTH_TOKEN_PREFIX}${token}`;
export const stripOAuthTokenPrefix = ({ token }: { token: string }) =>
isOAuthToken({ token })
? token.slice(AUTUMN_OAUTH_TOKEN_PREFIX.length)
: token;

View File

@@ -0,0 +1,13 @@
const BEARER_PREFIX = "Bearer ";
export const getBearerToken = ({
headers,
}: {
headers: Headers;
}): string | undefined => {
const authorization = headers.get("authorization");
if (!authorization?.startsWith(BEARER_PREFIX)) return undefined;
const token = authorization.slice(BEARER_PREFIX.length).trim();
return token.length ? token : undefined;
};

View File

@@ -0,0 +1,2 @@
export * from "./authTokenUtils.js";
export * from "./getBearerToken.js";

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"allowJs": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"incremental": false,
"isolatedModules": true,
"lib": ["dom", "dom.iterable", "es2024"],
"module": "Preserve",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": false,
"noImplicitReturns": false,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"types": ["bun", "node"],
"useUnknownInCatchVariables": true
},
"exclude": ["node_modules"],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,30 @@
{
"name": "@autumn/logging",
"version": "0.0.1",
"author": "Autumn",
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"files": [
"README.md",
"src"
],
"scripts": {
"build": "tsc",
"ts": "tsc --noEmit",
"test": "bun test tests/unit",
"prepack": "bun run build",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@axiomhq/pino": "^1.3.1",
"pino": "^9.6.0"
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3"
}
}

View File

@@ -0,0 +1,38 @@
import type { AutumnLogger } from "../types.js";
import type {
LogAppContext,
LogRequestContext,
LogTriggerContext,
} from "./types.js";
export const addRequestToLogs = ({
logger,
requestContext,
}: {
logger: AutumnLogger;
requestContext: LogRequestContext;
}): AutumnLogger => logger.child({ context: { req: requestContext } });
export const addAppContextToLogs = ({
logger,
appContext,
}: {
logger: AutumnLogger;
appContext: LogAppContext;
}): AutumnLogger => logger.child({ context: { context: appContext } });
export const addTriggerToLogs = ({
logger,
triggerContext,
}: {
logger: AutumnLogger;
triggerContext: LogTriggerContext;
}): AutumnLogger => logger.child({ context: { trigger: triggerContext } });
export const addExtrasToLogs = ({
logger,
extras,
}: {
logger: AutumnLogger;
extras: Record<string, unknown>;
}): AutumnLogger => logger.child({ context: { extras } });

View File

@@ -0,0 +1,35 @@
export type LogRequestContext = {
id: string;
method: string;
url: string;
timestamp: number;
customer_id?: string;
entity_id?: string;
user_agent?: string;
ip_address?: string;
region?: string;
query: Record<string, string>;
body: unknown;
name: string;
};
export type LogAppContext = {
org_id?: string;
org_slug?: string;
env?: string;
auth_type?: string;
customer_id?: string;
entity_id?: string;
user_id?: string;
user_email?: string;
api_version?: string;
scopes?: string[];
full_subject_bucket?: number;
full_subject_rollout_enabled?: boolean;
};
export type LogTriggerContext = {
run_id: string;
task_id: string;
attempt_number?: number;
};

View File

@@ -0,0 +1,21 @@
import { createHash } from "node:crypto";
const stableStringify = ({ value }: { value: unknown }): string => {
if (!value || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value))
return `[${value.map((item) => stableStringify({ value: item })).join(",")}]`;
return `{${Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([key, item]) =>
`${JSON.stringify(key)}:${stableStringify({ value: item })}`,
)
.join(",")}}`;
};
export const createSessionId = ({ parts }: { parts: unknown }): string =>
createHash("sha256")
.update(stableStringify({ value: parts }))
.digest("hex")
.slice(0, 24);

View File

@@ -0,0 +1,3 @@
import { randomUUID } from "node:crypto";
export const createTraceId = (): string => randomUUID();

View File

@@ -0,0 +1,40 @@
export {
addAppContextToLogs,
addExtrasToLogs,
addRequestToLogs,
addTriggerToLogs,
} from "./context/addContextToLogs.js";
export type {
LogAppContext,
LogRequestContext,
LogTriggerContext,
} from "./context/types.js";
export { createSessionId } from "./ids/createSessionId.js";
export { createTraceId } from "./ids/createTraceId.js";
export {
createAppLogger,
createAutumnLogger,
} from "./logger/autumnLogger.js";
export { createConsoleLogger } from "./logger/consoleLogger.js";
export { createLogger } from "./logger/createLogger.js";
export {
mirrorLogger,
withLogPrefix,
} from "./logger/loggerWrappers.js";
export { resolveLoggerOptions } from "./logger/resolveLoggerOptions.js";
export { asAxiomMap } from "./payload/asAxiomMap.js";
export {
type GuardLogPayloadOptions,
guardLogPayload,
} from "./payload/guardLogPayload.js";
export type {
AutumnLogger,
ConsoleLogger,
ConsoleLoggerLevel,
CreateLoggerParams,
LoggerLevel,
LoggerOutput,
LoggerPreset,
PinoLogger,
ResolvedLoggerOptions,
} from "./types.js";

View File

@@ -0,0 +1,69 @@
import type pino from "pino";
import type {
AutumnLogger,
ConsoleLoggerLevel,
CreateLoggerParams,
LogArgs,
} from "../types.js";
import { createLogger } from "./createLogger.js";
const rewriteAppPath = (value: string): string =>
value.replace("file:///app/", "./").replace(/\/app\//g, "./");
const errorToObject = (error: Error) => ({
name: error.name,
message: error.message,
stack: error.stack ? rewriteAppPath(error.stack) : undefined,
});
const normalizeLogArgs = ({ args }: { args: LogArgs }) => {
const strings = args
.filter((arg): arg is string => typeof arg === "string")
.map(rewriteAppPath);
const objects = args
.filter(
(arg) => typeof arg !== "string" && arg !== null && arg !== undefined,
)
.map((arg) => (arg instanceof Error ? { error: errorToObject(arg) } : arg));
const error = args.find((arg): arg is Error => arg instanceof Error);
const message =
strings.at(-1) ??
(error
? rewriteAppPath(error.stack || error.message || "Error occurred")
: "");
return {
message,
merged: Object.assign({}, ...objects) as Record<string, unknown>,
};
};
const createLogMethod =
({ method }: { method: pino.LogFn }) =>
(...args: LogArgs) => {
const { message, merged } = normalizeLogArgs({ args });
if (Object.keys(merged).length > 0) method(merged, message);
else method(message);
};
export const createAutumnLogger = ({
logger,
}: {
logger: pino.Logger;
}): AutumnLogger => ({
level: logger.level as ConsoleLoggerLevel,
debug: createLogMethod({ method: logger.debug.bind(logger) }),
info: createLogMethod({ method: logger.info.bind(logger) }),
warn: createLogMethod({ method: logger.warn.bind(logger) }),
warning: createLogMethod({ method: logger.warn.bind(logger) }),
error: createLogMethod({ method: logger.error.bind(logger) }),
child: ({ context, onlyProd = false }) => {
if (onlyProd && process.env.NODE_ENV !== "production") {
return createAutumnLogger({ logger });
}
return createAutumnLogger({ logger: logger.child(context) });
},
});
export const createAppLogger = (params: CreateLoggerParams): AutumnLogger =>
createAutumnLogger({ logger: createLogger(params) });

View File

@@ -0,0 +1,28 @@
import type { ConsoleLogger, ConsoleLoggerLevel, LogArgs } from "../types.js";
export const createConsoleLogger = ({
level,
}: {
level: ConsoleLoggerLevel;
}): ConsoleLogger => {
const levels: ConsoleLoggerLevel[] = ["debug", "info", "warning", "error"];
const min = levels.indexOf(level);
const noop = () => {};
const log =
({ method }: { method: "debug" | "info" | "warn" | "error" }) =>
(...args: LogArgs) => {
console[method](...args);
};
const logger: ConsoleLogger = {
level,
debug: min <= 0 ? log({ method: "debug" }) : noop,
info: min <= 1 ? log({ method: "info" }) : noop,
warn: min <= 2 ? log({ method: "warn" }) : noop,
warning: min <= 2 ? log({ method: "warn" }) : noop,
error: min <= 3 ? log({ method: "error" }) : noop,
child: () => logger,
};
return logger;
};

View File

@@ -0,0 +1,60 @@
import pino from "pino";
import { createConsoleJsonStream } from "../streams/consoleJsonStream.js";
import { createPrettyLogStream } from "../streams/prettyLogStream.js";
import type { CreateLoggerParams } from "../types.js";
import { resolveLoggerOptions } from "./resolveLoggerOptions.js";
export const createLogger = (params: CreateLoggerParams): pino.Logger => {
const resolved = resolveLoggerOptions({ options: params });
const axiomToken = params.axiomToken ?? process.env.AXIOM_TOKEN;
const axiomOrgId = params.axiomOrgId ?? process.env.AXIOM_ORG_ID;
const streams: pino.StreamEntry[] = [];
for (const output of resolved.outputs) {
if (output === "console-pretty") {
streams.push({
level: resolved.level,
stream: createPrettyLogStream({
trailingNewline: resolved.preset !== "dual",
useConsoleLog: params.useConsoleLog ?? resolved.preset === "dual",
}),
});
}
if (output === "console-json") {
streams.push({
level: resolved.level,
stream: createConsoleJsonStream(),
});
}
if (output === "axiom" && axiomToken) {
streams.push({
level: resolved.level,
stream: pino.transport({
target: "@axiomhq/pino",
options: {
dataset: resolved.dataset,
token: axiomToken,
orgId: axiomOrgId,
},
}),
});
}
}
return pino(
{
level: resolved.level,
base: {
service: resolved.service,
...(params.context ?? {}),
},
mixin: params.mixin,
formatters: {
level: (label: string) => ({ level: label.toUpperCase() }),
},
},
pino.multistream(streams),
);
};

View File

@@ -0,0 +1,71 @@
import type { AutumnLogger, LogArgs } from "../types.js";
const logToStdout = ({
level,
args,
}: {
level: "debug" | "info" | "warn" | "error";
args: LogArgs;
}) => {
const method =
level === "debug"
? console.debug
: level === "info"
? console.info
: level === "warn"
? console.warn
: console.error;
method(...args);
};
export const mirrorLogger = ({
logger,
}: {
logger: AutumnLogger;
}): AutumnLogger => ({
debug: (...args) => {
logger.debug(...args);
logToStdout({ level: "debug", args });
},
info: (...args) => {
logger.info(...args);
logToStdout({ level: "info", args });
},
warn: (...args) => {
logger.warn(...args);
logToStdout({ level: "warn", args });
},
warning: (...args) => {
logger.warn(...args);
logToStdout({ level: "warn", args });
},
error: (...args) => {
logger.error(...args);
logToStdout({ level: "error", args });
},
child: (params) => mirrorLogger({ logger: logger.child(params) }),
});
const prefixArgs = ({ prefix, args }: { prefix: string; args: LogArgs }) => {
if (typeof args[0] !== "string") return [prefix, ...args];
if (args[0].startsWith(prefix)) return args;
return [`${prefix} ${args[0]}`, ...args.slice(1)];
};
export const withLogPrefix = ({
logger,
label,
}: {
logger: AutumnLogger;
label: string;
}): AutumnLogger => {
const prefix = `[${label}]`;
return {
debug: (...args) => logger.debug(...prefixArgs({ prefix, args })),
info: (...args) => logger.info(...prefixArgs({ prefix, args })),
warn: (...args) => logger.warn(...prefixArgs({ prefix, args })),
warning: (...args) => logger.warn(...prefixArgs({ prefix, args })),
error: (...args) => logger.error(...prefixArgs({ prefix, args })),
child: (params) => withLogPrefix({ logger: logger.child(params), label }),
};
};

View File

@@ -0,0 +1,67 @@
import type {
CreateLoggerParams,
LoggerLevel,
LoggerOutput,
ResolvedLoggerOptions,
} from "../types.js";
const parseOutputs = (
value: string | undefined,
): LoggerOutput[] | undefined => {
if (!value) return undefined;
const outputs = value
.split(",")
.map((part) => part.trim())
.filter(Boolean);
if (
outputs.every(
(output): output is LoggerOutput =>
output === "console-pretty" ||
output === "console-json" ||
output === "axiom",
)
) {
return outputs;
}
return undefined;
};
export const resolveLoggerOptions = ({
options,
env = process.env,
}: {
options: CreateLoggerParams;
env?: NodeJS.ProcessEnv;
}): ResolvedLoggerOptions => {
const preset = options.preset ?? "default";
const isDevOrTest = env.NODE_ENV === "development" || env.NODE_ENV === "test";
const hasAxiomToken = Boolean(options.axiomToken ?? env.AXIOM_TOKEN);
let outputs = options.outputs ?? parseOutputs(env.LOG_OUTPUTS);
if (!outputs) {
if (preset === "console-only") outputs = ["console-pretty"];
else if (preset === "axiom-only") outputs = ["axiom"];
else if (preset === "dual")
outputs = [isDevOrTest ? "console-pretty" : "console-json", "axiom"];
else if (isDevOrTest) outputs = ["console-pretty", "axiom"];
else outputs = ["axiom"];
}
const filteredOutputs = outputs.filter(
(output) => output !== "axiom" || hasAxiomToken,
);
return {
service: options.service,
dataset: options.dataset ?? options.service,
preset,
level:
options.level ??
((env.LOG_LEVEL as LoggerLevel | undefined) ||
(isDevOrTest || preset === "dual" ? "debug" : "info")),
outputs: filteredOutputs.length > 0 ? filteredOutputs : ["console-pretty"],
hasAxiomToken,
};
};

View File

@@ -0,0 +1,8 @@
export const asAxiomMap = ({
value,
}: {
value: unknown;
}): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: { value };

View File

@@ -0,0 +1,147 @@
const defaultMaxPayloadBytes = 512_000;
const defaultTruncateAboveBytes = 4_000;
const defaultMaxArrayItems = 5;
const defaultMaxStringLength = 500;
const defaultMaxDepth = 6;
export type GuardLogPayloadOptions = {
maxPayloadBytes?: number;
truncateAboveBytes?: number;
maxArrayItems?: number;
maxStringLength?: number;
maxDepth?: number;
};
const envNumber = ({
value,
fallback,
}: {
value?: string;
fallback: number;
}) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const resolveOptions = ({
options = {},
}: {
options?: GuardLogPayloadOptions;
}) => ({
maxPayloadBytes:
options.maxPayloadBytes ??
envNumber({
value: process.env.LOG_MAX_PAYLOAD_BYTES,
fallback: defaultMaxPayloadBytes,
}),
truncateAboveBytes:
options.truncateAboveBytes ??
envNumber({
value: process.env.LOG_TRUNCATE_ABOVE_BYTES,
fallback: defaultTruncateAboveBytes,
}),
maxArrayItems:
options.maxArrayItems ??
envNumber({
value: process.env.LOG_MAX_ARRAY_ITEMS,
fallback: defaultMaxArrayItems,
}),
maxStringLength:
options.maxStringLength ??
envNumber({
value: process.env.LOG_MAX_STRING_LENGTH,
fallback: defaultMaxStringLength,
}),
maxDepth: options.maxDepth ?? defaultMaxDepth,
});
type ResolvedGuardOptions = ReturnType<typeof resolveOptions>;
const truncateString = ({
value,
maxStringLength,
}: {
value: string;
maxStringLength: number;
}): string =>
value.length > maxStringLength
? `${value.slice(0, maxStringLength)}...[+${value.length - maxStringLength} chars]`
: value;
const truncateValue = ({
value,
options,
depth = 0,
}: {
value: unknown;
options: ResolvedGuardOptions;
depth?: number;
}): unknown => {
if (typeof value === "string")
return truncateString({
value,
maxStringLength: options.maxStringLength,
});
if (!value || typeof value !== "object") return value;
if (depth >= options.maxDepth) {
if (Array.isArray(value)) return `...[${value.length} items]`;
return "...[object]";
}
if (Array.isArray(value)) {
const kept = value.slice(0, options.maxArrayItems).map((item) =>
truncateValue({
value: item,
options,
depth: depth + 1,
}),
);
if (value.length > options.maxArrayItems) {
kept.push(`...[+${value.length - options.maxArrayItems} more items]`);
}
return kept;
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = truncateValue({
value: item,
options,
depth: depth + 1,
});
}
return out;
};
export const guardLogPayload = ({
value,
options: guardOptions,
}: {
value: unknown;
options?: GuardLogPayloadOptions;
}): unknown => {
if (value === undefined) return undefined;
const options = resolveOptions({ options: guardOptions });
try {
const json = JSON.stringify(value);
if (!json || json.length <= options.truncateAboveBytes) return value;
const truncated = truncateValue({ value, options });
const truncatedJson = JSON.stringify(truncated);
if (truncatedJson && truncatedJson.length > options.maxPayloadBytes) {
return { _truncated: true, _bytes: truncatedJson.length };
}
return truncated;
} catch {
return { _unserializable: true };
}
};

View File

@@ -0,0 +1,9 @@
import { Writable } from "node:stream";
export const createConsoleJsonStream = () =>
new Writable({
write(chunk, _encoding, callback) {
console.log(chunk.toString().trimEnd());
callback();
},
});

View File

@@ -0,0 +1,117 @@
import { Writable } from "node:stream";
const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
"time",
"level",
"msg",
"pid",
"hostname",
"req",
"res",
"statusCode",
"body",
"query",
"durationMs",
"duration_ms",
"event",
"context",
"workflow",
"trigger",
"stripe_event",
"vercel_event",
"worker",
"extras",
"type",
"data",
"aws",
"service",
]);
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
white: "\x1b[37m",
gray: "\x1b[90m",
bgRed: "\x1b[41m",
};
const levelColors: Record<number | string, string> = {
10: colors.gray,
20: colors.blue,
30: colors.green,
40: colors.yellow,
50: colors.red,
60: colors.bgRed,
TRACE: colors.gray,
DEBUG: colors.blue,
INFO: colors.green,
WARN: colors.yellow,
ERROR: colors.red,
FATAL: colors.bgRed,
};
const levelNames: Record<number | string, string> = {
10: "TRACE",
20: "DEBUG",
30: "INFO",
40: "WARN",
50: "ERROR",
60: "FATAL",
TRACE: "TRACE",
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
FATAL: "FATAL",
};
export const createPrettyLogStream = ({
trailingNewline = true,
useConsoleLog = false,
}: {
trailingNewline?: boolean;
useConsoleLog?: boolean;
} = {}) =>
new Writable({
write(chunk, _encoding, callback) {
try {
const log = JSON.parse(chunk.toString());
const timestamp = new Date(log.time)
.toISOString()
.replace("T", " ")
.replace("Z", "");
const level = log.level;
const levelColor = levelColors[level] || colors.white;
const levelName =
levelNames[level] || (typeof level === "string" ? level : "UNKNOWN");
let message = log.msg || "";
const additionalFields = Object.keys(log)
.filter((key) => !FORMATTED_LOG_EXCLUDE_FIELDS.has(key))
.reduce(
(acc, key) => {
acc[key] = log[key];
return acc;
},
{} as Record<string, unknown>,
);
if (Object.keys(additionalFields).length > 0) {
message += ` ${JSON.stringify(additionalFields, null, 2)}`;
}
const formattedLog = `${colors.gray}${timestamp}${colors.reset} ${levelColor}${colors.bright}${levelName}${colors.reset} ${message}${trailingNewline ? "\n" : ""}`;
if (useConsoleLog) console.log(formattedLog);
else process.stdout.write(formattedLog);
callback();
} catch {
if (useConsoleLog) console.log(chunk.toString());
else process.stdout.write(chunk);
callback();
}
},
});

View File

@@ -0,0 +1,56 @@
import type pino from "pino";
export type LoggerOutput = "console-pretty" | "console-json" | "axiom";
export type LoggerPreset = "default" | "dual" | "console-only" | "axiom-only";
export type LoggerLevel =
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type CreateLoggerParams = {
service: string;
dataset?: string;
level?: LoggerLevel;
preset?: LoggerPreset;
outputs?: LoggerOutput[];
context?: Record<string, unknown>;
mixin?: () => Record<string, unknown>;
axiomToken?: string;
axiomOrgId?: string;
useConsoleLog?: boolean;
};
export type ResolvedLoggerOptions = Required<
Pick<CreateLoggerParams, "service" | "preset">
> & {
dataset: string;
level: LoggerLevel;
outputs: LoggerOutput[];
hasAxiomToken: boolean;
};
export type LogArgs = unknown[];
export type AutumnLogger = {
level?: string;
debug: (...args: LogArgs) => void;
info: (...args: LogArgs) => void;
warn: (...args: LogArgs) => void;
warning: (...args: LogArgs) => void;
error: (...args: LogArgs) => void;
child: (params: {
context: Record<string, unknown>;
onlyProd?: boolean;
}) => AutumnLogger;
};
export type ConsoleLoggerLevel = "debug" | "info" | "warning" | "error";
export type ConsoleLogger = AutumnLogger & {
level: ConsoleLoggerLevel;
};
export type PinoLogger = pino.Logger;

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"allowJs": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2024"],
"module": "Preserve",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": false,
"noImplicitReturns": false,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"types": ["bun", "node"],
"useUnknownInCatchVariables": true
},
"exclude": ["node_modules"],
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -2,11 +2,10 @@
Mastra-backed MCP library for Autumn operations.
The hosted runtime lives in `apps/leaf` (see `src/mcp/http.ts`) and exposes two
Streamable HTTP MCP routes:
The hosted runtime lives in `apps/leaf` (see `src/mcp/mcpRouter.ts`) and exposes a
Streamable HTTP MCP route:
- `/mcp` - public, API-shaped operational tools.
- `/internal/mcp` - internal Autumn agent tool.
## `/mcp`
@@ -31,19 +30,6 @@ The write tools are marked destructive. Clients should call the matching preview
tool first where one exists and only call a write tool after explicit user
confirmation.
## `/internal/mcp`
Use this for Autumn-controlled agent flows.
Tools:
- `ask_autumn({ message, context? })`
`ask_autumn` can look up customers/plans, inspect scoped Axiom logs when
available, preview billing changes, and apply confirmed billing writes. Billing
writes are preview-first: the server stores the pending action internally and
executes it only after a follow-up confirmation.
## Local
The routes are served by the `@autumn/leaf` app. From the repo root:
@@ -52,15 +38,13 @@ The routes are served by the `@autumn/leaf` app. From the repo root:
bun run leaf
```
This starts both MCP routes (on the leaf port, `3099` by default):
This starts the MCP route (on the leaf port, `3099` by default):
- `http://localhost:3099/mcp`
- `http://localhost:3099/internal/mcp`
OAuth metadata is route-aware:
- `http://localhost:3099/.well-known/oauth-protected-resource/mcp`
- `http://localhost:3099/.well-known/oauth-protected-resource/internal/mcp`
OAuth uses the Autumn Better Auth issuer from `--server-url`:
OAuth uses the Autumn Better Auth issuer from `MCP_SERVER_URL`:

View File

@@ -20,7 +20,9 @@
"prepublishOnly": "bun run build"
},
"dependencies": {
"@autumn/shared": "workspace:*",
"@autumn/auth": "workspace:*",
"@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",
"@mastra/mcp": "^1.8.0",

View File

@@ -1,4 +1,10 @@
import { createHash } from "node:crypto";
import {
makeScopeChecker,
type ScopeString,
Scopes,
} from "@autumn/shared/scopeDefinitions";
import { ms } from "@autumn/shared/unixUtils";
import { Axiom } from "@axiomhq/js";
import { createTool } from "@mastra/core/tools";
import {
@@ -9,18 +15,12 @@ import {
isValid,
parseISO,
} from "date-fns";
import {
makeScopeChecker,
Scopes,
type ScopeString,
} from "@autumn/shared/scopeDefinitions";
import { ms } from "@autumn/shared/unixUtils";
import * as z from "zod/v4";
import {
type AutumnMcpAuth,
createAutumnClient,
getAutumnAuth,
type AutumnMcpAuth,
} from "./auth.js";
} from "../server/auth/auth.js";
const axiomDataset = "express";
const defaultStartTime = "now-30m";
@@ -28,8 +28,10 @@ const defaultEndTime = "now";
const maxRangeMs = ms.days(7);
const searchMaxRangeMs = ms.hours(1);
type AutumnOrg = { id: string; slug?: string | undefined };
let axiomClient: Axiom | null = null;
const orgCache = new Map<string, { orgId: string; expiresAt: Date }>();
const orgCache = new Map<string, { org: AutumnOrg; expiresAt: Date }>();
const getAxiomClient = () => {
if (!process.env.AXIOM_ADMIN_TOKEN) {
@@ -78,14 +80,22 @@ const getRangeMs = (startTime: string, endTime: string) => {
};
const assertCanUseAxiom = (auth: AutumnMcpAuth) => {
if (!makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)) {
if (
!makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)
) {
throw new Error("analytics:read scope is required to query Axiom logs.");
}
};
export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
if (auth.orgId) return auth.orgId;
/**
* Resolves the Autumn org (id + slug) for an authenticated request. Cached
* (~5min) per credential. Unlike `resolveAutumnOrgId`, this always hits
* `/v1/organization` when uncached so the slug is available the id alone may
* already be on `auth`, but the slug never is.
*/
export const resolveAutumnOrg = async (
auth: AutumnMcpAuth,
): Promise<AutumnOrg> => {
const cacheKey = [
auth.serverURL ?? "https://api.useautumn.com",
auth.env,
@@ -94,7 +104,7 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
String(auth.failOpen),
].join(":");
const cached = orgCache.get(cacheKey);
if (cached && isFuture(cached.expiresAt)) return cached.orgId;
if (cached && isFuture(cached.expiresAt)) return cached.org;
const client = createAutumnClient(auth);
const response = await fetch(new URL("/v1/organization", client.baseUrl), {
@@ -105,17 +115,26 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
throw new Error("Could not resolve Autumn organization for MCP request.");
}
const body = (await response.json()) as { id?: unknown };
const body = (await response.json()) as { id?: unknown; slug?: unknown };
if (typeof body.id !== "string" || !body.id) {
throw new Error("Autumn organization response did not include an id.");
}
const org: AutumnOrg = {
id: body.id,
slug: typeof body.slug === "string" ? body.slug : undefined,
};
orgCache.set(cacheKey, {
orgId: body.id,
org,
expiresAt: addMilliseconds(new Date(), ms.minutes(5)),
});
return body.id;
return org;
};
export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
if (auth.orgId) return auth.orgId;
return (await resolveAutumnOrg(auth)).id;
};
export const prepareAxiomQuery = ({
@@ -133,7 +152,9 @@ export const prepareAxiomQuery = ({
const rangeMs = getRangeMs(startTime, endTime);
if (rangeMs === null || rangeMs <= 0 || rangeMs > maxRangeMs) {
throw new Error("Axiom queries must use a bounded time range of at most 7 days.");
throw new Error(
"Axiom queries must use a bounded time range of at most 7 days.",
);
}
const trimmed = apl.trim();
@@ -152,7 +173,9 @@ export const prepareAxiomQuery = ({
}
if (/\|\s*\[\s*['"][^'"]+['"]\s*\](?=\s*(?:\||$))/i.test(rest)) {
throw new Error("Axiom queries may only use the express dataset source once.");
throw new Error(
"Axiom queries may only use the express dataset source once.",
);
}
if (/\bsearch\b/i.test(rest) && rangeMs > searchMaxRangeMs) {
@@ -165,7 +188,9 @@ export const prepareAxiomQuery = ({
`| where ['context.org_id'] == '${escapeAplString(auth.orgId)}'`,
`| where ['context.env'] == '${escapeAplString(auth.env)}'`,
rest,
].filter(Boolean).join("\n"),
]
.filter(Boolean)
.join("\n"),
startTime,
endTime,
};
@@ -180,11 +205,13 @@ export const createAxiomTools = () => ({
id: "queryAxiomLogs",
description:
"Run a read-only Axiom APL query against Autumn logs. The query is always constrained to the authenticated Autumn org and environment.",
inputSchema: z.object({
apl: z.string().min(1),
startTime: z.string().optional(),
endTime: z.string().optional(),
}).strict(),
inputSchema: z
.object({
apl: z.string().min(1),
startTime: z.string().optional(),
endTime: z.string().optional(),
})
.strict(),
execute: async ({ apl, startTime, endTime }, context) => {
const auth = await withAxiomOrg(getAutumnAuth(context));
const query = prepareAxiomQuery({ auth, apl, startTime, endTime });
@@ -198,9 +225,11 @@ export const createAxiomTools = () => ({
id: "getAxiomDatasetFields",
description:
"List available Axiom field metadata for the express dataset, scoped to the authenticated Autumn org and environment.",
inputSchema: z.object({
dataset: z.literal(axiomDataset),
}).strict(),
inputSchema: z
.object({
dataset: z.literal(axiomDataset),
})
.strict(),
execute: async ({ dataset }, context) => {
const auth = await withAxiomOrg(getAutumnAuth(context));
const query = prepareAxiomQuery({

View File

@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { ms } from "@autumn/shared/unixUtils";
import { addMilliseconds, isPast } from "date-fns";
import { Redis } from "ioredis";
import type { AutumnMcpAuth } from "./auth.js";
import type { AutumnMcpAuth } from "../server/auth/auth.js";
export type BillingToolName =
| "attach"
@@ -89,7 +89,7 @@ const getRedis = (): PendingActionRedis => {
};
const parseStoredAction = (value: string | null) =>
(value ? (JSON.parse(value) as PendingBillingAction) : null);
value ? (JSON.parse(value) as PendingBillingAction) : null;
const createAction = ({
auth,
@@ -150,7 +150,11 @@ export const claimLatestPendingAction = async (auth: AutumnMcpAuth) => {
if (!token || !action || isExpired(action)) {
logPendingAction("claim-miss", {
backend: "redis",
reason: !token ? "missing_latest" : !action ? "missing_action" : "expired",
reason: !token
? "missing_latest"
: !action
? "missing_action"
: "expired",
token: token ? shortHash(token) : null,
...actionDebug(auth),
});

View File

@@ -0,0 +1,37 @@
import type { AnalyticsSink } from "./analyticsTypes.js";
import { createLoggerAnalyticsSink } from "./loggerSink.js";
const DEFAULT_DATASET = "leaf";
const noopSink: AnalyticsSink = {
emit() {},
flush: async () => {},
};
let cachedSink: AnalyticsSink | null | undefined;
let overrideSink: AnalyticsSink | null | undefined;
/**
* Override the analytics sink (tests, or wiring a pino/OTEL sink from the host
* app). Pass `null` to disable. Pass `undefined` to fall back to env defaults.
*/
export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => {
overrideSink = sink;
if (sink !== undefined) cachedSink = undefined;
};
export const getAnalyticsSink = (): AnalyticsSink => {
if (overrideSink !== undefined) return overrideSink ?? noopSink;
if (cachedSink === undefined) {
cachedSink = createLoggerAnalyticsSink({
token: process.env.AXIOM_TOKEN,
orgId: process.env.AXIOM_ORG_ID,
dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,
});
}
return cachedSink ?? noopSink;
};
/** True when a real sink is configured — lets callers skip hot-path work. */
export const isAnalyticsEnabled = (): boolean =>
getAnalyticsSink() !== noopSink;

View File

@@ -0,0 +1,54 @@
/**
* Where a tool call originated:
* - `mcp` — an external MCP client hitting our hosted server (e.g. Claude
* Code, Cursor). The #1 usage-analytics target.
* - `agent` — our own Autumn Ops agent (e.g. Slack) invoking tools
* internally. Drives agent reliability / failure detection.
*/
export type McpAnalyticsSurface = "mcp" | "agent";
/**
* Org/auth context for a tool call. Mirrors the server's `context.*` log shape
* (see server/src/utils/logging) so MCP analytics and agent logs unify cleanly.
*/
export type McpAnalyticsContext = {
/** Autumn org id. Resolved lazily; may be absent if resolution fails. */
orgId?: string | undefined;
/** Autumn org slug. Resolved lazily; may be absent if resolution fails. */
orgSlug?: string | undefined;
env: string;
scopes?: string[] | undefined;
};
export type McpAnalyticsEvent = {
event: "mcp.tool_call";
surface: McpAnalyticsSurface;
tool: string;
/** One-sentence statement of what the caller is trying to do. */
intent?: string | undefined;
status: "ok" | "error";
durationMs: number;
principalId: string;
/** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */
client?: string | undefined;
/** MCP transport session id, or fallback hash(principal + client + window). */
sessionId: string;
context: McpAnalyticsContext;
/** Tool request payload (stored as an Axiom map field). */
input?: unknown;
/** Tool result payload (stored as an Axiom map field). */
output?: unknown;
error?: string | undefined;
};
/**
* Pluggable destination for analytics events. Implementations must be
* non-blocking: `emit` runs on the hot path of every tool call and must never
* throw or await network I/O inline. Swap this (pino/Axiom, an OTEL exporter,
* a test spy) without touching the instrumentation layer.
*/
export interface AnalyticsSink {
emit(event: McpAnalyticsEvent): void;
/** Drain any buffered events. Call on graceful shutdown. */
flush(): Promise<void>;
}

View File

@@ -0,0 +1,78 @@
import { resolveAutumnOrg } from "../agent/axiom.js";
import type { AutumnMcpAuth } from "../server/auth/auth.js";
import { getAnalyticsSink } from "./analyticsSink.js";
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
import { deriveSessionId } from "./sessionId.js";
/**
* Builds and dispatches a single tool-call analytics event. Org resolution and
* the actual sink write run off the hot path so the tool response is never
* delayed by analytics.
*/
export const emitMcpToolEvent = ({
surface,
toolId,
auth,
client,
transportSessionId,
intent,
status,
durationMs,
input,
output,
error,
}: {
surface: McpAnalyticsSurface;
toolId: string;
auth: AutumnMcpAuth;
client: string | undefined;
transportSessionId?: string | undefined;
intent?: string | undefined;
status: "ok" | "error";
durationMs: number;
input?: unknown;
output?: unknown;
error?: string | undefined;
}) => {
const sink = getAnalyticsSink();
// Resolve org off the hot path; resolveAutumnOrg is cached (~5min).
void (async () => {
let orgId = auth.orgId;
let orgSlug: string | undefined;
try {
const org = await resolveAutumnOrg(auth);
orgId = org.id;
orgSlug = org.slug;
} catch {
// Best-effort: emit without org context rather than dropping the event.
}
const now = Date.now();
sink.emit({
event: "mcp.tool_call",
surface,
tool: toolId,
intent,
status,
durationMs,
principalId: auth.principalId,
client,
sessionId:
transportSessionId ??
deriveSessionId({
principalId: auth.principalId,
client,
now,
}),
context: {
orgId,
orgSlug,
env: auth.env,
scopes: auth.scopes,
},
input,
output,
error,
});
})();
};

View File

@@ -0,0 +1,15 @@
export {
getAnalyticsSink,
isAnalyticsEnabled,
setAnalyticsSink,
} from "./analyticsSink.js";
export type {
AnalyticsSink,
McpAnalyticsEvent,
McpAnalyticsSurface,
} from "./analyticsTypes.js";
export { instrumentToolsWithAnalytics } from "./instrumentTools.js";
export {
createAxiomAnalyticsSink,
createLoggerAnalyticsSink,
} from "./loggerSink.js";

View File

@@ -0,0 +1,115 @@
import type { createTool } from "@mastra/core/tools";
import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
import { getIntent } from "../tools/utils/intent.js";
import { isAnalyticsEnabled } from "./analyticsSink.js";
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
import { emitMcpToolEvent } from "./emitToolEvent.js";
type AnyTool = ReturnType<typeof createTool>;
type ToolContext = Parameters<NonNullable<AnyTool["execute"]>>[1];
const getHeadersFromContext = (
context: ToolContext,
): Record<string, string | undefined> | undefined => {
const extra = (
context as {
mcp?: {
extra?: {
requestInfo?: { headers?: Record<string, string | undefined> };
};
};
}
)?.mcp?.extra;
return extra?.requestInfo?.headers;
};
const getHeader = (
headers: Record<string, string | undefined> | undefined,
name: string,
): string | undefined => {
const direct = headers?.[name] ?? headers?.[name.toLowerCase()];
if (direct) return direct;
const entry = Object.entries(headers ?? {}).find(
([key]) => key.toLowerCase() === name.toLowerCase(),
);
return entry?.[1];
};
const extractRequest = (input: unknown): unknown =>
input && typeof input === "object" && "request" in input
? (input as { request: unknown }).request
: input;
/**
* Wraps each tool's `execute` to emit a usage event per call. Auth/identity is
* read from the same MCP context the tools already use, so an unauthenticated
* call simply skips analytics (it would have failed in the tool anyway).
*
* Tools are wrapped once when the MCP server is created. The wrapper keeps no
* per-request mutable state; auth/session data is read from the execution
* context for each tool call.
*
* @param tools The toolset to instrument (mutated in place and returned).
* @param surface Origin of the calls — `mcp` (external clients) or `agent`
* (our own Autumn Ops agent).
*/
export const instrumentToolsWithAnalytics = <
T extends Record<string, AnyTool>,
>({
tools,
surface,
}: {
tools: T;
surface: McpAnalyticsSurface;
}): T => {
if (!isAnalyticsEnabled()) return tools;
for (const [toolId, tool] of Object.entries(tools)) {
const original = tool.execute;
if (!original) continue;
tool.execute = (async (input: unknown, context: ToolContext) => {
const started = Date.now();
let auth: AutumnMcpAuth | undefined;
try {
auth = getAutumnAuth(context);
} catch {
return original(input as never, context as never);
}
const headers = getHeadersFromContext(context);
const client = getHeader(headers, "user-agent");
const transportSessionId = getHeader(headers, "mcp-session-id");
const intent = getIntent(input);
try {
const output = await original(input as never, context as never);
emitMcpToolEvent({
surface,
toolId,
auth,
client,
transportSessionId,
intent,
status: "ok",
durationMs: Date.now() - started,
input: extractRequest(input),
output,
});
return output;
} catch (error) {
emitMcpToolEvent({
surface,
toolId,
auth,
client,
transportSessionId,
intent,
status: "error",
durationMs: Date.now() - started,
input: extractRequest(input),
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}) as AnyTool["execute"];
}
return tools;
};

View File

@@ -0,0 +1,60 @@
import { asAxiomMap, createLogger, guardLogPayload } from "@autumn/logging";
import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js";
const toLoggerRecord = (event: McpAnalyticsEvent) => ({
_time: new Date().toISOString(),
event: event.event,
surface: event.surface,
tool: event.tool,
intent: event.intent,
status: event.status,
duration_ms: event.durationMs,
principal_id: event.principalId,
client: event.client,
session_id: event.sessionId,
context: {
org_id: event.context.orgId,
org_slug: event.context.orgSlug,
env: event.context.env,
scopes: event.context.scopes,
},
input: asAxiomMap({ value: guardLogPayload({ value: event.input }) }),
output: asAxiomMap({ value: guardLogPayload({ value: event.output }) }),
error: event.error,
});
export const createLoggerAnalyticsSink = ({
token,
orgId,
dataset,
}: {
token?: string | undefined;
orgId?: string | undefined;
dataset: string;
}): AnalyticsSink | null => {
if (!token) return null;
const logger = createLogger({
service: "mcp",
dataset,
preset: "axiom-only",
outputs: ["axiom"],
axiomToken: token,
axiomOrgId: orgId,
});
return {
emit(event) {
logger.info(toLoggerRecord(event), "MCP tool call");
},
flush: async () => {
await new Promise<void>((resolve) => {
const flush = logger.flush;
if (typeof flush !== "function") return resolve();
flush.call(logger, () => resolve());
});
},
};
};
/** @deprecated Use createLoggerAnalyticsSink. */
export const createAxiomAnalyticsSink = createLoggerAnalyticsSink;

View File

@@ -0,0 +1,23 @@
import { createHash } from "node:crypto";
import { ms } from "@autumn/shared/unixUtils";
const sessionWindowMs = ms.minutes(30);
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
/**
* Fallback session grouping. Stateful MCP clients send Mcp-Session-Id; when it
* is absent, synthesize a coarse principal/client bucket so calls from the same
* client within the window still collapse into one session.
*/
export const deriveSessionId = ({
principalId,
client,
now,
}: {
principalId: string;
client: string | undefined;
now: number;
}) =>
hash(`${principalId}|${client ?? ""}|${Math.floor(now / sessionWindowMs)}`);

View File

@@ -16,7 +16,8 @@ export type ConsoleLogger = Record<ConsoleLoggerLevel, LogMethod> & {
export function createConsoleLogger(level: ConsoleLoggerLevel): ConsoleLogger {
const min = consoleLoggerLevels.indexOf(level);
const noop = () => {};
const log = (method: "debug" | "info" | "warn" | "error"): LogMethod =>
const log =
(method: "debug" | "info" | "warn" | "error"): LogMethod =>
(message, data) => {
if (data) console[method](message, data);
else console[method](message);

View File

@@ -0,0 +1,18 @@
import type { ScopeString } from "@autumn/shared/scopeDefinitions";
import { Scopes } from "@autumn/shared/scopeDefinitions";
/** Shared defaults for talking to the Autumn API from the MCP server. */
export const DEFAULT_AUTUMN_API_URL = "https://api.useautumn.com";
export const DEFAULT_API_VERSION = "2.3.0";
/** Scopes requested when exchanging an OAuth token for an Autumn API key. */
export const MCP_OAUTH_SCOPES = [
Scopes.Customers.Read,
Scopes.Customers.Write,
Scopes.Plans.Read,
Scopes.Plans.Write,
Scopes.Billing.Read,
Scopes.Billing.Write,
Scopes.Balances.Write,
Scopes.Analytics.Read,
] as const satisfies readonly ScopeString[];

View File

@@ -1,19 +1,27 @@
export {
createAskAutumnMCPServer,
createAutumnOperationsMCPServer,
createMCPServer,
} from "./mcp-server/agent/server.js";
type AnalyticsSink,
createAxiomAnalyticsSink,
getAnalyticsSink,
isAnalyticsEnabled,
type McpAnalyticsEvent,
type McpAnalyticsSurface,
setAnalyticsSink,
} from "./analytics/index.js";
export {
type ConsoleLogger,
type ConsoleLoggerLevel,
consoleLoggerLevels,
createConsoleLogger,
} from "./mcp-server/console-logger.js";
export type { MCPServerFlags } from "./mcp-server/flags.js";
} from "./console-logger.js";
export {
buildAuthForRequest,
getAuthorizationServerMetadata,
getProtectedResourceMetadata,
DEFAULT_API_VERSION,
DEFAULT_AUTUMN_API_URL,
MCP_OAUTH_SCOPES,
} from "./constants.js";
export {
type AutumnMcpAuth,
environmentSchema,
type OAuthEnvironment,
OAuthHttpError,
} from "./mcp-server/oauth.js";
} from "./server/auth/auth.js";
export type { MCPServerFlags } from "./server/flags.js";
export { createAutumnOperationsMCPServer } from "./server/server.js";

View File

@@ -1,124 +0,0 @@
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import {
type AutumnMcpAuth,
createRequestContext,
getAutumnAuth,
} from "./auth.js";
import { getLatestPendingAction } from "./pending-actions.js";
import { createAgentAutumnOperationTools } from "./tools.js";
const model = "anthropic/claude-sonnet-4-6";
const instructions = `You are Autumn's operational billing assistant.
Use Autumn tools for customer, plan, and billing work.
Use Axiom tools only for read-only investigation of Autumn logs.
Rules:
- Read requests can be answered directly.
- For plan-attribute queries, call listPlans first and filter returned plans locally.
- For customer-heavy queries, push filters into listCustomers and paginate for complete results.
- For customer lookup, use listCustomers first when the id/email/name is ambiguous.
- For plan lookup, use listPlans first when the plan is ambiguous.
- Avoid getCustomer fan-out unless listCustomers is missing details required by the user.
- For customer creation, use createCustomer only when the user explicitly asks to create or pre-create a customer.
- For plan creation, gather plan id, name, price, items/features, trials, and add-on/default behavior before calling createPlan.
- For standalone credit or balance grants, use previewCreateBalance before createBalance. Use entity_id for entity-scoped grants, included_grant for the granted amount, expires_at in milliseconds for expiring grants, and omit reset when using expires_at.
- For multi-phase billing schedules, gather customer, optional entity, ordered phase start times, and phase plans before calling previewCreateSchedule.
- Use dateToEpochMilliseconds to convert user-facing dates into epoch milliseconds before calling tools with starts_at or expires_at fields; if a named timezone matters, ask for or use an explicit offset.
- If a fee schedule says year 1 is already paid or has no billing changes, do not add an immediate/year-1 phase; start the schedule at the first future billing change.
- For custom consumable grants, map "per month/year" to customize.items[].reset.interval. Omit reset only for unlimited, non-consumable, or clearly one-time grants.
- For billing changes, call previewAttach or previewUpdateSubscription first. These preview tools automatically create the pending billing action.
- previewCreateSchedule stores the pending createSchedule write; after it returns pending, ask the user to confirm the exact schedule before applying it.
- previewCreateBalance stores the pending createBalance write; after it returns pending, ask the user to confirm the exact balance grant before applying it.
- createPlan stores a pending write; after it returns pending, ask the user to confirm the exact plan configuration before applying it.
- Never expose internal ids or server bookkeeping details.
- After a billing preview, tell the user to explicitly apply or approve the exact previewed change.
- If the user semantically confirms, applies, or approves a billing preview, call confirmBillingAction even if the preview is not visible in the current message. The tool validates whether a pending action exists.
- Never claim a billing write has been applied unless confirmBillingAction succeeds.
- If customer, plan, entity, subscription, or environment is ambiguous, ask a short clarifying question.
- Keep responses concise. Use JSON only when it materially helps debugging.`;
// To be added when we add axiom:
// - For log investigations, start with narrow structured fields such as context.customer_id, context.org_slug, req.url, req.id, stripe_event.id, stripe_event.type, workflow.id, or workflow.name.
// - For wide log windows, use a cheap aggregate query first, then focused <= 1 hour queries. Prefer ERROR/WARN levels first.
// - Axiom queries are already scoped to the authenticated org and environment; do not add or mention separate org filters unless useful to explain the investigation.
// - Axiom tools are read-only and must never be used as part of a billing confirmation or write flow.
const createAgent = () =>
new Agent({
id: "autumn-ops",
name: "Autumn Ops",
description:
"Answers Autumn customer, plan, and billing questions using controlled Autumn operations.",
instructions,
model,
tools: createAgentAutumnOperationTools(),
});
const getAuth = (
toolContext: Parameters<
NonNullable<ReturnType<typeof createTool>["execute"]>
>[1],
defaultAuth?: AutumnMcpAuth,
) => {
try {
return getAutumnAuth(toolContext);
} catch (error) {
if (defaultAuth) return defaultAuth;
throw error;
}
};
const getPendingAction = async (auth: AutumnMcpAuth) => {
try {
return await getLatestPendingAction(auth);
} catch {
return null;
}
};
export const createAskAutumnTool = (defaultAuth?: AutumnMcpAuth) =>
createTool({
id: "ask_autumn",
description:
"Ask Autumn to look up customers/plans or safely preview and confirm billing changes.",
inputSchema: z.object({
message: z.string().min(1),
context: z.record(z.string(), z.unknown()).optional(),
}),
mcp: {
annotations: {
title: "Ask Autumn",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
},
execute: async ({ message, context }, toolContext) => {
const auth = getAuth(toolContext, defaultAuth);
const pendingAction = await getPendingAction(auth);
const contextText = context
? `\n\nCaller context:\n${JSON.stringify(context, null, 2)}`
: "";
const pendingText = pendingAction
? `\n\nPending billing action:\nTool: ${pendingAction.toolName}\nPreview: ${pendingAction.preview}\nIf the user confirms this preview, call confirmBillingAction.`
: "";
const output = await createAgent().generate(message, {
maxSteps: 8,
requestContext: createRequestContext(auth),
context: [
{
role: "system",
content: `Current Autumn environment: ${auth.env}.${pendingText}${contextText}`,
},
],
});
return output.text;
},
});
export const askAutumnTool = createAskAutumnTool();

View File

@@ -1,53 +0,0 @@
import { createHash } from "node:crypto";
import { RequestContext } from "@mastra/core/request-context";
import type { ToolExecutionContext } from "@mastra/core/tools";
import type { OAuthEnvironment } from "../oauth.js";
export type AutumnMcpAuth = {
apiKey: string;
env: OAuthEnvironment;
principalId: string;
resource: string;
scopes: string[];
orgId?: string | undefined;
serverURL?: string | undefined;
xApiVersion?: string | undefined;
failOpen?: boolean | undefined;
};
type MaybeToolContext = Pick<ToolExecutionContext, "mcp" | "requestContext">;
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
export const principalFromSecret = (kind: string, value: string) =>
`${kind}:${hash(value)}`;
export const createAutumnClient = (auth: AutumnMcpAuth) => ({
baseUrl: auth.serverURL ?? "https://api.useautumn.com",
headers: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"x-api-version": auth.xApiVersion ?? "2.3.0",
...(auth.failOpen === undefined
? {}
: { "fail-open": String(auth.failOpen) }),
},
});
export const getAutumnAuth = (context?: MaybeToolContext): AutumnMcpAuth => {
const direct = context?.mcp?.extra?.authInfo as AutumnMcpAuth | undefined;
const nested = context?.requestContext?.get?.("mcp.extra") as
| { authInfo?: AutumnMcpAuth }
| undefined;
const auth = direct ?? nested?.authInfo;
if (!auth?.apiKey) throw new Error("Autumn MCP authentication is required.");
return auth;
};
export const createRequestContext = (auth: AutumnMcpAuth) => {
const requestContext = new RequestContext();
requestContext.set("mcp.extra", { authInfo: auth });
return requestContext;
};

View File

@@ -1,36 +0,0 @@
import { MCPServer } from "@mastra/mcp";
import { createAskAutumnTool } from "./ask-autumn.js";
import type { AutumnMcpAuth } from "./auth.js";
import { autumnMcpResources } from "./resources.js";
import { createRawAutumnOperationTools } from "./tools.js";
export const createAskAutumnMCPServer = (_opts?: {
defaultAuth?: AutumnMcpAuth;
}) =>
new MCPServer({
id: "autumn-internal-mcp",
name: "Autumn Internal MCP",
version: "0.0.1",
description:
"Ask Autumn to safely operate on customers, plans, and billing.",
instructions:
"Use ask_autumn for all Autumn work. Billing writes require preview and explicit user confirmation.",
tools: {
ask_autumn: createAskAutumnTool(_opts?.defaultAuth),
},
resources: autumnMcpResources,
});
export const createAutumnOperationsMCPServer = () =>
new MCPServer({
id: "autumn-mcp",
name: "Autumn MCP",
version: "0.0.1",
description: "Operate on Autumn customers, plans, and billing.",
instructions:
"Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
tools: createRawAutumnOperationTools(),
resources: autumnMcpResources,
});
export const createMCPServer = createAskAutumnMCPServer;

View File

@@ -1,554 +0,0 @@
import {
AttachParamsV1Schema,
CreateBalanceParamsV0Schema,
CreateCustomerParamsV1Schema,
CreatePlanParamsV2Schema,
CreateSchedulePhaseSchema,
CreateScheduleParamsV0Schema,
GetCustomerParamsV1Schema,
GetPlanParamsV0Schema,
ListCustomersV2_3ParamsSchema,
ListPlanParamsSchema,
UpdateSubscriptionV1ParamsSchema,
} from "@autumn/shared/publicApiSchemas";
import { createTool } from "@mastra/core/tools";
import { isValid, parseISO } from "date-fns";
import * as z from "zod/v4";
import { createAutumnClient, getAutumnAuth } from "./auth.js";
import {
claimLatestPendingAction,
createPendingAction,
} from "./pending-actions.js";
type ToolContext = Parameters<
NonNullable<ReturnType<typeof createTool>["execute"]>
>[1];
type ConfirmedWriteToolName =
| "attach"
| "updateSubscription"
| "createPlan"
| "createSchedule"
| "createBalance";
type OperationToolConfig = {
id: string;
description: string;
schema: z.ZodType;
endpoint: string;
destructive?: boolean;
idempotent?: boolean;
};
type BillingPreviewToolConfig = {
id: string;
description: string;
schema: z.ZodType;
previewEndpoint: string;
writeToolName: ConfirmedWriteToolName;
};
type LocalPreviewToolConfig = {
id: string;
description: string;
schema: z.ZodType;
writeToolName: ConfirmedWriteToolName;
preview: (request: unknown) => unknown;
};
export const endpointByTool = {
listCustomers: "/v1/customers.list",
createCustomer: "/v1/customers.get_or_create",
getCustomer: "/v1/customers.get",
listPlans: "/v1/plans.list",
createPlan: "/v1/plans.create",
getPlan: "/v1/plans.get",
previewAttach: "/v1/billing.preview_attach",
attach: "/v1/billing.attach",
previewUpdateSubscription: "/v1/billing.preview_update",
updateSubscription: "/v1/billing.update",
previewCreateSchedule: "/v1/billing.preview_create_schedule",
createSchedule: "/v1/billing.create_schedule",
createBalance: "/v1/balances.create",
} as const;
const epochMillisecondsSchema = z
.union([z.number(), z.string()])
.transform((value, context) => {
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
} else {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
? `${value}T00:00:00.000Z`
: value;
const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
if (isValid(parsed)) return parsed.getTime();
}
context.addIssue({
code: "custom",
message:
"Expected epoch milliseconds or an ISO date/timestamp string.",
});
return z.NEVER;
});
const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({
starts_at: epochMillisecondsSchema.meta({
description:
"Phase start time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
}),
});
const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({
phases: z.tuple([createSchedulePhaseMcpSchema]).rest(
createSchedulePhaseMcpSchema,
),
});
const createBalanceMcpSchema = CreateBalanceParamsV0Schema.extend({
expires_at: epochMillisecondsSchema.optional().meta({
description:
"Expiry time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
}),
});
const listCustomersMcpSchema = ListCustomersV2_3ParamsSchema.extend({
limit: z
.preprocess(
(value) => (typeof value === "number" && value > 1000 ? 1000 : value),
z.number().int().positive().max(1000).optional(),
)
.meta({ description: "Maximum customers per page. Max 1000." }),
});
const writeSchemaByTool = {
attach: AttachParamsV1Schema,
updateSubscription: UpdateSubscriptionV1ParamsSchema,
createPlan: CreatePlanParamsV2Schema,
createSchedule: createScheduleMcpSchema,
createBalance: createBalanceMcpSchema,
} as const satisfies Record<ConfirmedWriteToolName, z.ZodType>;
export const schemaByTool = {
listCustomers: listCustomersMcpSchema,
createCustomer: CreateCustomerParamsV1Schema,
getCustomer: GetCustomerParamsV1Schema,
listPlans: ListPlanParamsSchema,
createPlan: CreatePlanParamsV2Schema,
getPlan: GetPlanParamsV0Schema,
previewAttach: AttachParamsV1Schema,
attach: AttachParamsV1Schema,
previewUpdateSubscription: UpdateSubscriptionV1ParamsSchema,
updateSubscription: UpdateSubscriptionV1ParamsSchema,
previewCreateSchedule: createScheduleMcpSchema,
createSchedule: createScheduleMcpSchema,
previewCreateBalance: createBalanceMcpSchema,
createBalance: createBalanceMcpSchema,
} as const satisfies Record<
keyof typeof endpointByTool | "previewCreateBalance",
z.ZodType
>;
const toolConfigs: OperationToolConfig[] = [
{
id: "listCustomers",
description:
"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.",
schema: listCustomersMcpSchema,
endpoint: endpointByTool.listCustomers,
},
{
id: "createCustomer",
description:
"Create an Autumn customer, or return the existing customer with the same id. Use when the user explicitly wants a customer record created.",
schema: CreateCustomerParamsV1Schema,
endpoint: endpointByTool.createCustomer,
idempotent: true,
},
{
id: "getCustomer",
description: "Fetch one Autumn customer by id.",
schema: GetCustomerParamsV1Schema,
endpoint: endpointByTool.getCustomer,
},
{
id: "listPlans",
description:
"List Autumn plans. This is usually a cheap full scan; filter returned plans locally and use matching id/version pairs before customer queries based on plan attributes.",
schema: ListPlanParamsSchema,
endpoint: endpointByTool.listPlans,
},
{
id: "createPlan",
description:
"Create an Autumn plan. Destructive configuration write: gather plan_id, name, price, features/items, trials, and confirmation before running.",
schema: CreatePlanParamsV2Schema,
endpoint: endpointByTool.createPlan,
destructive: true,
},
{
id: "createBalance",
description:
"Create a standalone customer balance grant. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Destructive: preview first; use entity_id for entity-scoped credits, included_grant for the grant amount, expires_at for expiring grants, and omit reset when using expires_at. For relative expiries like '2 months', use calendar months, not a 30-day approximation. expires_at accepts epoch milliseconds or ISO/date strings.",
schema: createBalanceMcpSchema,
endpoint: endpointByTool.createBalance,
destructive: true,
},
{
id: "getPlan",
description: "Fetch one Autumn plan by id and optional version.",
schema: GetPlanParamsV0Schema,
endpoint: endpointByTool.getPlan,
},
];
const localPreviewConfigs: LocalPreviewToolConfig[] = [
{
id: "previewCreateBalance",
description:
"Preview a standalone balance grant before createBalance. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Use for one-time credit grants, referral/promotional credits, and entity-scoped credits. Does not mutate Autumn. For relative expiries like '2 months', use calendar months. expires_at accepts epoch milliseconds or ISO/date strings.",
schema: createBalanceMcpSchema,
writeToolName: "createBalance",
preview: (request) => ({
action: "createBalance",
request,
impact:
"Creates a standalone balance grant. If entity_id is present, the balance is scoped to that entity. If expires_at is present, the grant expires at that timestamp.",
}),
},
];
const billingPreviewConfigs: BillingPreviewToolConfig[] = [
{
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.",
schema: AttachParamsV1Schema,
previewEndpoint: endpointByTool.previewAttach,
writeToolName: "attach",
},
{
id: "previewUpdateSubscription",
description:
"Preview updating a subscription before updateSubscription. Include quantity/custom item changes; recurring custom grants need reset.interval.",
schema: UpdateSubscriptionV1ParamsSchema,
previewEndpoint: endpointByTool.previewUpdateSubscription,
writeToolName: "updateSubscription",
},
{
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.",
schema: createScheduleMcpSchema,
previewEndpoint: endpointByTool.previewCreateSchedule,
writeToolName: "createSchedule",
},
];
const confirmedWriteConfigs: OperationToolConfig[] = [
{
id: "attach",
description:
"Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.",
schema: AttachParamsV1Schema,
endpoint: endpointByTool.attach,
destructive: true,
},
{
id: "updateSubscription",
description:
"Update a subscription. Destructive: preview first; preserve quantity/custom item changes and reset intervals from the previewed request.",
schema: UpdateSubscriptionV1ParamsSchema,
endpoint: endpointByTool.updateSubscription,
destructive: true,
},
{
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.",
schema: createScheduleMcpSchema,
endpoint: endpointByTool.createSchedule,
destructive: true,
},
];
export const dateToEpochMillisecondsTool = createTool({
id: "dateToEpochMilliseconds",
description:
"Convert a calendar date or ISO timestamp to UTC epoch milliseconds for API timestamp fields. Date-only values default to midnight UTC; include an explicit offset in the date string when timezone matters.",
inputSchema: z
.object({
date: z.string(),
})
.strict(),
execute: async ({ date }) => toEpochMilliseconds(date),
});
const toEpochMilliseconds = (date: string) => {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(date)
? `${date}T00:00:00.000`
: date;
const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
if (!isValid(parsed)) throw new Error(`Invalid date: ${date}`);
return parsed.getTime();
};
const callAutumn = async ({
context,
endpoint,
request,
}: {
context?: ToolContext;
endpoint: string;
request: unknown;
}) => {
const auth = getAutumnAuth(context);
const client = createAutumnClient(auth);
const init: RequestInit = {
method: "POST",
headers: client.headers,
body: JSON.stringify(request),
};
if (context?.mcp?.extra?.signal) init.signal = context.mcp.extra.signal;
const response = await fetch(new URL(endpoint, client.baseUrl), init);
const text = await response.text();
const body = text ? parseBody(text) : null;
if (!response.ok) {
throw new Error(
`Autumn API request failed (${response.status}): ${typeof body === "string" ? body : JSON.stringify(body)}`,
);
}
return body;
};
const parseBody = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
return text;
}
};
const logTool = (event: string, data: Record<string, unknown>) => {
if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return;
console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`);
};
const mcpAnnotations = (destructive = false, idempotent = false) => ({
readOnlyHint: !destructive && !idempotent,
destructiveHint: destructive,
idempotentHint: idempotent,
openWorldHint: false,
});
const toTools = <Config extends { id: string }>(
configs: Config[],
create: (config: Config) => ReturnType<typeof createTool>,
) => Object.fromEntries(configs.map((config) => [config.id, create(config)]));
const operationTool = ({
id,
description,
schema,
endpoint,
destructive = false,
idempotent = false,
}: OperationToolConfig) =>
createTool({
id,
description,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(destructive, idempotent),
},
execute: (input, context) =>
callAutumn({
context,
endpoint,
request: schema.parse((input as { request: unknown }).request),
}),
});
const agentBillingPreviewTool = ({
id,
description,
schema,
previewEndpoint,
writeToolName,
}: {
id: string;
description: string;
schema: z.ZodType;
previewEndpoint: string;
writeToolName: ConfirmedWriteToolName;
}) =>
createTool({
id,
description: `${description} Store the exact pending billing action for later confirmation.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input, context) => {
const request = (input as { request: unknown }).request;
const parsedRequest = schema.parse(request);
const auth = getAutumnAuth(context);
logTool("preview-start", { previewTool: id, writeToolName });
const preview = await callAutumn({
context,
endpoint: previewEndpoint,
request: parsedRequest,
});
await createPendingAction({
auth,
toolName: writeToolName,
request: parsedRequest,
preview: JSON.stringify(preview),
});
logTool("preview-stored", { previewTool: id, writeToolName });
return {
preview,
pending: true,
message:
"Preview ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});
const rawLocalPreviewTool = ({
id,
description,
schema,
preview,
}: LocalPreviewToolConfig) =>
createTool({
id,
description,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input) =>
preview(schema.parse((input as { request: unknown }).request)),
});
const agentLocalPreviewTool = ({
id,
description,
schema,
writeToolName,
preview,
}: LocalPreviewToolConfig) =>
createTool({
id,
description: `${description} Store the exact pending billing action for later confirmation.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input, context) => {
const request = (input as { request: unknown }).request;
const parsedRequest = schema.parse(request);
const previewResult = preview(parsedRequest);
await createPendingAction({
auth: getAutumnAuth(context),
toolName: writeToolName,
request: parsedRequest,
preview: JSON.stringify(previewResult),
});
return {
preview: previewResult,
pending: true,
message:
"Preview ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});
const agentPendingWriteTool = ({
id,
description,
schema,
}: OperationToolConfig) =>
createTool({
id,
description: `${description} This internal agent tool stores the exact request for later confirmation instead of applying it immediately.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input, context) => {
const request = (input as { request: unknown }).request;
const parsedRequest = schema.parse(request);
await createPendingAction({
auth: getAutumnAuth(context),
toolName: id as ConfirmedWriteToolName,
request: parsedRequest,
preview: JSON.stringify(parsedRequest),
});
return {
pending: true,
request: parsedRequest,
message:
"Request ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});
export const createRawAutumnOperationTools = () => ({
...toTools(toolConfigs, operationTool),
...toTools(billingPreviewConfigs, (config) =>
operationTool({ ...config, endpoint: config.previewEndpoint }),
),
...toTools(localPreviewConfigs, rawLocalPreviewTool),
...toTools(confirmedWriteConfigs, operationTool),
});
export const createAgentAutumnOperationTools = () => ({
...toTools(
toolConfigs.filter(({ destructive }) => !destructive),
operationTool,
),
...toTools(
toolConfigs.filter(({ destructive }) => destructive),
agentPendingWriteTool,
),
...toTools(billingPreviewConfigs, agentBillingPreviewTool),
...toTools(localPreviewConfigs, agentLocalPreviewTool),
dateToEpochMilliseconds: dateToEpochMillisecondsTool,
confirmBillingAction: createTool({
id: "confirmBillingAction",
description:
"Apply the latest pending billing action after the user semantically confirms the preview.",
inputSchema: z.object({}).strict(),
execute: async (_input, context) => {
const auth = getAutumnAuth(context);
logTool("confirm-start", { env: auth.env });
const action = await claimLatestPendingAction(auth);
logTool("confirm-claimed", { toolName: action.toolName });
const result = await executeConfirmedBillingAction({
auth,
toolName: action.toolName,
request: action.request,
});
return {
message: `Confirmed and applied ${action.toolName}.`,
result,
};
},
}),
});
export const executeConfirmedBillingAction = async ({
auth,
toolName,
request,
}: {
auth: ReturnType<typeof getAutumnAuth>;
toolName: ConfirmedWriteToolName;
request: unknown;
}) =>
callAutumn({
context: { mcp: { extra: { authInfo: auth } } } as never,
endpoint: endpointByTool[toolName],
request: writeSchemaByTool[toolName].parse(request),
});

View File

@@ -1,333 +0,0 @@
import { type ScopeString, Scopes } from "@autumn/shared/scopeDefinitions";
import { ms } from "@autumn/shared/unixUtils";
import { addMilliseconds, isFuture } from "date-fns";
import * as z from "zod/v4";
import type { AutumnMcpAuth } from "./agent/auth.js";
import { principalFromSecret } from "./agent/auth.js";
import type { ConsoleLogger } from "./console-logger.js";
import type { MCPServerFlags } from "./flags.js";
export const MCP_OAUTH_SCOPES = [
Scopes.Customers.Read,
Scopes.Customers.Write,
Scopes.Plans.Read,
Scopes.Plans.Write,
Scopes.Billing.Read,
Scopes.Billing.Write,
Scopes.Balances.Write,
Scopes.Analytics.Read,
] as const satisfies readonly ScopeString[];
const environmentSchema = z.enum(["sandbox", "live"]);
const xApiVersionSchema = z.string().default("2.3.0");
const failOpenSchema = z
.union([
z.boolean(),
z.enum(["true", "false"]).transform((v) => v === "true"),
])
.default(true);
const secretKeySchema = z.string().min(1).optional();
const tokenExchangeSchema = z.object({
sandbox_key: z.string().optional(),
prod_key: z.string().optional(),
org_id: z.string().optional(),
user_id: z.string().optional(),
client_id: z.string().optional(),
scopes: z.array(z.string()).optional(),
});
export type OAuthEnvironment = z.infer<typeof environmentSchema>;
export interface MCPOAuthFlags extends MCPServerFlags {
readonly "oauth-enabled"?: boolean | undefined;
readonly "oauth-environment"?: OAuthEnvironment | undefined;
}
export class OAuthHttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly error = "invalid_token",
readonly wwwAuthenticate?: string,
) {
super(message);
}
}
const apiKeyCache = new Map<
string,
{
key: string;
orgId?: string | undefined;
userId?: string | undefined;
clientId?: string | undefined;
scopes?: string[] | undefined;
expiresAt: Date;
}
>();
function trimTrailingSlash(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
export function getResourceUrl(
headers: Headers,
_flags: MCPOAuthFlags,
resourcePath = "/mcp",
): string {
const host =
headers.get("x-autumn-forwarded-host") ??
headers.get("x-forwarded-host") ??
headers.get("host");
if (!host) {
throw new OAuthHttpError(400, "Missing Host header", "invalid_request");
}
const proto =
headers.get("x-autumn-forwarded-proto") ??
headers.get("x-forwarded-proto") ??
"http";
return new URL(resourcePath, `${proto}://${host}`).href;
}
export function getProtectedResourceMetadataUrl(resourceUrl: string): string {
const url = new URL(resourceUrl);
const path = url.pathname === "/" ? "" : url.pathname;
return new URL(`/.well-known/oauth-protected-resource${path}`, url).href;
}
function getIssuerUrl(flags: MCPOAuthFlags): string {
return trimTrailingSlash(
new URL("/api/auth", flags["server-url"] ?? "https://api.useautumn.com")
.href,
);
}
function getApiKeyUrl(flags: MCPOAuthFlags): string {
return new URL("/cli/api-keys", getIssuerUrl(flags)).href;
}
function getWWWAuthenticate(resourceUrl: string, error?: string): string {
const params = [
`resource_metadata="${getProtectedResourceMetadataUrl(resourceUrl)}"`,
];
if (error) params.push(`error="${error}"`);
return `Bearer ${params.join(", ")}`;
}
export function getProtectedResourceMetadata(
headers: Headers,
flags: MCPOAuthFlags,
resourcePath = "/mcp",
) {
const resource = getResourceUrl(headers, flags, resourcePath);
return {
resource,
authorization_servers: [getIssuerUrl(flags)],
scopes_supported: [...MCP_OAUTH_SCOPES],
bearer_methods_supported: ["header"],
resource_name: "Autumn MCP",
};
}
export function getAuthorizationServerMetadata(flags: MCPOAuthFlags) {
const issuer = getIssuerUrl(flags);
return {
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
registration_endpoint: `${issuer}/oauth2/register`,
revocation_endpoint: `${issuer}/oauth2/revoke`,
introspection_endpoint: `${issuer}/oauth2/introspect`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: [
"client_secret_post",
"client_secret_basic",
"none",
],
code_challenge_methods_supported: ["S256"],
scopes_supported: [...MCP_OAUTH_SCOPES],
};
}
function getEnvironment(
headers: Headers,
flags: MCPOAuthFlags,
): OAuthEnvironment {
const value =
headers.get("x-autumn-environment") ??
flags["oauth-environment"] ??
"sandbox";
const parsed = environmentSchema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(
400,
"Invalid x-autumn-environment",
"invalid_request",
);
}
function parseRequestOption<T>(
value: unknown,
schema: z.ZodType<T>,
message: string,
): T {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(400, message, "invalid_request");
}
async function exchangeOAuthToken(
headers: Headers,
flags: MCPOAuthFlags,
resource: string,
token: string,
): Promise<{
key: string;
orgId?: string | undefined;
userId?: string | undefined;
clientId?: string | undefined;
scopes?: string[];
}> {
const env = getEnvironment(headers, flags);
const cacheKey = `${token}:${resource}:${env}`;
const cached = apiKeyCache.get(cacheKey);
if (cached && isFuture(cached.expiresAt)) return cached;
const response = await fetch(getApiKeyUrl(flags), {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ resource, scopes: MCP_OAUTH_SCOPES }),
});
if (!response.ok) {
throw new OAuthHttpError(
response.status === 403 ? 403 : 401,
await response.text(),
response.status === 403 ? "insufficient_scope" : "invalid_token",
response.status === 403
? undefined
: getWWWAuthenticate(resource, "invalid_token"),
);
}
const data = tokenExchangeSchema.parse(await response.json());
const key = env === "live" ? data.prod_key : data.sandbox_key;
if (!key) {
throw new OAuthHttpError(
502,
"OAuth key exchange did not return an API key",
);
}
const exchanged = {
key,
orgId: data.org_id,
userId: data.user_id,
clientId: data.client_id,
scopes: data.scopes,
expiresAt: addMilliseconds(new Date(), ms.minutes(1)),
};
apiKeyCache.set(cacheKey, exchanged);
return exchanged;
}
function getOAuthPrincipalId(
token: string,
exchanged: Awaited<ReturnType<typeof exchangeOAuthToken>>,
) {
if (!exchanged.orgId) return principalFromSecret("oauth", token);
return [
"oauth",
exchanged.orgId,
exchanged.userId ?? "unknown-user",
exchanged.clientId ?? "unknown-client",
].join(":");
}
function getStaticApiKey(headers: Headers, flags: MCPOAuthFlags) {
const secretKey = headers.get("secret-key");
if (secretKey) return secretKey;
const authorization = headers.get("authorization");
const bearer = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: undefined;
if (bearer?.startsWith("am_")) return bearer;
return flags["oauth-enabled"] ? undefined : flags["secret-key"];
}
export async function buildAuthForRequest(
headers: Headers,
flags: MCPOAuthFlags,
logger: ConsoleLogger,
resourcePath = "/mcp",
): Promise<AutumnMcpAuth> {
const env = getEnvironment(headers, flags);
const resource = getResourceUrl(headers, flags, resourcePath);
const xApiVersion = parseRequestOption(
headers.get("x-api-version") ?? flags["x-api-version"],
xApiVersionSchema,
"Invalid x-api-version",
);
const failOpen = parseRequestOption(
headers.get("fail-open") ?? flags["fail-open"],
failOpenSchema,
"Invalid fail-open",
);
const apiKey = parseRequestOption(
getStaticApiKey(headers, flags),
secretKeySchema,
"Invalid secret-key",
);
if (apiKey) {
return {
apiKey,
env,
resource,
principalId: principalFromSecret("secret-key", apiKey),
scopes: [...MCP_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
if (flags["oauth-enabled"]) {
const authHeader = headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new OAuthHttpError(
401,
"Missing Authorization bearer token",
"invalid_token",
getWWWAuthenticate(resource),
);
}
const token = authHeader.slice("Bearer ".length);
const exchanged = await exchangeOAuthToken(headers, flags, resource, token);
return {
apiKey: exchanged.key,
env,
resource,
principalId: getOAuthPrincipalId(token, exchanged),
scopes: exchanged.scopes ?? [...MCP_OAUTH_SCOPES],
orgId: exchanged.orgId,
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
logger.warning("Missing secret-key for MCP request");
throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
}

View File

@@ -1,7 +1,28 @@
import type { MCPServerResources } from "@mastra/mcp";
const docs = {
"autumn://docs/tool-composition": {
type DocInput = {
name: string;
title: string;
description: string;
text: string;
};
/**
* 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,
});
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.",
@@ -21,8 +42,8 @@ Use Autumn tools as composable primitives.
- For billing writes, always preview first and wait for explicit user confirmation before applying.
Docs index: https://docs.useautumn.com/llms.txt`,
},
"autumn://docs/querying-plans": {
}),
defineDoc({
name: "querying-plans",
title: "Querying Plans",
description: "How to answer plan-filtering questions with listPlans.",
@@ -39,8 +60,8 @@ Use listPlans for questions about:
- 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.`,
},
"autumn://docs/creating-plans": {
}),
defineDoc({
name: "creating-plans",
title: "Creating Plans",
description: "How to gather plan details before using createPlan.",
@@ -59,8 +80,8 @@ Before creating a plan, resolve:
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.`,
},
"autumn://docs/querying-customers": {
}),
defineDoc({
name: "querying-customers",
title: "Querying Customers",
description: "How to answer customer-heavy questions with listCustomers.",
@@ -76,8 +97,8 @@ Prefer server-side filters before local filtering:
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.`,
},
"autumn://docs/schedules": {
}),
defineDoc({
name: "schedules",
title: "Billing Schedules",
description: "How to create multi-phase billing schedules safely.",
@@ -101,8 +122,8 @@ Custom feature mapping:
- 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.`,
},
"autumn://docs/balances": {
}),
defineDoc({
name: "balances",
title: "Standalone Balances",
description:
@@ -133,8 +154,8 @@ Useful docs:
- 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`,
},
"autumn://docs/billing-safety": {
}),
defineDoc({
name: "billing-safety",
title: "Billing Safety",
description: "Preview-first rules for Autumn billing changes.",
@@ -157,13 +178,15 @@ 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`,
},
} as const;
}),
];
const docByUri = new Map(docs.map((doc) => [doc.uri, doc]));
export const autumnMcpResources: MCPServerResources = {
listResources: async () =>
Object.entries(docs).map(([uri, doc]) => ({
uri,
docs.map((doc) => ({
uri: doc.uri,
name: doc.name,
title: doc.title,
description: doc.description,
@@ -175,13 +198,12 @@ export const autumnMcpResources: MCPServerResources = {
},
})),
getResourceContent: async ({ uri }) => {
if (!Object.hasOwn(docs, uri)) {
const doc = docByUri.get(uri);
if (!doc) {
throw new Error(`Unknown Autumn MCP resource: ${uri}`);
}
const doc = docs[uri as keyof typeof docs];
return { text: doc.text };
},
};
export const autumnMcpResourceUris = Object.keys(docs);
export const autumnMcpResourceUris = docs.map((doc) => doc.uri);

View File

@@ -0,0 +1,85 @@
import { RequestContext } from "@mastra/core/request-context";
import * as z from "zod/v4";
import {
DEFAULT_API_VERSION,
DEFAULT_AUTUMN_API_URL,
} from "../../constants.js";
export const environmentSchema = z.enum(["sandbox", "live"]);
export type OAuthEnvironment = z.infer<typeof environmentSchema>;
/**
* Authenticated Autumn identity attached to every MCP request. Defined as a zod
* schema so the same definition both types the value and validates it when read
* back from the (loosely-typed) MCP execution context — no casts required.
*/
export const autumnMcpAuthSchema = z.object({
apiKey: z.string().min(1),
authMethod: z.enum(["secret-key", "oauth"]).optional(),
env: environmentSchema,
principalId: z.string(),
resource: z.string(),
scopes: z.array(z.string()),
orgId: z.string().optional(),
serverURL: z.string().optional(),
xApiVersion: z.string().optional(),
failOpen: z.boolean().optional(),
});
export type AutumnMcpAuth = z.infer<typeof autumnMcpAuthSchema>;
/**
* Minimal structural view of the MCP tool execution context we read auth from.
* Kept intentionally loose so any Mastra `ToolExecutionContext` satisfies it
* without callers having to cast.
*/
type AuthContext = {
mcp?: { extra?: { authInfo?: unknown } | undefined } | undefined;
requestContext?: { get?: (key: string) => unknown } | undefined;
};
/** Reads `mcp.extra.authInfo` back out of a serialized request context. */
const readNestedAuthInfo = (
requestContext: AuthContext["requestContext"],
): unknown => {
const extra = requestContext?.get?.("mcp.extra");
if (typeof extra === "object" && extra !== null && "authInfo" in extra) {
return extra.authInfo;
}
return undefined;
};
export const getAutumnAuth = (context?: AuthContext): AutumnMcpAuth => {
const candidate =
context?.mcp?.extra?.authInfo ??
readNestedAuthInfo(context?.requestContext);
const parsed = autumnMcpAuthSchema.safeParse(candidate);
if (!parsed.success) {
throw new Error("Autumn MCP authentication is required.");
}
return parsed.data;
};
export const createRequestContext = (auth: AutumnMcpAuth) => {
const requestContext = new RequestContext();
requestContext.set("mcp.extra", { authInfo: auth });
return requestContext;
};
export const createAutumnClient = (auth: AutumnMcpAuth) => ({
baseUrl: auth.serverURL ?? DEFAULT_AUTUMN_API_URL,
headers: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION,
"x-autumn-environment": auth.env,
...(auth.authMethod === "oauth"
? { "x-autumn-oauth-resource": auth.resource }
: {}),
...(auth.failOpen === undefined
? {}
: { "fail-open": String(auth.failOpen) }),
},
});

View File

@@ -0,0 +1,15 @@
import { MCPServer } from "@mastra/mcp";
import { autumnMcpResources } from "../resources/index.js";
import { createRawAutumnOperationTools } from "../tools/index.js";
export const createAutumnOperationsMCPServer = () =>
new MCPServer({
id: "autumn-mcp",
name: "Autumn MCP",
version: "0.0.1",
description: "Operate on Autumn customers, plans, and billing.",
instructions:
"Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
tools: createRawAutumnOperationTools(),
resources: autumnMcpResources,
});

Some files were not shown because too many files have changed in this diff Show More