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:
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/server-unit-tests.yml
vendored
4
.github/workflows/server-unit-tests.yml
vendored
@@ -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
2
ai
Submodule ai updated: 794c164aed...0e52f71fbd
@@ -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>
|
||||
32
apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx
Normal file
32
apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx
Normal 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>
|
||||
77
apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx
Normal file
77
apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx
Normal 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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) ?? [];
|
||||
60
apps/leaf/src/lib/logger.ts
Normal file
60
apps/leaf/src/lib/logger.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
34
apps/leaf/src/mcp/auth/protectedResourceMetadata.ts
Normal file
34
apps/leaf/src/mcp/auth/protectedResourceMetadata.ts
Normal 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",
|
||||
});
|
||||
184
apps/leaf/src/mcp/auth/resolveRequestAuth.ts
Normal file
184
apps/leaf/src/mcp/auth/resolveRequestAuth.ts
Normal 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");
|
||||
};
|
||||
3
apps/leaf/src/mcp/constants.ts
Normal file
3
apps/leaf/src/mcp/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MCP_PATH = "/mcp" as const;
|
||||
export const PROTECTED_RESOURCE_METADATA_PATH =
|
||||
"/.well-known/oauth-protected-resource/mcp";
|
||||
71
apps/leaf/src/mcp/handlers/handleMcp.ts
Normal file
71
apps/leaf/src/mcp/handlers/handleMcp.ts
Normal 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;
|
||||
};
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
29
apps/leaf/src/mcp/mcpRouter.ts
Normal file
29
apps/leaf/src/mcp/mcpRouter.ts
Normal 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;
|
||||
};
|
||||
15
apps/leaf/src/mcp/types.ts
Normal file
15
apps/leaf/src/mcp/types.ts
Normal 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";
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
32
apps/leaf/tests/unit/lib/logger.test.ts
Normal file
32
apps/leaf/tests/unit/lib/logger.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
167
apps/leaf/tests/unit/mcp/oauth.test.ts
Normal file
167
apps/leaf/tests/unit/mcp/oauth.test.ts
Normal 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>);
|
||||
});
|
||||
});
|
||||
@@ -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/*"],
|
||||
|
||||
67
bun.lock
67
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
|
||||
179
others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py
Normal file
179
others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py
Normal 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
|
||||
72
others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py
Normal file
72
others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py
Normal 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
|
||||
206
others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py
Normal file
206
others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py
Normal 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]
|
||||
586
others/python-sdk/src/autumn_sdk/platform.py
Normal file
586
others/python-sdk/src/autumn_sdk/platform.py
Normal 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)
|
||||
@@ -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__(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
packages/auth/package.json
Normal file
36
packages/auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
packages/auth/src/index.ts
Normal file
2
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./oauth/index.js";
|
||||
export * from "./utils/index.js";
|
||||
1
packages/auth/src/oauth/index.ts
Normal file
1
packages/auth/src/oauth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./oauthUrls.js";
|
||||
32
packages/auth/src/oauth/oauthUrls.ts
Normal file
32
packages/auth/src/oauth/oauthUrls.ts
Normal 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(", ")}`;
|
||||
};
|
||||
23
packages/auth/src/utils/authTokenUtils.ts
Normal file
23
packages/auth/src/utils/authTokenUtils.ts
Normal 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;
|
||||
13
packages/auth/src/utils/getBearerToken.ts
Normal file
13
packages/auth/src/utils/getBearerToken.ts
Normal 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;
|
||||
};
|
||||
2
packages/auth/src/utils/index.ts
Normal file
2
packages/auth/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./authTokenUtils.js";
|
||||
export * from "./getBearerToken.js";
|
||||
34
packages/auth/tsconfig.json
Normal file
34
packages/auth/tsconfig.json
Normal 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"]
|
||||
}
|
||||
30
packages/logging/package.json
Normal file
30
packages/logging/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
packages/logging/src/context/addContextToLogs.ts
Normal file
38
packages/logging/src/context/addContextToLogs.ts
Normal 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 } });
|
||||
35
packages/logging/src/context/types.ts
Normal file
35
packages/logging/src/context/types.ts
Normal 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;
|
||||
};
|
||||
21
packages/logging/src/ids/createSessionId.ts
Normal file
21
packages/logging/src/ids/createSessionId.ts
Normal 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);
|
||||
3
packages/logging/src/ids/createTraceId.ts
Normal file
3
packages/logging/src/ids/createTraceId.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const createTraceId = (): string => randomUUID();
|
||||
40
packages/logging/src/index.ts
Normal file
40
packages/logging/src/index.ts
Normal 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";
|
||||
69
packages/logging/src/logger/autumnLogger.ts
Normal file
69
packages/logging/src/logger/autumnLogger.ts
Normal 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) });
|
||||
28
packages/logging/src/logger/consoleLogger.ts
Normal file
28
packages/logging/src/logger/consoleLogger.ts
Normal 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;
|
||||
};
|
||||
60
packages/logging/src/logger/createLogger.ts
Normal file
60
packages/logging/src/logger/createLogger.ts
Normal 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),
|
||||
);
|
||||
};
|
||||
71
packages/logging/src/logger/loggerWrappers.ts
Normal file
71
packages/logging/src/logger/loggerWrappers.ts
Normal 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 }),
|
||||
};
|
||||
};
|
||||
67
packages/logging/src/logger/resolveLoggerOptions.ts
Normal file
67
packages/logging/src/logger/resolveLoggerOptions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
8
packages/logging/src/payload/asAxiomMap.ts
Normal file
8
packages/logging/src/payload/asAxiomMap.ts
Normal 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 };
|
||||
147
packages/logging/src/payload/guardLogPayload.ts
Normal file
147
packages/logging/src/payload/guardLogPayload.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
9
packages/logging/src/streams/consoleJsonStream.ts
Normal file
9
packages/logging/src/streams/consoleJsonStream.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
117
packages/logging/src/streams/prettyLogStream.ts
Normal file
117
packages/logging/src/streams/prettyLogStream.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
56
packages/logging/src/types.ts
Normal file
56
packages/logging/src/types.ts
Normal 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;
|
||||
34
packages/logging/tsconfig.json
Normal file
34
packages/logging/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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`:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
@@ -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),
|
||||
});
|
||||
37
packages/mcp/src/analytics/analyticsSink.ts
Normal file
37
packages/mcp/src/analytics/analyticsSink.ts
Normal 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;
|
||||
54
packages/mcp/src/analytics/analyticsTypes.ts
Normal file
54
packages/mcp/src/analytics/analyticsTypes.ts
Normal 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>;
|
||||
}
|
||||
78
packages/mcp/src/analytics/emitToolEvent.ts
Normal file
78
packages/mcp/src/analytics/emitToolEvent.ts
Normal 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,
|
||||
});
|
||||
})();
|
||||
};
|
||||
15
packages/mcp/src/analytics/index.ts
Normal file
15
packages/mcp/src/analytics/index.ts
Normal 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";
|
||||
115
packages/mcp/src/analytics/instrumentTools.ts
Normal file
115
packages/mcp/src/analytics/instrumentTools.ts
Normal 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;
|
||||
};
|
||||
60
packages/mcp/src/analytics/loggerSink.ts
Normal file
60
packages/mcp/src/analytics/loggerSink.ts
Normal 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;
|
||||
23
packages/mcp/src/analytics/sessionId.ts
Normal file
23
packages/mcp/src/analytics/sessionId.ts
Normal 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)}`);
|
||||
@@ -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);
|
||||
18
packages/mcp/src/constants.ts
Normal file
18
packages/mcp/src/constants.ts
Normal 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[];
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
85
packages/mcp/src/server/auth/auth.ts
Normal file
85
packages/mcp/src/server/auth/auth.ts
Normal 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) }),
|
||||
},
|
||||
});
|
||||
15
packages/mcp/src/server/server.ts
Normal file
15
packages/mcp/src/server/server.ts
Normal 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
Reference in New Issue
Block a user