Merge branch 'feat/ai-credit-system' of https://github.com/TheUntraceable/autumn into feat/ai-credit-system
This commit is contained in:
6
.bt/config.json
Normal file
6
.bt/config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"profile": null,
|
||||
"org": "autumn",
|
||||
"project": "leaf",
|
||||
"project_id": "b5592c45-a906-4b43-93b7-bd05a8172b0b"
|
||||
}
|
||||
75
.github/workflows/build.yml
vendored
75
.github/workflows/build.yml
vendored
@@ -14,16 +14,24 @@ on:
|
||||
required: false
|
||||
default: 'manual'
|
||||
|
||||
# Cancel an older in-progress build on the same branch when a newer commit
|
||||
# lands, so the latest commit is the one that builds + deploys.
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
AWS_REGION: us-west-2
|
||||
# ECR region is selected per-branch in the "Extract metadata" step:
|
||||
# prod repo (autumn) -> us-east-2
|
||||
# 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
|
||||
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -55,17 +63,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
env:
|
||||
@@ -103,11 +100,13 @@ jobs:
|
||||
echo "sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "deploy_staging_override=$DEPLOY_STAGING_OVERRIDE" >> $GITHUB_OUTPUT
|
||||
|
||||
# Select ECR repository based on branch
|
||||
# Select ECR repository + region based on branch
|
||||
if [ "$BRANCH_NAME" = "dev" ] || [ "$DEPLOY_STAGING_OVERRIDE" = "true" ]; then
|
||||
echo "ecr_repo=autumn-staging" >> $GITHUB_OUTPUT
|
||||
echo "region=us-east-1" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "ecr_repo=autumn" >> $GITHUB_OUTPUT
|
||||
echo "region=us-east-2" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Set custom tag if provided
|
||||
@@ -115,8 +114,19 @@ jobs:
|
||||
echo "custom_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ steps.meta.outputs.region }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
|
||||
- name: Set up Blacksmith Docker builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Build and push Docker image
|
||||
env:
|
||||
@@ -129,14 +139,13 @@ jobs:
|
||||
COMBINED_TAG="${IMAGE_TAG_BRANCH}-${IMAGE_TAG_SHA}"
|
||||
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${COMBINED_TAG}"
|
||||
|
||||
# Build and push
|
||||
# Layer + cache-mount persistence is handled by the Blacksmith sticky
|
||||
# disk mounted at /var/lib/buildkit, so no registry cache-from/to.
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--sbom=false \
|
||||
--cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH} \
|
||||
--cache-to type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH},mode=max \
|
||||
--tag ${TAGS//,/ --tag } \
|
||||
-f docker/Dockerfile \
|
||||
.
|
||||
@@ -153,31 +162,19 @@ jobs:
|
||||
echo "Repository: ${ECR_REGISTRY}/${ECR_REPOSITORY}"
|
||||
echo "Tag: ${COMBINED_TAG}"
|
||||
|
||||
- name: Auto-deploy fix branches
|
||||
- name: Auto-deploy production
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEPLOY_URL: ${{ secrets.DEPLOY_URL }}
|
||||
DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# Get the PR that was merged (if any)
|
||||
PR_DATA=$(gh pr list --state merged --search "$COMMIT_SHA" --json headRefName --limit 1)
|
||||
BRANCH_NAME=$(echo "$PR_DATA" | jq -r '.[0].headRefName // empty')
|
||||
|
||||
if [[ "$BRANCH_NAME" == fix/* ]]; then
|
||||
echo "Detected merged fix/ branch: $BRANCH_NAME"
|
||||
echo "Triggering auto-deploy..."
|
||||
|
||||
curl --fail-with-body -X POST "$DEPLOY_URL/api/deploy/github" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-deploy-secret: $DEPLOY_SECRET" \
|
||||
-d "{\"commitSha\": \"$COMMIT_SHA\", \"deploymentType\": \"server\"}"
|
||||
|
||||
echo "Auto-deploy triggered!"
|
||||
else
|
||||
echo "Not a fix/ branch (branch: ${BRANCH_NAME:-direct push}), skipping auto-deploy"
|
||||
fi
|
||||
echo "Triggering production auto-deploy for $COMMIT_SHA..."
|
||||
curl --fail-with-body -X POST "$DEPLOY_URL/api/deploy/github" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-deploy-secret: $DEPLOY_SECRET" \
|
||||
-d "{\"commitSha\": \"$COMMIT_SHA\"}"
|
||||
echo "Auto-deploy triggered!"
|
||||
|
||||
- name: Auto-deploy staging
|
||||
if: github.ref == 'refs/heads/dev' || steps.meta.outputs.deploy_staging_override == 'true'
|
||||
|
||||
2
.github/workflows/knip.yml
vendored
2
.github/workflows/knip.yml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
knip:
|
||||
name: Check for unused code
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
2
.github/workflows/sdk-publish.yml
vendored
2
.github/workflows/sdk-publish.yml
vendored
@@ -46,7 +46,7 @@ env:
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
4
.github/workflows/server-typecheck.yml
vendored
4
.github/workflows/server-typecheck.yml
vendored
@@ -10,7 +10,7 @@ permissions:
|
||||
jobs:
|
||||
changes:
|
||||
name: Check changed files
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
name: Type Check
|
||||
needs: changes
|
||||
if: needs.changes.outputs.server == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
10
.github/workflows/server-unit-tests.yml
vendored
10
.github/workflows/server-unit-tests.yml
vendored
@@ -2,8 +2,6 @@ name: Server Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,7 +10,7 @@ permissions:
|
||||
jobs:
|
||||
changes:
|
||||
name: Check changed files
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
@@ -32,7 +30,7 @@ jobs:
|
||||
name: Unit Tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.server == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -41,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
|
||||
|
||||
4
.github/workflows/validate-schema.yml
vendored
4
.github/workflows/validate-schema.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect schema-relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
schema: ${{ steps.filter.outputs.schema }}
|
||||
steps:
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
validate-schema:
|
||||
name: Validate Schema
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Skip (no schema-relevant changes)
|
||||
|
||||
26
.github/workflows/vite-build.yml
vendored
26
.github/workflows/vite-build.yml
vendored
@@ -10,7 +10,7 @@ permissions:
|
||||
jobs:
|
||||
changes:
|
||||
name: Check changed files
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
vite: ${{ steps.filter.outputs.vite }}
|
||||
steps:
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
- "bun.lock"
|
||||
- ".github/workflows/vite-build.yml"
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
build:
|
||||
name: Build & Type Check
|
||||
needs: changes
|
||||
if: needs.changes.outputs.vite == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -49,23 +49,5 @@ jobs:
|
||||
- name: Run TypeScript type check
|
||||
run: cd vite && bunx tsc --noEmit
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: changes
|
||||
if: needs.changes.outputs.vite == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build Vite
|
||||
run: cd vite && bunx vite build
|
||||
|
||||
6
.github/workflows/vite-unit-tests.yml
vendored
6
.github/workflows/vite-unit-tests.yml
vendored
@@ -2,8 +2,6 @@ name: Vite Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "vite/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,7 +10,7 @@ permissions:
|
||||
jobs:
|
||||
changes:
|
||||
name: Check changed files
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
vite: ${{ steps.filter.outputs.vite }}
|
||||
steps:
|
||||
@@ -34,7 +32,7 @@ jobs:
|
||||
name: Unit Tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.vite == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,6 +21,9 @@ supabase.sh
|
||||
**/.env*
|
||||
tests/
|
||||
!server/tests
|
||||
!packages/mcp/tests
|
||||
!packages/ai-sdk/tests
|
||||
!apps/leaf/tests
|
||||
!vite/tests
|
||||
.secrets
|
||||
|
||||
|
||||
176
.plans/stripe-subscription-item-consolidation.md
Normal file
176
.plans/stripe-subscription-item-consolidation.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Plan: Stripe Subscription Item Consolidation
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce noisy duplicate Stripe subscription items when Autumn attaches multiple customer products with identical billing semantics.
|
||||
|
||||
Today, multi-attach prepaid add-ons can create one Stripe price and one Stripe subscription item per customer product. In Stripe this looks like four separate lines even when the customer really bought two identical monthly packs and two identical annual packs:
|
||||
|
||||
- prepaid credits `$10 / month`
|
||||
- prepaid credits `$10 / month`
|
||||
- prepaid workflows `$10 / year`
|
||||
- prepaid workflows `$10 / year`
|
||||
|
||||
The target shape is fewer Stripe subscription items with quantities or shared price resources, while Autumn still tracks each customer product, customer price, entitlement, cancellation, and renewal independently.
|
||||
|
||||
## Current Problem
|
||||
|
||||
Autumn often treats each attached customer product as its own concrete billing object. That is useful internally, but it leaks into Stripe:
|
||||
|
||||
- duplicate prepaid add-ons produce separate Stripe subscription items
|
||||
- customized or customer-specific prices produce separate Stripe price IDs even when the price shape is identical
|
||||
- schedules become harder to reason about because future phases contain repeated inline prices
|
||||
- Stripe dashboard pricing tables become noisy and hard to audit
|
||||
|
||||
This also interacts with schedule updates. If each duplicate has its own subscription item, cancellation or phase replacement must preserve item identity carefully to avoid Stripe resetting item periods or creating unexpected prorations.
|
||||
|
||||
## Proposed Model
|
||||
|
||||
Introduce a Stripe-side aggregation layer between Autumn customer prices and Stripe subscription item specs.
|
||||
|
||||
Autumn should still create and store one `customer_product` and one or more `customer_prices` per attached product. Stripe does not need to receive one subscription item for every `customer_price` when multiple prices are billing-equivalent.
|
||||
|
||||
Group billable specs by a strict `StripeSubscriptionItemAggregationKey`:
|
||||
|
||||
- Stripe product identity
|
||||
- currency
|
||||
- recurring interval and interval count
|
||||
- unit amount decimal or tier shape
|
||||
- billing scheme, tiers mode, transform quantity, usage type
|
||||
- tax behavior and tax code if present
|
||||
- discount eligibility or attached discounts
|
||||
- collection behavior relevant to invoice calculation
|
||||
- any metadata that must appear on the Stripe line item
|
||||
|
||||
For each group:
|
||||
|
||||
- create one Stripe subscription item
|
||||
- set quantity to the total billable quantity for the group
|
||||
- persist an Autumn allocation map that records which customer prices are represented by that Stripe item
|
||||
|
||||
## Allocation Map
|
||||
|
||||
Collapsing requires an explicit mapping because Stripe item metadata cannot safely represent many customer prices forever.
|
||||
|
||||
Candidate storage options:
|
||||
|
||||
- add a table mapping `stripe_subscription_item_id -> customer_price_id`
|
||||
- extend an existing customer price billing reference table if one exists
|
||||
- store only current mappings in Autumn DB, not in Stripe metadata
|
||||
|
||||
The map should support:
|
||||
|
||||
- cancel one attached copy by decrementing Stripe quantity
|
||||
- cancel all attached copies by deleting the Stripe item
|
||||
- distinguish two customer products that share the same underlying `autumn_price_id`
|
||||
- rebuild the mapping during sync or migration
|
||||
- explain an invoice line item back to all represented customer prices
|
||||
|
||||
Stripe metadata can keep summary fields for debugging, but should not be the source of truth for many-to-one identity.
|
||||
|
||||
## Zero-Dollar Items
|
||||
|
||||
Do not create Stripe subscription items for `$0` recurring prices when another subscription item with the same billing interval can safely carry the period.
|
||||
|
||||
This should be conservative:
|
||||
|
||||
- omit only if the zero-dollar item has no Stripe-visible invoice effect
|
||||
- omit only if Autumn can derive its period from another same-interval item
|
||||
- keep the item if it is the only Stripe object establishing that interval
|
||||
- keep the item if Stripe item identity is needed for schedule transitions, tax, discounts, trials, or external reporting
|
||||
|
||||
Zero-dollar omission should be a separate branch from paid-item consolidation. It is related, but riskier because the Stripe item disappears entirely.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not collapse items that merely look similar in the dashboard. They must be identical for Stripe invoice math.
|
||||
|
||||
Do not collapse across different intervals, currencies, Stripe products, tax behavior, discount behavior, or tier shapes.
|
||||
|
||||
Do not use `autumn_price_id` alone as the aggregation key. Two customer products can share the same Autumn price but still require separate Autumn lifecycle tracking.
|
||||
|
||||
Do not rely on a JSON blob in Stripe metadata as the durable allocation source. Metadata limits and sync behavior make that brittle.
|
||||
|
||||
## Implementation Shape
|
||||
|
||||
Add a dedicated aggregation step before converting Autumn billing specs into Stripe subscription items.
|
||||
|
||||
Likely areas:
|
||||
|
||||
- `server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/`
|
||||
- `server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/`
|
||||
- `server/src/internal/billing/v2/providers/stripe/utils/matchUtils/`
|
||||
|
||||
Candidate helpers:
|
||||
|
||||
```ts
|
||||
buildStripeSubscriptionItemAggregationKey(spec)
|
||||
collapseStripeItemSpecs(specs)
|
||||
expandStripeInvoiceLineAllocations(lineItem)
|
||||
```
|
||||
|
||||
The aggregation key should reuse the same price-shape comparison utilities we use for inline price matching. If the shape comparison says two inline prices are not identical, aggregation must not collapse them.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
Start with new subscriptions only.
|
||||
|
||||
For existing subscriptions:
|
||||
|
||||
1. Detect groups of active Stripe subscription items that are aggregation-equivalent.
|
||||
2. Preview the Stripe update that would replace them with one item and a higher quantity.
|
||||
3. Apply only if the preview has no invoice impact.
|
||||
4. Persist the allocation map.
|
||||
5. Leave ambiguous subscriptions untouched.
|
||||
|
||||
No migration should merge items if it would create prorations, change periods, or lose invoice-line attribution.
|
||||
|
||||
## Schedule Behavior
|
||||
|
||||
Schedule phases should collapse independently. A Stripe item can represent many Autumn customer prices within the same phase, but future phases must not accidentally reuse the same allocation map if membership changes.
|
||||
|
||||
Examples:
|
||||
|
||||
- Phase 1 has two identical monthly prepaid credit packs: one Stripe item, quantity `2`.
|
||||
- Phase 2 cancels one pack: same Stripe item, quantity `1`.
|
||||
- Phase 3 adds another identical pack: same billing key, quantity `2`, allocation map updated for that phase.
|
||||
|
||||
For inline prices, reuse the existing Stripe subscription item only when the collapsed group still matches the current item shape and period semantics.
|
||||
|
||||
## Test Matrix
|
||||
|
||||
Add tests before implementing:
|
||||
|
||||
- two identical monthly prepaid add-ons collapse to one Stripe item with quantity `2`
|
||||
- two identical annual prepaid add-ons collapse to one Stripe item with quantity `2`
|
||||
- cancel one of two identical monthly add-ons decrements quantity and invoices one renewal
|
||||
- cancel one of two identical annual add-ons decrements quantity without charging annual renewal
|
||||
- monthly and annual identical-looking add-ons do not collapse together
|
||||
- same interval but different amount does not collapse
|
||||
- same amount but different feature or Stripe product does not collapse unless explicitly allowed by product identity
|
||||
- customized inline prices collapse only when exact Stripe price shape matches
|
||||
- entity-scoped add-ons preserve allocation and entitlement ownership
|
||||
- invoice line item matching can explain a collapsed Stripe line back to all represented customer prices
|
||||
- checkout sessions either use the same aggregation rule or explicitly opt out
|
||||
- subscription schedules preserve correct quantities across phase changes
|
||||
- zero-dollar recurring item is omitted only when a same-interval carrier item exists
|
||||
- zero-dollar-only interval keeps a Stripe item or an explicit Autumn period source
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Stripe dashboard shows one line per billing-equivalent group, not one line per Autumn customer product.
|
||||
- Autumn can still cancel, renew, migrate, and explain each customer product independently.
|
||||
- Invoice totals are unchanged compared with the uncollapsed representation.
|
||||
- Schedule phase transitions do not create extra prorations from aggregation changes alone.
|
||||
- Sync and restore can rebuild or validate the allocation map.
|
||||
- Existing multi-attach, checkout, invoice-line-item, schedule, and migration test groups pass.
|
||||
|
||||
## Risks
|
||||
|
||||
The main risk is losing one-to-one identity. Stripe has one subscription item, but Autumn may have many customer prices behind it. Every path that currently assumes `customer_price -> stripe_subscription_item` is one-to-one must be audited.
|
||||
|
||||
Changing quantity can have different Stripe proration behavior than deleting one item and keeping another. This must be covered with invoice previews and test clocks.
|
||||
|
||||
Zero-dollar omission can break period derivation if Autumn currently depends on a Stripe subscription item to know renewal timing. That needs a clear replacement source before removing those items.
|
||||
|
||||
This is a cleanup and correctness project, not just a dashboard polish. The implementation should be gated behind tests and probably a feature flag.
|
||||
6
.vscode/tasks.json
vendored
6
.vscode/tasks.json
vendored
@@ -4,21 +4,21 @@
|
||||
{
|
||||
"label": "Run Test Pattern",
|
||||
"type": "shell",
|
||||
"command": "infisical run --env=dev --recursive -- bun test ${relativeFile} -t \"${input:testPattern}\"",
|
||||
"command": "./run.sh \"${file}\" -t \"${input:testPattern}\"",
|
||||
"options": { "env": { "NODE_ENV": "development" } },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run Describe at Cursor",
|
||||
"type": "shell",
|
||||
"command": "infisical run --env=dev --recursive -- bun test ${relativeFile} --timeout 0 -t \"$(bun scripts/testScripts/getDescribeAtCursor.ts ${file} ${lineNumber})\"",
|
||||
"command": "./run.sh \"${file}\" ${lineNumber}",
|
||||
"options": { "env": { "NODE_ENV": "development" } },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run Current Test File",
|
||||
"type": "shell",
|
||||
"command": "infisical run --env=dev --recursive -- bun test ${relativeFile}",
|
||||
"command": "./run.sh \"${file}\"",
|
||||
"options": { "env": { "NODE_ENV": "development" } },
|
||||
"problemMatcher": []
|
||||
}
|
||||
|
||||
2
ai
2
ai
Submodule ai updated: 0d561b1747...bca809a307
@@ -413,6 +413,14 @@ This is useful for attaching custom metadata to the Stripe subscription created
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -380,6 +380,14 @@ const response = await autumn.billing.update({
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -32,6 +32,28 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="discounts" type="object[]">
|
||||
List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.
|
||||
<Expandable title="properties">
|
||||
<DynamicParamField body="reward_id" type="string">
|
||||
The ID of the reward to apply as a discount.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="promotion_code" type="string">
|
||||
The promotion code to apply as a discount.
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -226,6 +226,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -346,6 +346,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -226,6 +226,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -346,6 +346,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="invoice_template_id" type="string">
|
||||
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
|
||||
</DynamicParamField>
|
||||
|
||||
<DynamicParamField body="net_terms_days" type="integer">
|
||||
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
|
||||
</DynamicParamField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicParamField>
|
||||
|
||||
|
||||
@@ -512,6 +512,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -827,6 +831,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -666,6 +666,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -981,6 +985,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -537,6 +537,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -852,6 +856,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -654,6 +654,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -969,6 +973,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -619,6 +619,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -933,6 +937,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -391,6 +391,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -705,6 +709,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -420,6 +420,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -734,6 +738,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -455,6 +455,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units of this subscription (for per-seat plans).
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this subscription is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
@@ -769,6 +773,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
|
||||
Number of units purchased.
|
||||
</DynamicResponseField>
|
||||
|
||||
<DynamicResponseField name="scope" type="'customer' | 'entity'">
|
||||
Whether this purchase is attached at the customer level or entity level.
|
||||
</DynamicResponseField>
|
||||
|
||||
</Expandable>
|
||||
</DynamicResponseField>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -482,6 +482,13 @@ components:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -519,6 +526,13 @@ components:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -2234,6 +2248,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -2272,6 +2293,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -3114,6 +3142,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -3152,6 +3187,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -3972,6 +4014,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -4010,6 +4059,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -8313,6 +8369,17 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates a draft or open invoice and sends it to the
|
||||
@@ -8628,10 +8695,37 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates and sends an invoice instead of charging the
|
||||
customer's payment method immediately for the first phase.
|
||||
discounts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
reward_id:
|
||||
type: string
|
||||
description: The ID of the reward to apply as a discount.
|
||||
promotion_code:
|
||||
type: string
|
||||
description: The promotion code to apply as a discount.
|
||||
title: AttachDiscount
|
||||
description: A discount to apply. Can be either a reward ID or a promotion code.
|
||||
description: List of discounts to apply to the immediate phase. Each discount
|
||||
can be an Autumn reward ID, Stripe coupon ID, or Stripe
|
||||
promotion code.
|
||||
success_url:
|
||||
type: string
|
||||
description: URL to redirect to after successful checkout.
|
||||
@@ -9654,6 +9748,17 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates a draft or open invoice and sends it to the
|
||||
@@ -10420,6 +10525,17 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates a draft or open invoice and sends it to the
|
||||
@@ -11327,6 +11443,17 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates a draft or open invoice and sends it to the
|
||||
@@ -12418,6 +12545,17 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates a draft or open invoice and sends it to the
|
||||
@@ -13104,6 +13242,17 @@ paths:
|
||||
default: true
|
||||
description: If true, finalizes the invoice so it can be sent to the customer.
|
||||
If false, keeps it as a draft for manual review.
|
||||
invoice_template_id:
|
||||
type: string
|
||||
description: ID of an invoice template (configured in billing settings) whose
|
||||
footer (e.g. bank details) is applied to the invoice.
|
||||
net_terms_days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
exclusiveMinimum: 0
|
||||
description: Number of days the customer has to pay the invoice before it is due
|
||||
(Stripe days_until_due).
|
||||
required:
|
||||
- enabled
|
||||
description: Invoice mode creates a draft or open invoice and sends it to the
|
||||
@@ -17247,6 +17396,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -17283,6 +17439,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -17737,6 +17900,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -17773,6 +17943,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -18272,6 +18449,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -18308,6 +18492,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -18843,6 +19034,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units of this subscription (for per-seat plans).
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this subscription is attached at the customer level or
|
||||
entity level.
|
||||
required:
|
||||
- id
|
||||
- plan_id
|
||||
@@ -18879,6 +19077,13 @@ paths:
|
||||
quantity:
|
||||
type: number
|
||||
description: Number of units purchased.
|
||||
scope:
|
||||
enum:
|
||||
- customer
|
||||
- entity
|
||||
type: string
|
||||
description: Whether this purchase is attached at the customer level or entity
|
||||
level.
|
||||
required:
|
||||
- plan_id
|
||||
- expires_at
|
||||
@@ -19539,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
|
||||
|
||||
|
||||
@@ -33,16 +33,16 @@ bun add @useautumn/ai-sdk
|
||||
|
||||
#### 2. Wrap your model
|
||||
|
||||
Use `withTokenTracking` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically.
|
||||
Use `withAutumn` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically.
|
||||
|
||||
```typescript
|
||||
import { Autumn } from "autumn-js";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { withTokenTracking } from "@useautumn/ai-sdk";
|
||||
import { withAutumn } from "@useautumn/ai-sdk";
|
||||
|
||||
const autumn = new Autumn({ secretKey: "am_sk_test_1234" });
|
||||
|
||||
const model = withTokenTracking({
|
||||
const model = withAutumn({
|
||||
autumn,
|
||||
model: anthropic("claude-sonnet-4-5-20250514"),
|
||||
customerId: "user_123",
|
||||
@@ -89,7 +89,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
|
||||
const openrouter = createOpenRouter();
|
||||
|
||||
const model = withTokenTracking({
|
||||
const model = withAutumn({
|
||||
autumn,
|
||||
model: openrouter("anthropic/claude-opus-4-6"),
|
||||
customerId: "user_123",
|
||||
@@ -116,12 +116,12 @@ const model = withTokenTracking({
|
||||
import { Autumn } from "autumn-js";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText } from "ai";
|
||||
import { withTokenTracking } from "@useautumn/ai-sdk";
|
||||
import { withAutumn } from "@useautumn/ai-sdk";
|
||||
|
||||
const autumn = new Autumn({ secretKey: process.env.AUTUMN_SECRET_KEY! });
|
||||
|
||||
async function chat(customerId: string, message: string) {
|
||||
const model = withTokenTracking({
|
||||
const model = withAutumn({
|
||||
autumn,
|
||||
model: openai("gpt-4o"),
|
||||
customerId,
|
||||
|
||||
53
apps/leaf/README.md
Normal file
53
apps/leaf/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Autumn Leaf
|
||||
|
||||
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.
|
||||
|
||||
## Slack test app
|
||||
|
||||
1. Start the stable local tunnel for the server. It forwards Slack requests to the chat app in local development:
|
||||
|
||||
```sh
|
||||
bun run chat:tunnel
|
||||
```
|
||||
|
||||
2. Start Autumn with the same public URL:
|
||||
|
||||
```sh
|
||||
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. `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`.
|
||||
|
||||
For production, use `slack-manifest.prod.json`. It points Slack at `https://api.useautumn.com/slack/*`, which the API proxies to the chat service.
|
||||
|
||||
4. Copy the Slack app credentials into the local environment:
|
||||
|
||||
```sh
|
||||
SLACK_CLIENT_ID=...
|
||||
SLACK_CLIENT_SECRET=...
|
||||
SLACK_SIGNING_SECRET=...
|
||||
```
|
||||
|
||||
5. In Autumn, open Settings -> Integrations and click Add Slack.
|
||||
|
||||
6. Test in Slack by DMing the app:
|
||||
|
||||
```txt
|
||||
list customers
|
||||
```
|
||||
|
||||
For billing changes, the app should post a preview with Approve and Cancel buttons before calling a write tool.
|
||||
42
apps/leaf/package.json
Normal file
42
apps/leaf/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@autumn/leaf",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"eval": "zsh -lc 'braintrust eval tests/evals/**/*.eval.ts --external-packages @mastra/mcp @mastra/core @mastra/braintrust @mastra/observability \"$@\"' --",
|
||||
"eval:mcp": "zsh -lc 'braintrust eval tests/evals/mcp/**/*.eval.ts --external-packages @mastra/mcp @mastra/core @mastra/braintrust @mastra/observability \"$@\"' --",
|
||||
"start": "bun src/index.ts",
|
||||
"test": "bun test tests/unit",
|
||||
"ts": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@autumn/auth": "workspace:*",
|
||||
"@autumn/logging": "workspace:*",
|
||||
"@autumn/mcp": "workspace:*",
|
||||
"@autumn/shared": "workspace:*",
|
||||
"@chat-adapter/slack": "^4.29.0",
|
||||
"@chat-adapter/state-pg": "^4.29.0",
|
||||
"@hono/node-server": "^1.19.5",
|
||||
"@mastra/braintrust": "^1.1.3",
|
||||
"@mastra/core": "^1.36.0",
|
||||
"@mastra/mcp": "^1.8.0",
|
||||
"@mastra/observability": "^1.14.1",
|
||||
"@mendable/firecrawl-js": "^4.25.1",
|
||||
"autoevals": "^0.0.132",
|
||||
"braintrust": "^3.14.0",
|
||||
"chat": "^4.29.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"e2b": "^2.8.4",
|
||||
"hono": "4.12.7",
|
||||
"postgres": "catalog:",
|
||||
"zod": "^3.25.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.1",
|
||||
"@types/node": "^25.0.7",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
53
apps/leaf/slack-manifest.example.json
Normal file
53
apps/leaf/slack-manifest.example.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"display_information": {
|
||||
"name": "Autumn Chat Local"
|
||||
},
|
||||
"features": {
|
||||
"bot_user": {
|
||||
"display_name": "Autumn",
|
||||
"always_online": false
|
||||
}
|
||||
},
|
||||
"oauth_config": {
|
||||
"redirect_urls": ["https://c.autumn.ngrok.app/slack/oauth/callback"],
|
||||
"scopes": {
|
||||
"bot": [
|
||||
"app_mentions:read",
|
||||
"assistant:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"mpim:history",
|
||||
"mpim:read",
|
||||
"users:read"
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"event_subscriptions": {
|
||||
"request_url": "https://c.autumn.ngrok.app/slack/events",
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"assistant_thread_started",
|
||||
"assistant_thread_context_changed",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
"message.mpim"
|
||||
]
|
||||
},
|
||||
"interactivity": {
|
||||
"is_enabled": true,
|
||||
"request_url": "https://c.autumn.ngrok.app/slack/interactions"
|
||||
},
|
||||
"org_deploy_enabled": false,
|
||||
"socket_mode_enabled": false,
|
||||
"is_hosted": false
|
||||
}
|
||||
}
|
||||
53
apps/leaf/slack-manifest.prod.json
Normal file
53
apps/leaf/slack-manifest.prod.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"display_information": {
|
||||
"name": "Autumn"
|
||||
},
|
||||
"features": {
|
||||
"bot_user": {
|
||||
"display_name": "Autumn",
|
||||
"always_online": false
|
||||
}
|
||||
},
|
||||
"oauth_config": {
|
||||
"redirect_urls": ["https://api.useautumn.com/slack/oauth/callback"],
|
||||
"scopes": {
|
||||
"bot": [
|
||||
"app_mentions:read",
|
||||
"assistant:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"mpim:history",
|
||||
"mpim:read",
|
||||
"users:read"
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"event_subscriptions": {
|
||||
"request_url": "https://api.useautumn.com/slack/events",
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"assistant_thread_started",
|
||||
"assistant_thread_context_changed",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
"message.mpim"
|
||||
]
|
||||
},
|
||||
"interactivity": {
|
||||
"is_enabled": true,
|
||||
"request_url": "https://api.useautumn.com/slack/interactions"
|
||||
},
|
||||
"org_deploy_enabled": false,
|
||||
"socket_mode_enabled": false,
|
||||
"is_hosted": false
|
||||
}
|
||||
}
|
||||
246
apps/leaf/src/agent/agent.ts
Normal file
246
apps/leaf/src/agent/agent.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import type { MessageListInput } from "@mastra/core/agent/message-list";
|
||||
import { Mastra } from "@mastra/core/mastra";
|
||||
import { InMemoryStore } from "@mastra/core/storage";
|
||||
import { z } from "zod";
|
||||
import { createLeafTracingOptions } from "../internal/observability/leafTracingOptions.js";
|
||||
import { env as chatEnv } from "../lib/env.js";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
import { createMastraBraintrustObservability } from "../providers/braintrust/index.js";
|
||||
import { createE2bSandboxProvider } from "../providers/e2b/e2bSandboxProvider.js";
|
||||
import type { ChatContextMessage } from "../types.js";
|
||||
import { createFirecrawlTools } from "./firecrawl.js";
|
||||
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
|
||||
import { sandboxConfig } from "./sandbox/config.js";
|
||||
import { createSandboxTools } from "./sandbox/createSandboxTools.js";
|
||||
import { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
|
||||
|
||||
export { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
|
||||
|
||||
const envSelectionSchema = z.strictObject({
|
||||
env: z.nativeEnum(AppEnv),
|
||||
});
|
||||
|
||||
export const getDefaultChatEnv = () =>
|
||||
process.env.NODE_ENV === "production" ? AppEnv.Live : AppEnv.Sandbox;
|
||||
|
||||
const recentMessageContext = (messages: ChatContextMessage[] = []) =>
|
||||
messages.map((message) => ({
|
||||
role: message.isBot === true ? ("assistant" as const) : ("user" as const),
|
||||
content: `${message.author}${message.isBot === true ? " (bot)" : ""}: ${message.text}`,
|
||||
}));
|
||||
|
||||
export const selectChatEnv = async ({
|
||||
logger = rootLogger,
|
||||
message,
|
||||
recentMessages,
|
||||
select,
|
||||
}: {
|
||||
logger?: AutumnLogger;
|
||||
message: string;
|
||||
recentMessages?: ChatContextMessage[];
|
||||
select?: () => Promise<unknown> | unknown;
|
||||
}) => {
|
||||
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",
|
||||
name: "Autumn Chat Env",
|
||||
instructions: `Choose the Autumn environment for the latest user request. Default to ${getDefaultChatEnv()}. Use the other environment only when the user clearly asks for it.`,
|
||||
model: chatEnv.CHAT_MODEL,
|
||||
});
|
||||
const output = await agent.generate(message, {
|
||||
maxSteps: 1,
|
||||
structuredOutput: {
|
||||
schema: envSelectionSchema,
|
||||
instructions: `Return ${getDefaultChatEnv()} unless the latest user request clearly asks to use the other environment.`,
|
||||
},
|
||||
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;
|
||||
};
|
||||
|
||||
const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
|
||||
const resources = await Promise.allSettled(
|
||||
agentDocUris.map((uri) => mcp.resources.read("autumn", uri)),
|
||||
);
|
||||
return resources
|
||||
.flatMap((result) =>
|
||||
result.status === "fulfilled"
|
||||
? result.value.contents.flatMap((content) =>
|
||||
"text" in content ? [content.text] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
export const runChatAgent = async ({
|
||||
token,
|
||||
env,
|
||||
logger = rootLogger,
|
||||
message,
|
||||
channelId,
|
||||
threadId,
|
||||
resourceId,
|
||||
onAction,
|
||||
provider,
|
||||
workspaceId,
|
||||
recentMessages,
|
||||
agentRunId,
|
||||
orgSlug,
|
||||
}: {
|
||||
token: string;
|
||||
env: AppEnv;
|
||||
logger?: AutumnLogger;
|
||||
message: MessageListInput;
|
||||
channelId: string;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
provider: string;
|
||||
workspaceId: string;
|
||||
agentRunId?: string;
|
||||
orgSlug?: string | null;
|
||||
recentMessages?: ChatContextMessage[];
|
||||
}) => {
|
||||
const mcp = createAutumnMcpClient({
|
||||
token,
|
||||
appEnv: env,
|
||||
options: { requireApproval: true },
|
||||
});
|
||||
let previewApproval:
|
||||
| {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
preview: unknown;
|
||||
}
|
||||
| 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,
|
||||
options: {
|
||||
applyApprovalPolicy: true,
|
||||
logger,
|
||||
onToolCall: onAction,
|
||||
onPreview: (approval) => {
|
||||
previewApproval = approval;
|
||||
},
|
||||
},
|
||||
}),
|
||||
readDocs(mcp),
|
||||
]);
|
||||
const firecrawlTools = createFirecrawlTools({
|
||||
apiKey: chatEnv.FIRECRAWL_API_KEY,
|
||||
onAction,
|
||||
});
|
||||
const sandboxTools =
|
||||
sandboxConfig.enabled && chatEnv.E2B_API_KEY
|
||||
? createSandboxTools({
|
||||
logger,
|
||||
onAction,
|
||||
provider: createE2bSandboxProvider({
|
||||
apiKey: chatEnv.E2B_API_KEY,
|
||||
context: {
|
||||
channelId,
|
||||
env,
|
||||
orgId: resourceId,
|
||||
provider,
|
||||
threadId,
|
||||
workspaceId,
|
||||
},
|
||||
sessionTimeoutMs: sandboxConfig.sessionTimeoutMs,
|
||||
}),
|
||||
})
|
||||
: {};
|
||||
if (sandboxConfig.enabled && !chatEnv.E2B_API_KEY) {
|
||||
logger.warn("Sandbox is enabled without an E2B API key", {
|
||||
event: "leaf.sandbox_disabled",
|
||||
});
|
||||
}
|
||||
await onAction?.("Reasoning over the request");
|
||||
const agent = createAutumnChatAgent({
|
||||
docsText,
|
||||
env,
|
||||
model: chatEnv.CHAT_MODEL,
|
||||
tools: { ...tools, ...firecrawlTools, ...sandboxTools },
|
||||
});
|
||||
const mastra = new Mastra({
|
||||
agents: { chat: agent },
|
||||
environment: process.env.NODE_ENV,
|
||||
logger: false,
|
||||
observability: createMastraBraintrustObservability(),
|
||||
storage: new InMemoryStore({ id: `leaf-chat-${crypto.randomUUID()}` }),
|
||||
});
|
||||
const chatAgent = mastra.getAgent("chat");
|
||||
|
||||
const output = await chatAgent.generate(message, {
|
||||
maxSteps: 8,
|
||||
context: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
`${provider} thread: ${threadId}. Autumn resource: ${resourceId}.`,
|
||||
"Answer the latest user message. Use prior thread messages only as context.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
},
|
||||
...recentMessageContext(recentMessages),
|
||||
],
|
||||
tracingOptions: createLeafTracingOptions({
|
||||
agentRunId,
|
||||
channelId,
|
||||
env,
|
||||
orgId: resourceId,
|
||||
orgSlug,
|
||||
provider,
|
||||
source: "prod",
|
||||
threadId,
|
||||
workspaceId,
|
||||
}),
|
||||
});
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
139
apps/leaf/src/agent/attachments.ts
Normal file
139
apps/leaf/src/agent/attachments.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import type { MessageListInput } from "@mastra/core/agent/message-list";
|
||||
import type { Attachment } from "chat";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
|
||||
const MAX_ATTACHMENTS = 4;
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
const SUPPORTED_MIME_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
type AttachmentFetchFallback = ({
|
||||
attachment,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
}) => Promise<Buffer | null>;
|
||||
|
||||
const getAttachmentLabel = (attachment: Attachment) =>
|
||||
attachment.name ?? attachment.mimeType ?? "unnamed attachment";
|
||||
|
||||
const isSupportedAttachment = (attachment: Attachment) =>
|
||||
attachment.mimeType ? SUPPORTED_MIME_TYPES.has(attachment.mimeType) : false;
|
||||
|
||||
const fetchAttachmentData = async ({
|
||||
attachment,
|
||||
fetchFallback,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
fetchFallback?: AttachmentFetchFallback;
|
||||
}) => {
|
||||
if (attachment.data) {
|
||||
return attachment.data instanceof Blob
|
||||
? Buffer.from(await attachment.data.arrayBuffer())
|
||||
: Buffer.from(attachment.data);
|
||||
}
|
||||
if (attachment.fetchData) return attachment.fetchData();
|
||||
return fetchFallback?.({ attachment }) ?? null;
|
||||
};
|
||||
|
||||
export const prepareAttachmentMessage = async ({
|
||||
attachments = [],
|
||||
fetchFallback,
|
||||
logger = rootLogger,
|
||||
text,
|
||||
}: {
|
||||
attachments?: Attachment[];
|
||||
fetchFallback?: AttachmentFetchFallback;
|
||||
logger?: AutumnLogger;
|
||||
text: string;
|
||||
}) => {
|
||||
const notes: string[] = [];
|
||||
const parts: Array<{
|
||||
data: Buffer;
|
||||
filename?: string;
|
||||
mediaType: string;
|
||||
type: "file";
|
||||
}> = [];
|
||||
|
||||
for (const attachment of attachments.slice(0, MAX_ATTACHMENTS)) {
|
||||
const label = getAttachmentLabel(attachment);
|
||||
if (!isSupportedAttachment(attachment)) {
|
||||
notes.push(`Skipped ${label}: unsupported file type.`);
|
||||
continue;
|
||||
}
|
||||
if (attachment.size && attachment.size > MAX_ATTACHMENT_BYTES) {
|
||||
notes.push(`Skipped ${label}: file is too large.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAttachmentData({ attachment, fetchFallback });
|
||||
if (!data) {
|
||||
notes.push(`Skipped ${label}: file could not be downloaded.`);
|
||||
continue;
|
||||
}
|
||||
if (data.byteLength > MAX_ATTACHMENT_BYTES) {
|
||||
notes.push(`Skipped ${label}: downloaded file is too large.`);
|
||||
continue;
|
||||
}
|
||||
parts.push({
|
||||
type: "file",
|
||||
data,
|
||||
filename: attachment.name,
|
||||
mediaType: attachment.mimeType as string,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Could not prepare Slack attachment", {
|
||||
event: "leaf.slack_attachment_prepare_failed",
|
||||
data: {
|
||||
name: attachment.name,
|
||||
mime_type: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
},
|
||||
error,
|
||||
});
|
||||
notes.push(`Skipped ${label}: file could not be processed.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length > MAX_ATTACHMENTS) {
|
||||
notes.push(
|
||||
`Skipped ${attachments.length - MAX_ATTACHMENTS} extra attachment(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
const userText = [
|
||||
text.trim() || "Please answer using the attached Slack file(s).",
|
||||
notes.length ? `Attachment processing notes:\n${notes.join("\n")}` : null,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n\n");
|
||||
const message = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [...parts, { type: "text" as const, text: userText }],
|
||||
},
|
||||
] satisfies MessageListInput;
|
||||
|
||||
return {
|
||||
attachmentCount: parts.length,
|
||||
envSelectionText: [
|
||||
text,
|
||||
attachments.length
|
||||
? `Slack attachments: ${attachments
|
||||
.map((attachment) => getAttachmentLabel(attachment))
|
||||
.join(", ")}`
|
||||
: null,
|
||||
notes.length ? `Attachment notes: ${notes.join(" ")}` : null,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n\n"),
|
||||
message,
|
||||
notes,
|
||||
};
|
||||
};
|
||||
50
apps/leaf/src/agent/chatAgent.ts
Normal file
50
apps/leaf/src/agent/chatAgent.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import type { ToolsInput } from "@mastra/core/agent";
|
||||
|
||||
export const agentDocUris = [
|
||||
"autumn://docs/tool-composition",
|
||||
"autumn://docs/feature-catalog",
|
||||
"autumn://docs/querying-plans",
|
||||
"autumn://docs/querying-customers",
|
||||
"autumn://docs/schedules",
|
||||
"autumn://docs/balances",
|
||||
"autumn://docs/billing-safety",
|
||||
"autumn://docs/request-logs",
|
||||
"autumn://docs/request-log-customers",
|
||||
"autumn://docs/request-log-balances",
|
||||
"autumn://docs/request-log-billing",
|
||||
"autumn://docs/request-log-stripe-webhooks",
|
||||
"autumn://docs/request-log-analytics",
|
||||
];
|
||||
|
||||
export const autumnChatInstructions = `You are Autumn Chat.
|
||||
Use Autumn MCP tools for customer, plan, balance, schedule, and billing work.
|
||||
Use web search only for current or external web context. Never use web search for Autumn customer, plan, billing, balance, or schedule state.
|
||||
When web content influences the answer, cite the source URLs.
|
||||
Prefer searchWeb first, then scrapeUrl only for the most relevant result.
|
||||
Use listFeatures only when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids/types are not already known; never invent feature ids.
|
||||
Use the sandbox only for short parsing, calculation, transformation, and file-analysis tasks. Never send secrets to the sandbox, never use it for Autumn writes, and treat sandbox output as advisory.
|
||||
Preview billing-impacting changes first, summarize the preview in short Slack-friendly bullets, then call the matching write tool with the same request args.
|
||||
When Autumn responses include epoch millisecond timestamps, use epochMillisecondsToDate before explaining those timestamps to a user.
|
||||
Treat Slack PDFs and images attached to the latest message as part of the user's request. If an attachment was skipped or unavailable, say so briefly instead of pretending to have read it.
|
||||
The runtime pauses destructive tools for approval before execution, so do not ask for confirmation in plain text.`;
|
||||
|
||||
export const createAutumnChatAgent = ({
|
||||
docsText,
|
||||
env,
|
||||
model,
|
||||
tools,
|
||||
}: {
|
||||
docsText: string;
|
||||
env: AppEnv;
|
||||
model: string;
|
||||
tools: ToolsInput;
|
||||
}) =>
|
||||
new Agent({
|
||||
id: "autumn-chat",
|
||||
name: "Autumn Chat",
|
||||
instructions: `${autumnChatInstructions}\n\nCurrent Autumn environment: ${env}.\n\n${docsText}`,
|
||||
model,
|
||||
tools,
|
||||
});
|
||||
89
apps/leaf/src/agent/firecrawl.ts
Normal file
89
apps/leaf/src/agent/firecrawl.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import Firecrawl from "@mendable/firecrawl-js";
|
||||
import { z } from "zod";
|
||||
|
||||
type FirecrawlClient = {
|
||||
search: (
|
||||
query: string,
|
||||
options?: { limit?: number; sources?: ["web"] },
|
||||
) => Promise<{ web?: unknown[] }>;
|
||||
scrape: (
|
||||
url: string,
|
||||
options?: { formats?: ["markdown"] },
|
||||
) => Promise<{
|
||||
markdown?: string;
|
||||
metadata?: { title?: string; sourceURL?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
const maxSearchResults = 5;
|
||||
const maxMarkdownLength = 12_000;
|
||||
|
||||
const trimMarkdown = (markdown = "") =>
|
||||
markdown
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
.slice(0, maxMarkdownLength);
|
||||
|
||||
const stringField = (value: unknown, field: string) =>
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)[field]
|
||||
: undefined;
|
||||
|
||||
export const createFirecrawlTools = ({
|
||||
apiKey,
|
||||
client,
|
||||
onAction,
|
||||
}: {
|
||||
apiKey: string;
|
||||
client?: FirecrawlClient;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
}): Record<string, ReturnType<typeof createTool>> => {
|
||||
const firecrawl = client ?? new Firecrawl({ apiKey });
|
||||
|
||||
return {
|
||||
searchWeb: createTool({
|
||||
id: "searchWeb",
|
||||
description:
|
||||
"Search the public web for current or external information. Use Autumn MCP tools instead for Autumn customer, plan, billing, balance, or schedule data.",
|
||||
inputSchema: z
|
||||
.object({
|
||||
query: z.string().min(1),
|
||||
limit: z.number().int().positive().max(maxSearchResults).optional(),
|
||||
})
|
||||
.strict(),
|
||||
execute: async ({ query, limit = maxSearchResults }) => {
|
||||
await onAction?.("Searching web");
|
||||
const results = await firecrawl.search(query, {
|
||||
limit,
|
||||
sources: ["web"],
|
||||
});
|
||||
return {
|
||||
results: (results.web ?? []).slice(0, limit).map((result) => ({
|
||||
title:
|
||||
stringField(result, "title") ??
|
||||
stringField(result, "url") ??
|
||||
"Untitled",
|
||||
url: stringField(result, "url"),
|
||||
description: stringField(result, "description"),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
scrapeUrl: createTool({
|
||||
id: "scrapeUrl",
|
||||
description:
|
||||
"Read one public web page as markdown after searchWeb identifies a relevant URL.",
|
||||
inputSchema: z.object({ url: z.string().url() }).strict(),
|
||||
execute: async ({ url }) => {
|
||||
await onAction?.("Reading page");
|
||||
const page = await firecrawl.scrape(url, { formats: ["markdown"] });
|
||||
return {
|
||||
title: page.metadata?.title,
|
||||
url: page.metadata?.sourceURL ?? url,
|
||||
markdown: trimMarkdown(page.markdown),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
181
apps/leaf/src/agent/mcp.ts
Normal file
181
apps/leaf/src/agent/mcp.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { isSecretKeyPrefix } from "@autumn/auth";
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { MCPClient } from "@mastra/mcp";
|
||||
import { env } from "../lib/env.js";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
|
||||
|
||||
type AutumnTool = {
|
||||
execute?: (
|
||||
args: Record<string, unknown>,
|
||||
...rest: unknown[]
|
||||
) => Promise<unknown>;
|
||||
mcp?: { annotations?: { destructiveHint?: boolean } };
|
||||
requireApproval?: boolean;
|
||||
needsApprovalFn?: unknown;
|
||||
};
|
||||
|
||||
type ToolOptions = {
|
||||
applyApprovalPolicy?: boolean;
|
||||
logger?: AutumnLogger;
|
||||
onToolCall?: (message: string) => Promise<void> | void;
|
||||
onPreview?: (approval: {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
preview: unknown;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const withAuthFetch =
|
||||
({ appEnv, token }: { appEnv: AppEnv; token: string }) =>
|
||||
(input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
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 = ({
|
||||
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-${token.slice(0, 14)}`,
|
||||
servers: {
|
||||
autumn: {
|
||||
url: new URL("/mcp", env.MCP_SERVER_URL),
|
||||
requestInit: { headers },
|
||||
eventSourceInit: { fetch: fetchWithAuth },
|
||||
fetch: fetchWithAuth,
|
||||
requireToolApproval: options.requireApproval
|
||||
? ({ annotations }) => annotations?.destructiveHint === true
|
||||
: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formatToolAction = ({
|
||||
toolName,
|
||||
args,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}) => {
|
||||
const request =
|
||||
args.request && typeof args.request === "object"
|
||||
? (args.request as Record<string, unknown>)
|
||||
: args;
|
||||
const details = [
|
||||
["customer", request.customer_id],
|
||||
["plan", request.plan_id],
|
||||
["entity", request.entity_id],
|
||||
["search", request.search],
|
||||
].flatMap(([label, value]) =>
|
||||
typeof value === "string" && value ? [`${label}: ${value}`] : [],
|
||||
);
|
||||
|
||||
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
|
||||
};
|
||||
|
||||
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) {
|
||||
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;
|
||||
if (!tool.requireApproval) tool.needsApprovalFn = undefined;
|
||||
}
|
||||
if (tool.execute && (options.onToolCall || options.onPreview)) {
|
||||
const execute = tool.execute.bind(tool);
|
||||
tool.execute = async (args, ...rest) => {
|
||||
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,
|
||||
preview: result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
};
|
||||
|
||||
export const executeAutumnMcpTool = async ({
|
||||
env,
|
||||
token,
|
||||
toolName,
|
||||
args,
|
||||
}: {
|
||||
env: AppEnv;
|
||||
token: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}) => {
|
||||
const mcp = createAutumnMcpClient({ token, appEnv: env });
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
await mcp.disconnect();
|
||||
}
|
||||
};
|
||||
72
apps/leaf/src/agent/messages.ts
Normal file
72
apps/leaf/src/agent/messages.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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";
|
||||
import { prepareAttachmentMessage } from "./attachments.js";
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("Chat agent timed out")),
|
||||
ms,
|
||||
);
|
||||
promise.then(resolve, reject).finally(() => clearTimeout(timeout));
|
||||
});
|
||||
|
||||
export const runMessage = async ({
|
||||
agentRunId,
|
||||
attachmentFetchFallback,
|
||||
attachments,
|
||||
installation,
|
||||
logger = rootLogger,
|
||||
onAction,
|
||||
recentMessages,
|
||||
text,
|
||||
channelId,
|
||||
threadId,
|
||||
}: BotMessage) =>
|
||||
withTimeout(
|
||||
(async () => {
|
||||
const preparedMessage = await prepareAttachmentMessage({
|
||||
attachments,
|
||||
fetchFallback: attachmentFetchFallback,
|
||||
logger,
|
||||
text,
|
||||
});
|
||||
const env = await selectChatEnv({
|
||||
message: preparedMessage.envSelectionText,
|
||||
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({
|
||||
token,
|
||||
env,
|
||||
logger,
|
||||
message: preparedMessage.message,
|
||||
onAction,
|
||||
channelId,
|
||||
threadId,
|
||||
agentRunId,
|
||||
resourceId: installation.org_id,
|
||||
orgSlug: installation.org_slug,
|
||||
provider: installation.provider,
|
||||
workspaceId: installation.workspace_id,
|
||||
recentMessages,
|
||||
}),
|
||||
);
|
||||
})(),
|
||||
60_000,
|
||||
);
|
||||
4
apps/leaf/src/agent/sandbox/config.ts
Normal file
4
apps/leaf/src/agent/sandbox/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const sandboxConfig = {
|
||||
enabled: true,
|
||||
sessionTimeoutMs: 10 * 60 * 1000,
|
||||
};
|
||||
83
apps/leaf/src/agent/sandbox/createSandboxTools.ts
Normal file
83
apps/leaf/src/agent/sandbox/createSandboxTools.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { z } from "zod";
|
||||
import { logger as rootLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
assertSafeSandboxCommand,
|
||||
sandboxLimits,
|
||||
sanitizeReturnFiles,
|
||||
sanitizeSandboxFiles,
|
||||
truncateSandboxResult,
|
||||
} from "./guardrails.js";
|
||||
import type { SandboxProvider } from "./types.js";
|
||||
|
||||
const sandboxFileSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createSandboxTools = ({
|
||||
logger = rootLogger,
|
||||
onAction,
|
||||
provider,
|
||||
}: {
|
||||
logger?: AutumnLogger;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
provider: SandboxProvider;
|
||||
}): Record<string, ReturnType<typeof createTool>> => ({
|
||||
runSandboxCommand: createTool({
|
||||
id: "runSandboxCommand",
|
||||
description:
|
||||
"Run a short command in an isolated sandbox for parsing, calculations, JSON/CSV transforms, and file analysis. Do not use it for Autumn writes, credentials, secrets, or direct API calls.",
|
||||
inputSchema: z
|
||||
.object({
|
||||
task: z.string().min(1),
|
||||
command: z.string().min(1),
|
||||
files: z
|
||||
.array(sandboxFileSchema)
|
||||
.max(sandboxLimits.maxFiles)
|
||||
.optional(),
|
||||
returnFiles: z
|
||||
.array(z.string().min(1))
|
||||
.max(sandboxLimits.maxFiles)
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
execute: async ({ task, command, files = [], returnFiles = [] }) => {
|
||||
await onAction?.("Running sandbox analysis");
|
||||
const startedAt = Date.now();
|
||||
const safeCommand = assertSafeSandboxCommand(command);
|
||||
const safeFiles = sanitizeSandboxFiles(files);
|
||||
const safeReturnFiles = sanitizeReturnFiles(returnFiles);
|
||||
logger.info("Calling sandbox tool", {
|
||||
event: "leaf.sandbox_tool_called",
|
||||
data: {
|
||||
file_count: safeFiles.length,
|
||||
return_file_count: safeReturnFiles.length,
|
||||
command_length: safeCommand.length,
|
||||
task_length: task.length,
|
||||
},
|
||||
});
|
||||
const result = truncateSandboxResult(
|
||||
await provider.run({
|
||||
command: safeCommand,
|
||||
files: safeFiles,
|
||||
returnFiles: safeReturnFiles,
|
||||
timeoutMs: sandboxLimits.timeoutMs,
|
||||
}),
|
||||
);
|
||||
logger.info("Completed sandbox tool", {
|
||||
event: "leaf.sandbox_tool_completed",
|
||||
data: {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
file_count: result.files.length,
|
||||
exit_code: result.exitCode,
|
||||
timed_out: result.timedOut,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
});
|
||||
106
apps/leaf/src/agent/sandbox/guardrails.ts
Normal file
106
apps/leaf/src/agent/sandbox/guardrails.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import path from "node:path";
|
||||
import type { SandboxFile, SandboxRunResult } from "./types.js";
|
||||
|
||||
export const sandboxLimits = {
|
||||
maxFiles: 5,
|
||||
maxInputBytes: 256 * 1024,
|
||||
maxOutputBytes: 24 * 1024,
|
||||
maxReturnedFileBytes: 24 * 1024,
|
||||
timeoutMs: 20_000,
|
||||
workDir: "/work",
|
||||
};
|
||||
|
||||
const secretPatterns = [
|
||||
/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i,
|
||||
/\bxox[baprs]-[A-Za-z0-9-]{10,}/i,
|
||||
/\bsk_(?:live|test|proj)?_[A-Za-z0-9]{12,}/i,
|
||||
/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b/,
|
||||
/^\s*[A-Z0-9_]*(?:SECRET|TOKEN|API_KEY|PASSWORD)[A-Z0-9_]*\s*=\s*\S+/im,
|
||||
];
|
||||
|
||||
const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
|
||||
|
||||
const assertNoSecrets = (value: string) => {
|
||||
if (secretPatterns.some((pattern) => pattern.test(value))) {
|
||||
throw new Error("Sandbox input appears to contain a secret or token");
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePath = (filePath: string) => {
|
||||
const normalized = path.posix.normalize(
|
||||
filePath.startsWith("/")
|
||||
? filePath
|
||||
: path.posix.join(sandboxLimits.workDir, filePath),
|
||||
);
|
||||
if (
|
||||
normalized !== sandboxLimits.workDir &&
|
||||
!normalized.startsWith(`${sandboxLimits.workDir}/`)
|
||||
) {
|
||||
throw new Error("Sandbox files must stay under /work");
|
||||
}
|
||||
if (path.posix.basename(normalized).toLowerCase() === ".env") {
|
||||
throw new Error("Sandbox files cannot be named .env");
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const sanitizeSandboxFiles = (files: SandboxFile[] = []) => {
|
||||
if (files.length > sandboxLimits.maxFiles) {
|
||||
throw new Error(
|
||||
`Sandbox input cannot exceed ${sandboxLimits.maxFiles} files`,
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = 0;
|
||||
const sanitized = files.map((file) => {
|
||||
assertNoSecrets(file.path);
|
||||
assertNoSecrets(file.content);
|
||||
bytes += byteLength(file.content);
|
||||
if (bytes > sandboxLimits.maxInputBytes) {
|
||||
throw new Error("Sandbox input is too large");
|
||||
}
|
||||
return {
|
||||
path: normalizePath(file.path),
|
||||
content: file.content,
|
||||
};
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const file of sanitized) {
|
||||
if (seen.has(file.path))
|
||||
throw new Error(`Duplicate sandbox file: ${file.path}`);
|
||||
seen.add(file.path);
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const sanitizeReturnFiles = (returnFiles: string[] = []) =>
|
||||
returnFiles.map(normalizePath);
|
||||
|
||||
export const assertSafeSandboxCommand = (command: string) => {
|
||||
if (!command.trim()) throw new Error("Sandbox command cannot be empty");
|
||||
assertNoSecrets(command);
|
||||
return command.trim();
|
||||
};
|
||||
|
||||
export const truncateText = (value: string, maxBytes: number) => {
|
||||
if (byteLength(value) <= maxBytes) return value;
|
||||
let output = "";
|
||||
for (const char of value) {
|
||||
if (byteLength(`${output}${char}`) > maxBytes) break;
|
||||
output += char;
|
||||
}
|
||||
return `${output}\n[truncated]`;
|
||||
};
|
||||
|
||||
export const truncateSandboxResult = (
|
||||
result: SandboxRunResult,
|
||||
): SandboxRunResult => ({
|
||||
...result,
|
||||
stdout: truncateText(result.stdout, sandboxLimits.maxOutputBytes),
|
||||
stderr: truncateText(result.stderr, sandboxLimits.maxOutputBytes),
|
||||
files: result.files.map((file) => ({
|
||||
path: file.path,
|
||||
content: truncateText(file.content, sandboxLimits.maxReturnedFileBytes),
|
||||
})),
|
||||
});
|
||||
32
apps/leaf/src/agent/sandbox/types.ts
Normal file
32
apps/leaf/src/agent/sandbox/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type SandboxFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type SandboxRunArgs = {
|
||||
command: string;
|
||||
files: SandboxFile[];
|
||||
returnFiles: string[];
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type SandboxRunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode?: number;
|
||||
timedOut: boolean;
|
||||
files: SandboxFile[];
|
||||
};
|
||||
|
||||
export type SandboxSessionContext = {
|
||||
channelId: string;
|
||||
env: string;
|
||||
orgId: string;
|
||||
provider: string;
|
||||
threadId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type SandboxProvider = {
|
||||
run(args: SandboxRunArgs): Promise<SandboxRunResult>;
|
||||
};
|
||||
24
apps/leaf/src/agent/toolPolicy.ts
Normal file
24
apps/leaf/src/agent/toolPolicy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const labels: Record<string, string> = {
|
||||
attach: "Attach plan",
|
||||
updateSubscription: "Update subscription",
|
||||
createSchedule: "Create schedule",
|
||||
createBalance: "Create balance",
|
||||
createPlan: "Create plan",
|
||||
};
|
||||
|
||||
const previewWriteTools: Record<string, string> = {
|
||||
previewAttach: "attach",
|
||||
previewUpdateSubscription: "updateSubscription",
|
||||
previewCreateSchedule: "createSchedule",
|
||||
previewCreateBalance: "createBalance",
|
||||
};
|
||||
|
||||
export const getWriteToolForPreview = (toolName: string) =>
|
||||
previewWriteTools[toolName.replace(/^autumn_/, "")];
|
||||
|
||||
export const toolLabel = (toolName: string) =>
|
||||
labels[toolName.replace(/^autumn_/, "")] ??
|
||||
toolName
|
||||
.replace(/^autumn_/, "")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/^./, (char) => char.toUpperCase());
|
||||
89
apps/leaf/src/approvals/errors.ts
Normal file
89
apps/leaf/src/approvals/errors.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 700;
|
||||
|
||||
const cleanMessage = (message: string) =>
|
||||
message
|
||||
.replace(/^Error:\s*/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const truncateMessage = (message: string) =>
|
||||
message.length > MAX_ERROR_MESSAGE_LENGTH
|
||||
? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 1)}…`
|
||||
: message;
|
||||
|
||||
const parseAutumnApiErrorMessage = (message: string) => {
|
||||
const match = message.match(/Autumn API request failed \(\d+\):\s*(.+)$/s);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[1] ?? "");
|
||||
return typeof parsed?.message === "string" ? parsed.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMcpContentText = (value: Record<string, unknown>): string | null => {
|
||||
if (!Array.isArray(value.content)) return null;
|
||||
const item = value.content.find((entry): entry is { text: string } =>
|
||||
Boolean(
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"text" in entry &&
|
||||
typeof entry.text === "string",
|
||||
),
|
||||
);
|
||||
return item?.text ?? null;
|
||||
};
|
||||
|
||||
const getObjectMessage = (value: Record<string, unknown>): string | null => {
|
||||
if (typeof value.message === "string") return value.message;
|
||||
if (typeof value.error === "string") return value.error;
|
||||
if (
|
||||
value.error &&
|
||||
typeof value.error === "object" &&
|
||||
typeof (value.error as { message?: unknown }).message === "string"
|
||||
) {
|
||||
return (value.error as { message: string }).message;
|
||||
}
|
||||
if (
|
||||
value.details &&
|
||||
typeof value.details === "object" &&
|
||||
typeof (value.details as { errorMessage?: unknown }).errorMessage ===
|
||||
"string"
|
||||
) {
|
||||
return (value.details as { errorMessage: string }).errorMessage;
|
||||
}
|
||||
const contentText = getMcpContentText(value);
|
||||
if (!contentText) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(contentText);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return getObjectMessage(parsed as Record<string, unknown>) ?? contentText;
|
||||
}
|
||||
} catch {
|
||||
return contentText;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const approvalErrorResult = (error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: error && typeof error === "object"
|
||||
? (getObjectMessage(error as Record<string, unknown>) ??
|
||||
"The action failed.")
|
||||
: "The action failed.";
|
||||
const cleanedRawMessage = cleanMessage(rawMessage);
|
||||
const message = cleanMessage(
|
||||
parseAutumnApiErrorMessage(cleanedRawMessage) ?? cleanedRawMessage,
|
||||
);
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: truncateMessage(message || "The action failed."),
|
||||
};
|
||||
};
|
||||
204
apps/leaf/src/approvals/flow.ts
Normal file
204
apps/leaf/src/approvals/flow.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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 { approvalErrorResult } from "./errors.js";
|
||||
import { approvalRequestFromOutput } from "./request.js";
|
||||
import {
|
||||
approveAndRun,
|
||||
cancelApproval,
|
||||
createApproval,
|
||||
getApproval,
|
||||
isErrorResult,
|
||||
} from "./store.js";
|
||||
|
||||
export const postApprovalRequest = async ({
|
||||
channelId,
|
||||
installation,
|
||||
loading,
|
||||
logAction,
|
||||
logger = rootLogger,
|
||||
output,
|
||||
providerUserId,
|
||||
target,
|
||||
}: {
|
||||
channelId: string;
|
||||
installation: ChatInstallation;
|
||||
loading: LoadingState;
|
||||
logAction: (message: string) => Promise<void> | void;
|
||||
logger?: AutumnLogger;
|
||||
output: AgentOutput;
|
||||
providerUserId: string;
|
||||
target: ReplyTarget;
|
||||
}) => {
|
||||
const approval = approvalRequestFromOutput(output);
|
||||
if (!approval) return false;
|
||||
|
||||
const approvalId = await createApproval({
|
||||
orgId: installation.org_id,
|
||||
provider: installation.provider,
|
||||
workspaceId: installation.workspace_id,
|
||||
channelId,
|
||||
providerUserId,
|
||||
...approval,
|
||||
});
|
||||
|
||||
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({
|
||||
id: approvalId,
|
||||
env: approval.env,
|
||||
toolName: approval.toolName,
|
||||
toolArgs: approval.toolArgs,
|
||||
preview: approval.preview,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const detailsFromApproval = (approval?: ChatApproval) => ({
|
||||
toolName: approval?.tool_name ?? "billing action",
|
||||
toolArgs:
|
||||
approval?.tool_args && typeof approval.tool_args === "object"
|
||||
? (approval.tool_args as Record<string, unknown>)
|
||||
: undefined,
|
||||
preview: approval?.preview,
|
||||
env: approval?.env,
|
||||
});
|
||||
|
||||
const editActionMessage = async (
|
||||
event: ActionEvent,
|
||||
content: Parameters<NonNullable<ActionEvent["adapter"]["editMessage"]>>[2],
|
||||
) => {
|
||||
await event.adapter.editMessage?.(event.threadId, event.messageId, content);
|
||||
};
|
||||
|
||||
type ApprovalActionDeps = {
|
||||
approveAndRun: typeof approveAndRun;
|
||||
cancelApproval: typeof cancelApproval;
|
||||
editActionMessage: typeof editActionMessage;
|
||||
getApproval: typeof getApproval;
|
||||
logger: Pick<AutumnLogger, "error" | "info" | "warn">;
|
||||
};
|
||||
|
||||
const defaultApprovalActionDeps = {
|
||||
approveAndRun,
|
||||
cancelApproval,
|
||||
editActionMessage,
|
||||
getApproval,
|
||||
logger: rootLogger,
|
||||
} satisfies ApprovalActionDeps;
|
||||
|
||||
const cardStatusForApproval = (
|
||||
status?: string,
|
||||
): "approved" | "cancelled" | "failed" | "running" => {
|
||||
if (status === "approved" || status === "cancelled" || status === "running")
|
||||
return status;
|
||||
return "failed";
|
||||
};
|
||||
|
||||
export const handleApprovalActionWithDeps = async (
|
||||
event: ActionEvent,
|
||||
deps: ApprovalActionDeps = defaultApprovalActionDeps,
|
||||
) => {
|
||||
if (!event.value) return;
|
||||
|
||||
try {
|
||||
deps.logger.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 = detailsFromApproval(await deps.getApproval(event.value));
|
||||
if (event.actionId === "cancel_billing_action") {
|
||||
const cancelled = await deps.cancelApproval(
|
||||
event.value,
|
||||
event.user.userId,
|
||||
);
|
||||
if (!cancelled) {
|
||||
deps.logger.warn("Approval cancellation ignored", {
|
||||
event: "leaf.approval_cancel_ignored",
|
||||
approval_id: event.value,
|
||||
});
|
||||
const current = await deps.getApproval(event.value);
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({
|
||||
status: cardStatusForApproval(current?.status),
|
||||
...details,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({ status: "cancelled", ...details }),
|
||||
);
|
||||
deps.logger.info("Cancelled approval", {
|
||||
event: "leaf.approval_cancelled",
|
||||
approval_id: event.value,
|
||||
tool: details.toolName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({ status: "running", ...details }),
|
||||
);
|
||||
const result = await deps.approveAndRun(event.value, event.user.userId);
|
||||
deps.logger.info("Completed approval action", {
|
||||
event: "leaf.approval_completed",
|
||||
approval_id: event.value,
|
||||
status: isErrorResult(result) ? "failed" : "approved",
|
||||
tool: details.toolName,
|
||||
});
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({
|
||||
status: isErrorResult(result) ? "failed" : "approved",
|
||||
...details,
|
||||
result,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
deps.logger.error("[chat] Approval action failed", error, {
|
||||
event: "leaf.approval_failed",
|
||||
approval_id: event.value,
|
||||
action: event.actionId,
|
||||
});
|
||||
const current = await deps.getApproval(event.value);
|
||||
await deps.editActionMessage(
|
||||
event,
|
||||
approvalStatusCard({
|
||||
status: cardStatusForApproval(current?.status),
|
||||
...detailsFromApproval(current),
|
||||
result: approvalErrorResult(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleApprovalAction = async (event: ActionEvent) =>
|
||||
handleApprovalActionWithDeps(event);
|
||||
26
apps/leaf/src/approvals/request.ts
Normal file
26
apps/leaf/src/approvals/request.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { AgentOutput } from "../types.js";
|
||||
|
||||
export const approvalRequestFromOutput = (output: AgentOutput) => {
|
||||
if (output.finishReason === "suspended" && output.suspendPayload) {
|
||||
return {
|
||||
env: output.env,
|
||||
runId: output.runId,
|
||||
toolCallId: output.suspendPayload.toolCallId,
|
||||
toolName: output.suspendPayload.toolName,
|
||||
toolArgs: output.suspendPayload.args ?? {},
|
||||
preview:
|
||||
output.text ||
|
||||
output.previewApproval?.preview ||
|
||||
output.suspendPayload.args,
|
||||
};
|
||||
}
|
||||
|
||||
if (output.previewApproval) {
|
||||
return {
|
||||
env: output.env,
|
||||
toolName: output.previewApproval.toolName,
|
||||
toolArgs: output.previewApproval.toolArgs,
|
||||
preview: output.text || output.previewApproval.preview,
|
||||
};
|
||||
}
|
||||
};
|
||||
165
apps/leaf/src/approvals/store.ts
Normal file
165
apps/leaf/src/approvals/store.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
type AppEnv,
|
||||
type ChatProvider,
|
||||
chatApprovals,
|
||||
chatInstallations,
|
||||
} from "@autumn/shared";
|
||||
import { addMinutes, isPast } from "date-fns";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { executeAutumnMcpTool } from "../agent/mcp.js";
|
||||
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
|
||||
import { db } from "../lib/db.js";
|
||||
import { approvalErrorResult } from "./errors.js";
|
||||
|
||||
export const normalizeToolName = (toolName: string) =>
|
||||
toolName.replace(/^autumn_/, "");
|
||||
|
||||
export const isErrorResult = (result: unknown): boolean =>
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
("error" in result ||
|
||||
(result as { isError?: unknown }).isError === true ||
|
||||
(result as { id?: unknown }).id === "TOOL_EXECUTION_FAILED" ||
|
||||
(typeof (result as { code?: unknown }).code === "string" &&
|
||||
typeof (result as { message?: unknown }).message === "string"));
|
||||
|
||||
export const createApproval = async ({
|
||||
orgId,
|
||||
provider,
|
||||
workspaceId,
|
||||
channelId,
|
||||
providerUserId,
|
||||
env,
|
||||
runId,
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolArgs,
|
||||
preview,
|
||||
}: {
|
||||
orgId: string;
|
||||
provider: ChatProvider;
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
providerUserId: string;
|
||||
env: AppEnv;
|
||||
runId?: string;
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
preview?: unknown;
|
||||
}) => {
|
||||
const id = `chat_app_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
await db.insert(chatApprovals).values({
|
||||
id,
|
||||
org_id: orgId,
|
||||
provider,
|
||||
workspace_id: workspaceId,
|
||||
channel_id: channelId,
|
||||
provider_user_id: providerUserId,
|
||||
env,
|
||||
run_id: runId,
|
||||
tool_call_id: toolCallId,
|
||||
tool_name: normalizeToolName(toolName),
|
||||
tool_args: toolArgs,
|
||||
preview,
|
||||
status: "pending",
|
||||
created_at: Date.now(),
|
||||
expires_at: addMinutes(Date.now(), 15).getTime(),
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
export const cancelApproval = async (id: string, providerUserId: string) => {
|
||||
const [claimed] = await db
|
||||
.update(chatApprovals)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
decided_at: Date.now(),
|
||||
decided_by_provider_user_id: providerUserId,
|
||||
})
|
||||
.where(and(eq(chatApprovals.id, id), eq(chatApprovals.status, "pending")))
|
||||
.returning();
|
||||
return claimed;
|
||||
};
|
||||
|
||||
export const getApproval = async (id: string) =>
|
||||
await db.query.chatApprovals.findFirst({
|
||||
where: eq(chatApprovals.id, id),
|
||||
});
|
||||
|
||||
export const approveAndRun = async (id: string, providerUserId: string) => {
|
||||
const approval = await db.query.chatApprovals.findFirst({
|
||||
where: eq(chatApprovals.id, id),
|
||||
});
|
||||
if (
|
||||
!approval ||
|
||||
approval.status !== "pending" ||
|
||||
isPast(approval.expires_at)
|
||||
) {
|
||||
throw new Error("Approval is no longer pending");
|
||||
}
|
||||
|
||||
const [claimed] = await db
|
||||
.update(chatApprovals)
|
||||
.set({
|
||||
status: "running",
|
||||
decided_at: Date.now(),
|
||||
decided_by_provider_user_id: providerUserId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatApprovals.id, id),
|
||||
eq(chatApprovals.status, "pending"),
|
||||
gt(chatApprovals.expires_at, Date.now()),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!claimed) throw new Error("Approval is no longer pending");
|
||||
|
||||
try {
|
||||
const installation = await db.query.chatInstallations.findFirst({
|
||||
where: and(
|
||||
eq(chatInstallations.org_id, claimed.org_id),
|
||||
eq(chatInstallations.provider, claimed.provider),
|
||||
eq(chatInstallations.workspace_id, claimed.workspace_id),
|
||||
),
|
||||
});
|
||||
if (!installation) throw new Error("Chat installation not found");
|
||||
|
||||
const token = await getInstallationOAuthAccessToken({
|
||||
installation,
|
||||
env: claimed.env,
|
||||
});
|
||||
|
||||
const rawResult = await executeAutumnMcpTool({
|
||||
token,
|
||||
env: claimed.env,
|
||||
toolName: claimed.tool_name,
|
||||
args: claimed.tool_args,
|
||||
});
|
||||
const result = isErrorResult(rawResult)
|
||||
? approvalErrorResult(rawResult)
|
||||
: rawResult;
|
||||
await db
|
||||
.update(chatApprovals)
|
||||
.set({
|
||||
status: isErrorResult(result) ? "failed" : "approved",
|
||||
decided_at: Date.now(),
|
||||
decided_by_provider_user_id: providerUserId,
|
||||
})
|
||||
.where(eq(chatApprovals.id, id));
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = approvalErrorResult(error);
|
||||
await db
|
||||
.update(chatApprovals)
|
||||
.set({
|
||||
status: "failed",
|
||||
decided_at: Date.now(),
|
||||
decided_by_provider_user_id: providerUserId,
|
||||
})
|
||||
.where(eq(chatApprovals.id, id));
|
||||
return result;
|
||||
}
|
||||
};
|
||||
228
apps/leaf/src/bot.ts
Normal file
228
apps/leaf/src/bot.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { createSlackAdapter } from "@chat-adapter/slack";
|
||||
import { createPostgresState } from "@chat-adapter/state-pg";
|
||||
import type { Attachment, Message, Thread } from "chat";
|
||||
import { Chat } from "chat";
|
||||
import { runMessage } from "./agent/messages.js";
|
||||
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.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 {
|
||||
fetchSlackAttachmentFallback,
|
||||
getSlackFilesFromRaw,
|
||||
} from "./providers/slack/files.js";
|
||||
import { findInstallationWithOrg } from "./providers/slack/installations.js";
|
||||
import { getRecentMessages } from "./providers/slack/threadContext.js";
|
||||
import type { ChatContextMessage } from "./types.js";
|
||||
import {
|
||||
createActionLogger,
|
||||
finishLoading,
|
||||
type LoadingState,
|
||||
type ReplyTarget,
|
||||
startLoading,
|
||||
} from "./ui/progress.js";
|
||||
|
||||
export const chatAdapterNames = ["slack"];
|
||||
|
||||
const getSlackAdminProvider = () =>
|
||||
`slack_admin:${env.SLACK_CLIENT_ID}` as const;
|
||||
|
||||
const findSlackInstallationForWorkspace = async ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
return (
|
||||
(await findInstallationWithOrg(getSlackAdminProvider(), workspaceId)) ??
|
||||
(await findInstallationWithOrg("slack", workspaceId))
|
||||
);
|
||||
};
|
||||
|
||||
export const bot = new Chat({
|
||||
userName: env.CHAT_NAME,
|
||||
adapters: {
|
||||
slack: createSlackAdapter({
|
||||
clientId: env.SLACK_CLIENT_ID,
|
||||
clientSecret: env.SLACK_CLIENT_SECRET,
|
||||
installationProvider: {
|
||||
getInstallation: async (workspaceId) => {
|
||||
const installation = await findSlackInstallationForWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
if (!installation) return null;
|
||||
return {
|
||||
botToken: decrypt(installation.bot_access_token),
|
||||
botUserId: installation.bot_user_id ?? undefined,
|
||||
teamName: installation.workspace_name,
|
||||
};
|
||||
},
|
||||
},
|
||||
signingSecret: env.SLACK_SIGNING_SECRET,
|
||||
userName: env.CHAT_NAME,
|
||||
}),
|
||||
},
|
||||
state: createPostgresState({
|
||||
keyPrefix: "chat",
|
||||
url: env.CHAT_STATE_DATABASE_URL,
|
||||
}),
|
||||
concurrency: "queue",
|
||||
});
|
||||
|
||||
const runAndReply = async ({
|
||||
channelId,
|
||||
attachments,
|
||||
providerUserId,
|
||||
raw,
|
||||
recentMessages,
|
||||
target,
|
||||
text,
|
||||
threadId,
|
||||
}: {
|
||||
attachments?: Attachment[];
|
||||
channelId: string;
|
||||
providerUserId: string;
|
||||
raw: unknown;
|
||||
recentMessages?: ChatContextMessage[];
|
||||
target: ReplyTarget;
|
||||
text: string;
|
||||
threadId: string;
|
||||
}) => {
|
||||
let loading: LoadingState = null;
|
||||
let logger = rootLogger;
|
||||
try {
|
||||
const workspaceId = getSlackWorkspaceId(raw);
|
||||
const installation = await findSlackInstallationForWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
if (!installation) {
|
||||
logger.warn("Slack installation not found", {
|
||||
event: "leaf.slack_installation_missing",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = createLeafSessionContext({
|
||||
channelId,
|
||||
provider: installation.provider,
|
||||
providerUserId,
|
||||
threadId,
|
||||
workspaceId,
|
||||
});
|
||||
logger = addLeafContext(rootLogger, {
|
||||
...session.context,
|
||||
agent_run_id: session.agentRunId,
|
||||
org_id: installation.org_id,
|
||||
org_slug: installation.org_slug,
|
||||
});
|
||||
logger.info("Received Slack message", {
|
||||
event: "leaf.slack_message_received",
|
||||
data: {
|
||||
attachment_count: attachments?.length ?? 0,
|
||||
text_length: text.length,
|
||||
},
|
||||
});
|
||||
if (!text.trim() && !attachments?.length) {
|
||||
logger.info("Skipping empty Slack message", {
|
||||
event: "leaf.slack_message_skipped",
|
||||
data: { reason: "empty" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading = await startLoading(target);
|
||||
const logAction = createActionLogger(loading);
|
||||
const rawFiles = getSlackFilesFromRaw({ raw });
|
||||
const botToken = decrypt(installation.bot_access_token);
|
||||
const output = await runMessage({
|
||||
agentRunId: session.agentRunId,
|
||||
attachmentFetchFallback: ({ attachment }) =>
|
||||
fetchSlackAttachmentFallback({
|
||||
attachment,
|
||||
botToken,
|
||||
rawFiles,
|
||||
}),
|
||||
attachments,
|
||||
installation,
|
||||
logger,
|
||||
onAction: logAction,
|
||||
recentMessages,
|
||||
text,
|
||||
channelId,
|
||||
threadId,
|
||||
});
|
||||
|
||||
const postedApproval = await postApprovalRequest({
|
||||
channelId,
|
||||
installation,
|
||||
loading,
|
||||
logAction,
|
||||
logger,
|
||||
output,
|
||||
providerUserId,
|
||||
target,
|
||||
});
|
||||
if (postedApproval) return;
|
||||
|
||||
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) {
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = async (thread: Thread, message: Message) => {
|
||||
await runAndReply({
|
||||
target: thread,
|
||||
attachments: message.attachments,
|
||||
raw: message.raw,
|
||||
text: message.text,
|
||||
channelId: thread.channelId,
|
||||
providerUserId: message.author.userId,
|
||||
threadId: thread.id,
|
||||
recentMessages: await getRecentMessages(thread, message),
|
||||
});
|
||||
};
|
||||
|
||||
bot.onDirectMessage(handleMessage);
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
await thread.subscribe();
|
||||
await handleMessage(thread, message);
|
||||
});
|
||||
|
||||
bot.onSubscribedMessage(handleMessage);
|
||||
|
||||
bot.onSlashCommand(async (event) => {
|
||||
await runAndReply({
|
||||
target: event.channel,
|
||||
raw: event.raw,
|
||||
text: event.text || event.command,
|
||||
channelId: event.channel.id,
|
||||
providerUserId: event.user.userId,
|
||||
threadId: event.channel.id,
|
||||
});
|
||||
});
|
||||
|
||||
bot.onAction(
|
||||
["approve_billing_action", "cancel_billing_action"],
|
||||
handleApprovalAction,
|
||||
);
|
||||
|
||||
bot.registerSingleton();
|
||||
4
apps/leaf/src/index.ts
Normal file
4
apps/leaf/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { initInfisical } from "@autumn/shared/utils/infisical";
|
||||
|
||||
await initInfisical();
|
||||
await import("./main.js");
|
||||
@@ -0,0 +1,103 @@
|
||||
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";
|
||||
import { replaceInstallationOAuthCredentials } from "./replaceInstallationOAuthCredentials.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;
|
||||
}) => {
|
||||
let credential = await getChatOAuthCredentialByInstallationEnv({
|
||||
db,
|
||||
chatInstallationId: installation.id,
|
||||
env,
|
||||
});
|
||||
|
||||
if (
|
||||
installation.provider.startsWith("slack_admin") &&
|
||||
(!credential || credential.org_id !== installation.org_id)
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
await replaceInstallationOAuthCredentials({
|
||||
tx,
|
||||
installation,
|
||||
userId: installation.installed_by_user_id ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
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,313 @@
|
||||
import crypto from "node:crypto";
|
||||
import { prefixOAuthToken } from "@autumn/auth";
|
||||
import {
|
||||
AppEnv,
|
||||
type ChatInstallation,
|
||||
chatOAuthCredentials,
|
||||
LEAF_OAUTH_SCOPES,
|
||||
oauthAccessToken,
|
||||
oauthClient,
|
||||
oauthConsent,
|
||||
oauthRefreshToken,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { encrypt } from "../../../lib/crypto.js";
|
||||
import type { db } from "../../../lib/db.js";
|
||||
import {
|
||||
AUTUMN_ADMIN_OAUTH_CLIENT_ID,
|
||||
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 SLACK_ADMIN_CONSENT_KIND = "slack_admin";
|
||||
|
||||
type OAuthConsentMetadata =
|
||||
| {
|
||||
kind: typeof SLACK_ADMIN_CONSENT_KIND;
|
||||
chatInstallationId: string;
|
||||
createdByUserId: string;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
const isSlackAdminInstallation = ({
|
||||
installation,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
}) => installation.provider.startsWith("slack_admin");
|
||||
|
||||
const getSlackMcpOAuthClientId = ({
|
||||
installation,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
}) =>
|
||||
isSlackAdminInstallation({ installation })
|
||||
? AUTUMN_ADMIN_OAUTH_CLIENT_ID
|
||||
: AUTUMN_SLACK_OAUTH_CLIENT_ID;
|
||||
|
||||
const getSlackMcpOAuthClientName = ({
|
||||
installation,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
}) => (isSlackAdminInstallation({ installation }) ? "Slack Admin" : "Slack");
|
||||
|
||||
const getOAuthClientMetadata = ({
|
||||
installation,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
}) => ({
|
||||
kind: "mcp_client",
|
||||
mcpClientType: isSlackAdminInstallation({ installation })
|
||||
? "slack_admin"
|
||||
: "slack",
|
||||
});
|
||||
|
||||
const getOAuthConsentMetadata = ({
|
||||
installation,
|
||||
userId,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
userId: string;
|
||||
}): OAuthConsentMetadata =>
|
||||
isSlackAdminInstallation({ installation })
|
||||
? {
|
||||
kind: SLACK_ADMIN_CONSENT_KIND,
|
||||
chatInstallationId: installation.id,
|
||||
createdByUserId: userId,
|
||||
}
|
||||
: {};
|
||||
|
||||
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,
|
||||
installation,
|
||||
}: {
|
||||
tx: ChatTransaction;
|
||||
installation: ChatInstallation;
|
||||
}) => {
|
||||
const now = new Date();
|
||||
const clientId = getSlackMcpOAuthClientId({ installation });
|
||||
const name = getSlackMcpOAuthClientName({ installation });
|
||||
const metadata = getOAuthClientMetadata({ installation });
|
||||
|
||||
await tx
|
||||
.insert(oauthClient)
|
||||
.values({
|
||||
id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
clientId,
|
||||
name,
|
||||
redirectUris: ["slack://autumn-chat"],
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
grantTypes: ["authorization_code", "refresh_token"],
|
||||
responseTypes: ["code"],
|
||||
public: true,
|
||||
type: "native",
|
||||
metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: oauthClient.clientId,
|
||||
set: {
|
||||
name,
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
grantTypes: ["authorization_code", "refresh_token"],
|
||||
responseTypes: ["code"],
|
||||
public: true,
|
||||
type: "native",
|
||||
metadata,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const upsertOAuthConsent = async ({
|
||||
tx,
|
||||
env,
|
||||
orgId,
|
||||
userId,
|
||||
clientId,
|
||||
metadata,
|
||||
}: {
|
||||
tx: ChatTransaction;
|
||||
env: AppEnv;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
clientId: string;
|
||||
metadata: OAuthConsentMetadata;
|
||||
}) => {
|
||||
const now = new Date();
|
||||
const [existingConsent] = await tx
|
||||
.select({ id: oauthConsent.id })
|
||||
.from(oauthConsent)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConsent.clientId, clientId),
|
||||
eq(oauthConsent.userId, userId),
|
||||
eq(oauthConsent.referenceId, orgId),
|
||||
eq(oauthConsent.env, env),
|
||||
metadata?.kind === SLACK_ADMIN_CONSENT_KIND
|
||||
? sql`${oauthConsent.metadata}->>'kind' = ${SLACK_ADMIN_CONSENT_KIND}`
|
||||
: sql`COALESCE(${oauthConsent.metadata}->>'kind', '') != ${SLACK_ADMIN_CONSENT_KIND}`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingConsent) {
|
||||
await tx
|
||||
.update(oauthConsent)
|
||||
.set({
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
metadata,
|
||||
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,
|
||||
userId,
|
||||
referenceId: orgId,
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
env,
|
||||
redirectUri: "slack://autumn-chat",
|
||||
metadata,
|
||||
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 clientId = getSlackMcpOAuthClientId({ installation });
|
||||
const metadata = getOAuthConsentMetadata({ installation, userId });
|
||||
const consentId = await upsertOAuthConsent({
|
||||
tx,
|
||||
env,
|
||||
orgId: installation.org_id,
|
||||
userId,
|
||||
clientId,
|
||||
metadata,
|
||||
});
|
||||
|
||||
await tx.insert(oauthRefreshToken).values({
|
||||
id: refreshTokenId,
|
||||
token: tokenHash({ token: rawRefreshToken }),
|
||||
clientId,
|
||||
userId,
|
||||
referenceId: installation.org_id,
|
||||
expiresAt: new Date(refreshTokenExpiresAt),
|
||||
createdAt: nowDate,
|
||||
authTime: nowDate,
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
});
|
||||
await tx.insert(oauthAccessToken).values({
|
||||
id: accessTokenId,
|
||||
token: tokenHash({ token: rawAccessToken }),
|
||||
clientId,
|
||||
userId,
|
||||
referenceId: installation.org_id,
|
||||
refreshId: refreshTokenId,
|
||||
expiresAt: new Date(accessTokenExpiresAt),
|
||||
createdAt: nowDate,
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
});
|
||||
const credential = {
|
||||
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
chat_installation_id: installation.id,
|
||||
org_id: installation.org_id,
|
||||
env,
|
||||
oauth_client_id: clientId,
|
||||
oauth_consent_id: consentId,
|
||||
access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
|
||||
refresh_token: encrypt(rawRefreshToken),
|
||||
access_token_expires_at: accessTokenExpiresAt,
|
||||
scopes: [...LEAF_OAUTH_SCOPES],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
await tx
|
||||
.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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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, installation });
|
||||
await createCredentialForEnv({
|
||||
tx,
|
||||
installation,
|
||||
env: AppEnv.Sandbox,
|
||||
userId,
|
||||
});
|
||||
await createCredentialForEnv({
|
||||
tx,
|
||||
installation,
|
||||
env: AppEnv.Live,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
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 AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin";
|
||||
|
||||
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) ?? [];
|
||||
49
apps/leaf/src/internal/observability/leafTracingOptions.ts
Normal file
49
apps/leaf/src/internal/observability/leafTracingOptions.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import type { TracingOptions } from "@mastra/core/observability";
|
||||
|
||||
const compact = (values: Array<string | null | undefined>) =>
|
||||
values.filter((value): value is string => Boolean(value));
|
||||
|
||||
export const createLeafTracingOptions = ({
|
||||
agentRunId,
|
||||
channelId,
|
||||
env,
|
||||
orgId,
|
||||
orgSlug,
|
||||
provider,
|
||||
source,
|
||||
threadId,
|
||||
workspaceId,
|
||||
setup,
|
||||
}: {
|
||||
agentRunId?: string;
|
||||
channelId?: string;
|
||||
env?: AppEnv | string;
|
||||
orgId?: string;
|
||||
orgSlug?: string | null;
|
||||
provider?: string;
|
||||
source: "eval" | "prod";
|
||||
threadId?: string;
|
||||
workspaceId?: string;
|
||||
setup?: string;
|
||||
}): TracingOptions => ({
|
||||
metadata: {
|
||||
agent_run_id: agentRunId,
|
||||
autumn_env: env,
|
||||
org_id: orgId,
|
||||
org_slug: orgSlug,
|
||||
provider,
|
||||
setup,
|
||||
slack_channel_id: channelId,
|
||||
slack_thread_id: threadId,
|
||||
slack_workspace_id: workspaceId,
|
||||
source,
|
||||
},
|
||||
tags: compact([
|
||||
source,
|
||||
env ? `autumn:${env}` : undefined,
|
||||
orgSlug ? `org:${orgSlug}` : undefined,
|
||||
provider ? `provider:${provider}` : undefined,
|
||||
setup ? `setup:${setup}` : undefined,
|
||||
]),
|
||||
});
|
||||
37
apps/leaf/src/lib/crypto.ts
Normal file
37
apps/leaf/src/lib/crypto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import crypto from "node:crypto";
|
||||
import { env } from "./env.js";
|
||||
|
||||
const key = () =>
|
||||
crypto.createHash("sha256").update(env.ENCRYPTION_PASSWORD).digest();
|
||||
|
||||
export const encrypt = (data: string) => {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(data, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([1]),
|
||||
iv,
|
||||
cipher.getAuthTag(),
|
||||
ciphertext,
|
||||
]).toString("base64");
|
||||
};
|
||||
|
||||
export const decrypt = (data: string) => {
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
if (buffer[0] !== 1) throw new Error("Unsupported encrypted payload");
|
||||
const iv = buffer.subarray(1, 13);
|
||||
const authTag = buffer.subarray(13, 29);
|
||||
const ciphertext = buffer.subarray(29);
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
key(),
|
||||
iv,
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
|
||||
"utf8",
|
||||
);
|
||||
};
|
||||
9
apps/leaf/src/lib/db.ts
Normal file
9
apps/leaf/src/lib/db.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as schema from "@autumn/shared/db/schema";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "./env.js";
|
||||
|
||||
const client = postgres(env.DATABASE_URL, { max: 4 });
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
export type ChatDb = typeof db;
|
||||
53
apps/leaf/src/lib/env.ts
Normal file
53
apps/leaf/src/lib/env.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const optionalString = z.preprocess(
|
||||
(value) => (value === "" ? undefined : value),
|
||||
z.string().min(1).optional(),
|
||||
);
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
MCP_SERVER_URL: optionalString,
|
||||
BETTER_AUTH_SECRET: optionalString,
|
||||
BETTER_AUTH_URL: optionalString,
|
||||
CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4-6"),
|
||||
CHAT_NAME: z.string().min(1).default("Autumn"),
|
||||
CHAT_STATE_DATABASE_URL: optionalString,
|
||||
CHAT_STATE_SECRET: optionalString,
|
||||
CLIENT_URL: z.string().min(1).default("http://localhost:3000"),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
E2B_API_KEY: optionalString,
|
||||
ENCRYPTION_PASSWORD: z.string().min(1),
|
||||
FIRECRAWL_API_KEY: z.string().min(1),
|
||||
MCP_OAUTH_ENVIRONMENT: z.enum(["live", "sandbox"]).default("sandbox"),
|
||||
PORT: z.coerce.number().int().positive().default(3099),
|
||||
SLACK_CLIENT_ID: z.string().min(1),
|
||||
SLACK_CLIENT_SECRET: z.string().min(1),
|
||||
SLACK_REDIRECT_URI: optionalString,
|
||||
SLACK_SIGNING_SECRET: z.string().min(1),
|
||||
SLACK_STATE_SECRET: optionalString,
|
||||
})
|
||||
.transform((values) => {
|
||||
const databaseUrl = new URL(values.DATABASE_URL);
|
||||
databaseUrl.pathname = "/chat";
|
||||
|
||||
return {
|
||||
...values,
|
||||
MCP_SERVER_URL:
|
||||
values.MCP_SERVER_URL ?? `http://localhost:${values.PORT}`,
|
||||
BETTER_AUTH_URL:
|
||||
values.BETTER_AUTH_URL ??
|
||||
(process.env.NODE_ENV === "production"
|
||||
? "https://api.useautumn.com"
|
||||
: "http://localhost:8080"),
|
||||
CHAT_STATE_DATABASE_URL:
|
||||
values.CHAT_STATE_DATABASE_URL ?? databaseUrl.toString(),
|
||||
CHAT_STATE_SECRET:
|
||||
values.CHAT_STATE_SECRET ??
|
||||
values.SLACK_STATE_SECRET ??
|
||||
values.BETTER_AUTH_SECRET ??
|
||||
values.ENCRYPTION_PASSWORD,
|
||||
};
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
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,
|
||||
},
|
||||
});
|
||||
49
apps/leaf/src/main.ts
Normal file
49
apps/leaf/src/main.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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 { logger } from "./lib/logger.js";
|
||||
import { createMcpRouter } from "./mcp/mcpRouter.js";
|
||||
import { slackRoutes } from "./providers/slack/routes.js";
|
||||
|
||||
const app = new Hono<{ Bindings: HttpBindings }>();
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
c.header("Access-Control-Allow-Origin", "*");
|
||||
c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
c.header("Access-Control-Allow-Headers", "*");
|
||||
return c.req.method === "OPTIONS" ? c.body(null, 204) : next();
|
||||
});
|
||||
|
||||
app.get("/health", (c) => c.json({ ok: true }));
|
||||
|
||||
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);
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
hostname: "0.0.0.0",
|
||||
port: env.PORT,
|
||||
},
|
||||
({ address, port }) => {
|
||||
logger.info("Chat listening", {
|
||||
event: "leaf.server_started",
|
||||
data: {
|
||||
host: `${address}:${port}`,
|
||||
adapters: chatAdapterNames,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
30
apps/leaf/src/mcp/auth/protectedResourceMetadata.ts
Normal file
30
apps/leaf/src/mcp/auth/protectedResourceMetadata.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getOAuthIssuerUrl } from "@autumn/auth/oauth";
|
||||
import { DEFAULT_AUTUMN_API_URL } from "@autumn/mcp";
|
||||
import { LEAF_OAUTH_SCOPES } from "@autumn/shared";
|
||||
|
||||
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: [...LEAF_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,
|
||||
type MCPServerFlags,
|
||||
type OAuthEnvironment,
|
||||
} from "@autumn/mcp";
|
||||
import { LEAF_OAUTH_SCOPES } from "@autumn/shared";
|
||||
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: [...LEAF_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: [...LEAF_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"],
|
||||
}),
|
||||
);
|
||||
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
apps/leaf/src/providers/braintrust/config.ts
Normal file
5
apps/leaf/src/providers/braintrust/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const braintrustConfig = {
|
||||
enabled: true,
|
||||
projectName: process.env.LEAF_BRAINTRUST_PROJECT ?? "leaf",
|
||||
serviceName: process.env.LEAF_BRAINTRUST_SERVICE ?? "leaf",
|
||||
};
|
||||
15
apps/leaf/src/providers/braintrust/createBraintrustLogger.ts
Normal file
15
apps/leaf/src/providers/braintrust/createBraintrustLogger.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { initLogger, type Logger } from "braintrust";
|
||||
import { braintrustConfig } from "./config.js";
|
||||
|
||||
export const createBraintrustLogger = ({
|
||||
apiKey = process.env.BRAINTRUST_API_KEY,
|
||||
enabled = braintrustConfig.enabled,
|
||||
projectName = braintrustConfig.projectName,
|
||||
}: {
|
||||
apiKey?: string;
|
||||
enabled?: boolean;
|
||||
projectName?: string;
|
||||
} = {}): Logger<true> | undefined => {
|
||||
if (!enabled || !apiKey) return undefined;
|
||||
return initLogger({ apiKey, projectName });
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BraintrustExporter } from "@mastra/braintrust";
|
||||
import { SpanType } from "@mastra/core/observability";
|
||||
import { Observability, SamplingStrategyType } from "@mastra/observability";
|
||||
import { currentSpan } from "braintrust";
|
||||
import { braintrustConfig } from "./config.js";
|
||||
import { createBraintrustLogger } from "./createBraintrustLogger.js";
|
||||
|
||||
export const createMastraBraintrustObservability = ({
|
||||
apiKey = process.env.BRAINTRUST_API_KEY,
|
||||
enabled = braintrustConfig.enabled,
|
||||
projectName = braintrustConfig.projectName,
|
||||
serviceName = braintrustConfig.serviceName,
|
||||
braintrustLogger = createBraintrustLogger({
|
||||
apiKey,
|
||||
enabled,
|
||||
projectName,
|
||||
}),
|
||||
}: {
|
||||
apiKey?: string;
|
||||
braintrustLogger?: unknown;
|
||||
enabled?: boolean;
|
||||
projectName?: string;
|
||||
serviceName?: string;
|
||||
} = {}): Observability | undefined => {
|
||||
if (!enabled) return undefined;
|
||||
const exporterConfig = {
|
||||
apiKey,
|
||||
braintrustLogger,
|
||||
currentSpan: () => currentSpan(),
|
||||
projectName,
|
||||
} as unknown as ConstructorParameters<typeof BraintrustExporter>[0];
|
||||
|
||||
return new Observability({
|
||||
configs: {
|
||||
braintrust: {
|
||||
excludeSpanTypes: [SpanType.MODEL_CHUNK],
|
||||
exporters: [new BraintrustExporter(exporterConfig)],
|
||||
sampling: { type: SamplingStrategyType.ALWAYS },
|
||||
serializationOptions: {
|
||||
maxArrayLength: 50,
|
||||
maxDepth: 6,
|
||||
maxObjectKeys: 80,
|
||||
maxStringLength: 8_000,
|
||||
},
|
||||
serviceName,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
3
apps/leaf/src/providers/braintrust/index.ts
Normal file
3
apps/leaf/src/providers/braintrust/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { braintrustConfig } from "./config.js";
|
||||
export { createBraintrustLogger } from "./createBraintrustLogger.js";
|
||||
export { createMastraBraintrustObservability } from "./createMastraBraintrustObservability.js";
|
||||
46
apps/leaf/src/providers/e2b/e2bSandboxFiles.ts
Normal file
46
apps/leaf/src/providers/e2b/e2bSandboxFiles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { SandboxFile } from "../../agent/sandbox/types.js";
|
||||
import type { E2bSandbox } from "./e2bSandboxLifecycle.js";
|
||||
|
||||
export const e2bWorkDir = "/work";
|
||||
|
||||
export const ensureE2bWorkDir = async ({
|
||||
sandbox,
|
||||
}: {
|
||||
sandbox: E2bSandbox;
|
||||
}) => {
|
||||
try {
|
||||
await sandbox.files.makeDir(e2bWorkDir);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !/exist/i.test(error.message)) throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeE2bSandboxFiles = async ({
|
||||
files,
|
||||
sandbox,
|
||||
}: {
|
||||
files: SandboxFile[];
|
||||
sandbox: E2bSandbox;
|
||||
}) => {
|
||||
for (const file of files) {
|
||||
await sandbox.files.write(file.path, file.content);
|
||||
}
|
||||
};
|
||||
|
||||
export const readRequestedE2bFiles = async ({
|
||||
returnFiles,
|
||||
sandbox,
|
||||
}: {
|
||||
returnFiles: string[];
|
||||
sandbox: E2bSandbox;
|
||||
}): Promise<SandboxFile[]> => {
|
||||
const files: SandboxFile[] = [];
|
||||
for (const filePath of returnFiles) {
|
||||
if (!(await sandbox.files.exists(filePath))) continue;
|
||||
files.push({
|
||||
path: filePath,
|
||||
content: await sandbox.files.read(filePath),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
};
|
||||
78
apps/leaf/src/providers/e2b/e2bSandboxLifecycle.ts
Normal file
78
apps/leaf/src/providers/e2b/e2bSandboxLifecycle.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Sandbox } from "e2b";
|
||||
import type { SandboxSessionContext } from "../../agent/sandbox/types.js";
|
||||
import {
|
||||
e2bSandboxLookupMetadata,
|
||||
e2bSandboxMetadata,
|
||||
} from "./e2bSandboxMetadata.js";
|
||||
|
||||
export type E2bSandbox = Awaited<ReturnType<typeof Sandbox.create>>;
|
||||
|
||||
export const findE2bSandbox = async ({
|
||||
apiKey,
|
||||
context,
|
||||
}: {
|
||||
apiKey: string;
|
||||
context: SandboxSessionContext;
|
||||
}) => {
|
||||
const paginator = Sandbox.list({
|
||||
apiKey,
|
||||
query: {
|
||||
metadata: e2bSandboxLookupMetadata({ context }),
|
||||
state: ["running", "paused"],
|
||||
},
|
||||
});
|
||||
const matches = await paginator.nextItems();
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
export const createE2bSandbox = ({
|
||||
apiKey,
|
||||
context,
|
||||
timeoutMs,
|
||||
}: {
|
||||
apiKey: string;
|
||||
context: SandboxSessionContext;
|
||||
timeoutMs: number;
|
||||
}) =>
|
||||
Sandbox.create({
|
||||
allowInternetAccess: false,
|
||||
apiKey,
|
||||
metadata: e2bSandboxMetadata({ context }),
|
||||
network: { allowPublicTraffic: false },
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
export const connectE2bSandbox = ({
|
||||
apiKey,
|
||||
sandboxId,
|
||||
timeoutMs,
|
||||
}: {
|
||||
apiKey: string;
|
||||
sandboxId: string;
|
||||
timeoutMs: number;
|
||||
}) =>
|
||||
Sandbox.connect(sandboxId, {
|
||||
apiKey,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
export const findOrCreateE2bSandbox = async ({
|
||||
apiKey,
|
||||
context,
|
||||
timeoutMs,
|
||||
}: {
|
||||
apiKey: string;
|
||||
context: SandboxSessionContext;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
const existing = await findE2bSandbox({ apiKey, context });
|
||||
if (existing) {
|
||||
return connectE2bSandbox({
|
||||
apiKey,
|
||||
sandboxId: existing.sandboxId,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
return createE2bSandbox({ apiKey, context, timeoutMs });
|
||||
};
|
||||
40
apps/leaf/src/providers/e2b/e2bSandboxMetadata.ts
Normal file
40
apps/leaf/src/providers/e2b/e2bSandboxMetadata.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SandboxSessionContext } from "../../agent/sandbox/types.js";
|
||||
|
||||
const metadataApp = "leaf";
|
||||
|
||||
export const e2bThreadKey = ({ context }: { context: SandboxSessionContext }) =>
|
||||
[
|
||||
context.orgId,
|
||||
context.env,
|
||||
context.provider,
|
||||
context.workspaceId,
|
||||
context.channelId,
|
||||
context.threadId,
|
||||
].join(":");
|
||||
|
||||
export const e2bSandboxMetadata = ({
|
||||
context,
|
||||
}: {
|
||||
context: SandboxSessionContext;
|
||||
}) => ({
|
||||
app: metadataApp,
|
||||
channelId: context.channelId,
|
||||
env: context.env,
|
||||
orgId: context.orgId,
|
||||
provider: context.provider,
|
||||
threadId: context.threadId,
|
||||
threadKey: e2bThreadKey({ context }),
|
||||
workspaceId: context.workspaceId,
|
||||
});
|
||||
|
||||
export const e2bSandboxLookupMetadata = ({
|
||||
context,
|
||||
}: {
|
||||
context: SandboxSessionContext;
|
||||
}) => {
|
||||
const metadata = e2bSandboxMetadata({ context });
|
||||
return {
|
||||
app: metadata.app,
|
||||
threadKey: metadata.threadKey,
|
||||
};
|
||||
};
|
||||
55
apps/leaf/src/providers/e2b/e2bSandboxProvider.ts
Normal file
55
apps/leaf/src/providers/e2b/e2bSandboxProvider.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
SandboxProvider,
|
||||
SandboxRunResult,
|
||||
SandboxSessionContext,
|
||||
} from "../../agent/sandbox/types.js";
|
||||
import {
|
||||
e2bWorkDir,
|
||||
ensureE2bWorkDir,
|
||||
readRequestedE2bFiles,
|
||||
writeE2bSandboxFiles,
|
||||
} from "./e2bSandboxFiles.js";
|
||||
import { findOrCreateE2bSandbox } from "./e2bSandboxLifecycle.js";
|
||||
|
||||
export const createE2bSandboxProvider = ({
|
||||
apiKey,
|
||||
context,
|
||||
sessionTimeoutMs,
|
||||
}: {
|
||||
apiKey: string;
|
||||
context: SandboxSessionContext;
|
||||
sessionTimeoutMs: number;
|
||||
}): SandboxProvider => ({
|
||||
run: async ({ command, files, returnFiles, timeoutMs }) => {
|
||||
const sandbox = await findOrCreateE2bSandbox({
|
||||
apiKey,
|
||||
context,
|
||||
timeoutMs: sessionTimeoutMs,
|
||||
});
|
||||
try {
|
||||
await ensureE2bWorkDir({ sandbox });
|
||||
await writeE2bSandboxFiles({ files, sandbox });
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: e2bWorkDir,
|
||||
timeoutMs,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
exitCode: result.exitCode,
|
||||
timedOut: false,
|
||||
files: await readRequestedE2bFiles({ returnFiles, sandbox }),
|
||||
} satisfies SandboxRunResult;
|
||||
} catch (error) {
|
||||
const timedOut =
|
||||
error instanceof Error && /timeout|timed out/i.test(error.message);
|
||||
if (!timedOut) throw error;
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "Sandbox command timed out",
|
||||
timedOut: true,
|
||||
files: await readRequestedE2bFiles({ returnFiles, sandbox }),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
21
apps/leaf/src/providers/slack/context.ts
Normal file
21
apps/leaf/src/providers/slack/context.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const slackWorkspaceSchema = z.preprocess(
|
||||
(value) => {
|
||||
const payload =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
workspaceId:
|
||||
payload.team_id ??
|
||||
(payload.team as Record<string, unknown> | undefined)?.id ??
|
||||
(typeof payload.team === "string" ? payload.team : undefined) ??
|
||||
(payload.user as Record<string, unknown> | undefined)?.team_id,
|
||||
};
|
||||
},
|
||||
z.strictObject({ workspaceId: z.string() }),
|
||||
);
|
||||
|
||||
export const getSlackWorkspaceId = (raw: unknown) =>
|
||||
slackWorkspaceSchema.parse(raw).workspaceId;
|
||||
105
apps/leaf/src/providers/slack/files.ts
Normal file
105
apps/leaf/src/providers/slack/files.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Attachment } from "chat";
|
||||
|
||||
const SLACK_FILES_INFO_URL = "https://slack.com/api/files.info";
|
||||
|
||||
type SlackRawFile = {
|
||||
id?: string;
|
||||
mimetype?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
url_private?: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const parseSlackFile = (value: unknown): SlackRawFile | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
id: typeof value.id === "string" ? value.id : undefined,
|
||||
mimetype: typeof value.mimetype === "string" ? value.mimetype : undefined,
|
||||
name: typeof value.name === "string" ? value.name : undefined,
|
||||
size: typeof value.size === "number" ? value.size : undefined,
|
||||
url_private:
|
||||
typeof value.url_private === "string" ? value.url_private : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSlackFilesFromRaw = ({ raw }: { raw: unknown }) => {
|
||||
if (!isRecord(raw) || !Array.isArray(raw.files)) return [];
|
||||
return raw.files.flatMap((file) => {
|
||||
const parsed = parseSlackFile(file);
|
||||
return parsed ? [parsed] : [];
|
||||
});
|
||||
};
|
||||
|
||||
const findRawFileForAttachment = ({
|
||||
attachment,
|
||||
files,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
files: SlackRawFile[];
|
||||
}) =>
|
||||
files.find(
|
||||
(file) =>
|
||||
file.name === attachment.name &&
|
||||
file.mimetype === attachment.mimeType &&
|
||||
file.size === attachment.size,
|
||||
) ?? files.find((file) => file.name === attachment.name);
|
||||
|
||||
const fetchSlackPrivateUrl = async ({
|
||||
botToken,
|
||||
url,
|
||||
}: {
|
||||
botToken: string;
|
||||
url: string;
|
||||
}) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack file download failed: ${response.status}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
};
|
||||
|
||||
const fetchSlackFileInfoUrl = async ({
|
||||
botToken,
|
||||
fileId,
|
||||
}: {
|
||||
botToken: string;
|
||||
fileId: string;
|
||||
}) => {
|
||||
const url = new URL(SLACK_FILES_INFO_URL);
|
||||
url.searchParams.set("file", fileId);
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Slack files.info failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (!isRecord(data) || data.ok !== true || !isRecord(data.file)) return null;
|
||||
return typeof data.file.url_private === "string"
|
||||
? data.file.url_private
|
||||
: null;
|
||||
};
|
||||
|
||||
export const fetchSlackAttachmentFallback = async ({
|
||||
attachment,
|
||||
botToken,
|
||||
rawFiles,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
botToken: string;
|
||||
rawFiles: SlackRawFile[];
|
||||
}) => {
|
||||
const rawFile = findRawFileForAttachment({ attachment, files: rawFiles });
|
||||
if (!rawFile) return null;
|
||||
const url =
|
||||
rawFile.url_private ??
|
||||
(rawFile.id
|
||||
? await fetchSlackFileInfoUrl({ botToken, fileId: rawFile.id })
|
||||
: null);
|
||||
if (!url) return null;
|
||||
return fetchSlackPrivateUrl({ botToken, url });
|
||||
};
|
||||
149
apps/leaf/src/providers/slack/installations.ts
Normal file
149
apps/leaf/src/providers/slack/installations.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
AppEnv,
|
||||
apiKeys,
|
||||
type ChatInstallation,
|
||||
type ChatInstallState,
|
||||
type ChatProvider,
|
||||
chatInstallations,
|
||||
organizations,
|
||||
} from "@autumn/shared";
|
||||
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];
|
||||
|
||||
export const getStateSecret = () => env.CHAT_STATE_SECRET;
|
||||
|
||||
export const findInstallation = (provider: ChatProvider, workspaceId: string) =>
|
||||
db.query.chatInstallations.findFirst({
|
||||
where: and(
|
||||
eq(chatInstallations.provider, provider),
|
||||
eq(chatInstallations.workspace_id, workspaceId),
|
||||
),
|
||||
});
|
||||
|
||||
export type ChatInstallationWithOrg = ChatInstallation & {
|
||||
org_slug?: string;
|
||||
};
|
||||
|
||||
export const findInstallationWithOrg = async (
|
||||
provider: ChatProvider,
|
||||
workspaceId: string,
|
||||
): Promise<ChatInstallationWithOrg | undefined> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
installation: chatInstallations,
|
||||
orgSlug: organizations.slug,
|
||||
})
|
||||
.from(chatInstallations)
|
||||
.innerJoin(organizations, eq(organizations.id, chatInstallations.org_id))
|
||||
.where(
|
||||
and(
|
||||
eq(chatInstallations.provider, provider),
|
||||
eq(chatInstallations.workspace_id, workspaceId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row
|
||||
? {
|
||||
...row.installation,
|
||||
org_slug: row.orgSlug,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const getInstallationKey = (
|
||||
installation: ChatInstallation,
|
||||
env: AppEnv,
|
||||
) => {
|
||||
const key =
|
||||
env === AppEnv.Live
|
||||
? installation.live_api_key
|
||||
: installation.sandbox_api_key;
|
||||
if (!key) throw new Error(`Missing ${env} API key`);
|
||||
return decrypt(key);
|
||||
};
|
||||
|
||||
const deleteInstallationApiKeys = async (
|
||||
tx: ChatTransaction,
|
||||
installation: ChatInstallation,
|
||||
) => {
|
||||
for (const id of [
|
||||
installation.sandbox_api_key_id,
|
||||
installation.live_api_key_id,
|
||||
]) {
|
||||
if (!id) continue;
|
||||
await tx
|
||||
.delete(apiKeys)
|
||||
.where(and(eq(apiKeys.id, id), eq(apiKeys.org_id, installation.org_id)));
|
||||
}
|
||||
};
|
||||
|
||||
export const replaceInstallation = async ({
|
||||
state,
|
||||
provider,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
botUserId,
|
||||
botAccessToken,
|
||||
scopes,
|
||||
installedByProviderUserId,
|
||||
}: {
|
||||
state: ChatInstallState;
|
||||
provider: ChatProvider;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
botUserId?: string;
|
||||
botAccessToken: string;
|
||||
scopes: string[];
|
||||
installedByProviderUserId?: string;
|
||||
}) => {
|
||||
const sameOrg = and(
|
||||
eq(chatInstallations.org_id, state.orgId),
|
||||
eq(chatInstallations.provider, provider),
|
||||
);
|
||||
const sameWorkspace = and(
|
||||
eq(chatInstallations.provider, provider),
|
||||
eq(chatInstallations.workspace_id, workspaceId),
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const existingInstallations = await tx.query.chatInstallations.findMany({
|
||||
where: or(sameOrg, sameWorkspace),
|
||||
});
|
||||
for (const installation of existingInstallations) {
|
||||
await deleteInstallationApiKeys(tx, installation);
|
||||
}
|
||||
|
||||
await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace));
|
||||
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,
|
||||
});
|
||||
});
|
||||
};
|
||||
45
apps/leaf/src/providers/slack/oauth.ts
Normal file
45
apps/leaf/src/providers/slack/oauth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { env } from "../../lib/env.js";
|
||||
|
||||
const SLACK_OAUTH_URL = "https://slack.com/api/oauth.v2.access";
|
||||
|
||||
const slackOAuthBaseSchema = z
|
||||
.object({
|
||||
ok: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const slackOAuthSuccessSchema = z.object({
|
||||
access_token: z.string(),
|
||||
scope: z.string().optional(),
|
||||
bot_user_id: z.string().optional(),
|
||||
team: z.object({ id: z.string(), name: z.string() }),
|
||||
authed_user: z.object({ id: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const exchangeSlackCode = async (code: string) => {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.SLACK_CLIENT_ID,
|
||||
client_secret: env.SLACK_CLIENT_SECRET,
|
||||
code,
|
||||
});
|
||||
if (env.SLACK_REDIRECT_URI) {
|
||||
body.set("redirect_uri", env.SLACK_REDIRECT_URI);
|
||||
}
|
||||
const response = await fetch(SLACK_OAUTH_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const data = slackOAuthBaseSchema.parse(await response.json());
|
||||
if (!data.ok)
|
||||
throw new Error(`Slack OAuth failed: ${data.error ?? "unknown"}`);
|
||||
return slackOAuthSuccessSchema.parse(data);
|
||||
};
|
||||
|
||||
export const slackSuccessUrl = () =>
|
||||
`${env.CLIENT_URL}/settings?tab=integrations&chat=connected`;
|
||||
|
||||
export const slackErrorUrl = (error: string) =>
|
||||
`${env.CLIENT_URL}/settings?tab=integrations&chat_error=${encodeURIComponent(error)}`;
|
||||
79
apps/leaf/src/providers/slack/routes.ts
Normal file
79
apps/leaf/src/providers/slack/routes.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type ChatProvider, verifyChatInstallState } from "@autumn/shared";
|
||||
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";
|
||||
|
||||
const callbackQuery = z.strictObject({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
const isSlackInstallProvider = (provider: string): provider is ChatProvider =>
|
||||
provider === "slack" ||
|
||||
provider === "slack_admin" ||
|
||||
provider.startsWith("slack_admin:");
|
||||
|
||||
export const slackRoutes = new Hono();
|
||||
|
||||
slackRoutes.get("/oauth/callback", async (c) => {
|
||||
try {
|
||||
const { code, state } = callbackQuery.parse({
|
||||
code: c.req.query("code"),
|
||||
state: c.req.query("state"),
|
||||
});
|
||||
|
||||
const parsedState = verifyChatInstallState(state, getStateSecret());
|
||||
if (!parsedState || !isSlackInstallProvider(parsedState.provider))
|
||||
throw new Error("Invalid or expired Slack OAuth state");
|
||||
|
||||
const oauth = await exchangeSlackCode(code);
|
||||
await replaceInstallation({
|
||||
state: parsedState,
|
||||
provider: parsedState.provider,
|
||||
workspaceId: oauth.team.id,
|
||||
workspaceName: oauth.team.name,
|
||||
botUserId: oauth.bot_user_id,
|
||||
botAccessToken: oauth.access_token,
|
||||
scopes: String(oauth.scope ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean),
|
||||
installedByProviderUserId: oauth.authed_user?.id,
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
});
|
||||
30
apps/leaf/src/providers/slack/threadContext.ts
Normal file
30
apps/leaf/src/providers/slack/threadContext.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Message, Thread } from "chat";
|
||||
import type { ChatContextMessage } from "../../types.js";
|
||||
|
||||
export const getRecentMessages = async (
|
||||
thread: Thread,
|
||||
currentMessage: Message,
|
||||
): Promise<ChatContextMessage[]> => {
|
||||
try {
|
||||
await thread.refresh();
|
||||
} catch (error) {
|
||||
console.warn("[chat] Could not refresh thread context", error);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...thread.recentMessages, currentMessage]
|
||||
.filter((message) => {
|
||||
if (seen.has(message.id) || !message.text.trim()) return false;
|
||||
seen.add(message.id);
|
||||
return true;
|
||||
})
|
||||
.slice(-8)
|
||||
.map((message) => ({
|
||||
author:
|
||||
message.author.fullName ||
|
||||
message.author.userName ||
|
||||
message.author.userId,
|
||||
isBot: message.author.isBot,
|
||||
text: message.text,
|
||||
}));
|
||||
};
|
||||
88
apps/leaf/src/types.ts
Normal file
88
apps/leaf/src/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import { AppEnv, type ChatInstallation } from "@autumn/shared";
|
||||
import type { Attachment } from "chat";
|
||||
import { z } from "zod";
|
||||
|
||||
export type LeafChatInstallation = ChatInstallation & {
|
||||
org_slug?: string;
|
||||
};
|
||||
|
||||
export const agentOutputSchema = z.preprocess(
|
||||
(value) => {
|
||||
const payload =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const suspendPayload = payload.suspendPayload as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const previewApproval = payload.previewApproval as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return {
|
||||
text: payload.text,
|
||||
env: payload.env,
|
||||
finishReason: payload.finishReason,
|
||||
runId: payload.runId,
|
||||
suspendPayload: suspendPayload && {
|
||||
toolCallId: suspendPayload.toolCallId,
|
||||
toolName: suspendPayload.toolName,
|
||||
args: suspendPayload.args,
|
||||
},
|
||||
previewApproval: previewApproval && {
|
||||
toolName: previewApproval.toolName,
|
||||
toolArgs: previewApproval.toolArgs,
|
||||
preview: previewApproval.preview,
|
||||
},
|
||||
};
|
||||
},
|
||||
z.strictObject({
|
||||
text: z.string().optional(),
|
||||
env: z.nativeEnum(AppEnv),
|
||||
finishReason: z.string().optional(),
|
||||
runId: z.string().optional(),
|
||||
suspendPayload: z
|
||||
.strictObject({
|
||||
toolCallId: z.string().optional(),
|
||||
toolName: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
previewApproval: z
|
||||
.strictObject({
|
||||
toolName: z.string(),
|
||||
toolArgs: z.record(z.string(), z.unknown()),
|
||||
preview: z.unknown(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type AgentOutput = z.infer<typeof agentOutputSchema>;
|
||||
|
||||
export type SignatureArgs = {
|
||||
body: string;
|
||||
timestamp?: string | null;
|
||||
signature?: string | null;
|
||||
};
|
||||
|
||||
export type BotMessage = {
|
||||
agentRunId?: string;
|
||||
attachmentFetchFallback?: (params: {
|
||||
attachment: Attachment;
|
||||
}) => Promise<Buffer | null>;
|
||||
attachments?: Attachment[];
|
||||
installation: LeafChatInstallation;
|
||||
logger?: AutumnLogger;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
recentMessages?: ChatContextMessage[];
|
||||
text: string;
|
||||
channelId: string;
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
export type ChatContextMessage = {
|
||||
author: string;
|
||||
isBot: boolean | "unknown";
|
||||
text: string;
|
||||
};
|
||||
254
apps/leaf/src/ui/blocks.ts
Normal file
254
apps/leaf/src/ui/blocks.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { Actions, Button, Card, CardText, Divider, Field, Fields } from "chat";
|
||||
import { toolLabel } from "../agent/toolPolicy.js";
|
||||
|
||||
const formatPreview = (preview: unknown) =>
|
||||
typeof preview === "string"
|
||||
? preview
|
||||
: "";
|
||||
|
||||
const getRequest = (args?: Record<string, unknown>) =>
|
||||
(args?.request && typeof args.request === "object"
|
||||
? args.request
|
||||
: args) as Record<string, unknown> | undefined;
|
||||
|
||||
const getFieldValue = (value: unknown) =>
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: typeof value === "boolean"
|
||||
? value
|
||||
? "Yes"
|
||||
: "No"
|
||||
: null;
|
||||
|
||||
const getRecord = (value: unknown) =>
|
||||
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
|
||||
const formatPrice = (request: Record<string, unknown>) => {
|
||||
const customize = getRecord(request.customize);
|
||||
const price = getRecord(customize.price);
|
||||
const amount = getFieldValue(price.amount);
|
||||
const interval = getFieldValue(price.interval);
|
||||
return amount ? `$${amount}${interval ? `/${interval}` : ""}` : null;
|
||||
};
|
||||
|
||||
const formatInvoiceMode = (value: unknown) => {
|
||||
if (typeof value === "boolean") return value ? "enabled" : "disabled";
|
||||
const invoiceMode = getRecord(value);
|
||||
if (!Object.keys(invoiceMode).length) return null;
|
||||
|
||||
return [
|
||||
invoiceMode.enabled === true
|
||||
? "enabled"
|
||||
: invoiceMode.enabled === false
|
||||
? "disabled"
|
||||
: null,
|
||||
invoiceMode.finalize === false
|
||||
? "draft invoice"
|
||||
: invoiceMode.finalize === true
|
||||
? "finalize invoice"
|
||||
: null,
|
||||
invoiceMode.enable_plan_immediately === true
|
||||
? "enable immediately"
|
||||
: invoiceMode.enable_plan_immediately === false
|
||||
? "access waits"
|
||||
: null,
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
const envLabel = (env?: AppEnv) =>
|
||||
env === "live" ? "Live" : env === "sandbox" ? "Sandbox" : null;
|
||||
|
||||
const requestFields = ({
|
||||
env,
|
||||
toolName,
|
||||
toolArgs,
|
||||
}: {
|
||||
env?: AppEnv;
|
||||
toolName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
}) => {
|
||||
const request = getRequest(toolArgs);
|
||||
const environment = envLabel(env);
|
||||
const baseFields = [
|
||||
Field({
|
||||
label: "Action",
|
||||
value: toolLabel(toolName),
|
||||
}),
|
||||
...(environment
|
||||
? [Field({ label: "Environment", value: environment })]
|
||||
: []),
|
||||
];
|
||||
if (!request) return baseFields;
|
||||
|
||||
return [
|
||||
...baseFields,
|
||||
...[
|
||||
["Customer", request.customer_id],
|
||||
["Plan", request.plan_id],
|
||||
["Entity", request.entity_id],
|
||||
["Subscription", request.subscription_id],
|
||||
["Price", formatPrice(request)],
|
||||
["Enable immediately", request.enable_plan_immediately],
|
||||
["Invoice mode", formatInvoiceMode(request.invoice_mode)],
|
||||
["Proration", request.proration_behavior],
|
||||
["Redirect", request.redirect_mode],
|
||||
].flatMap(([label, value]) => {
|
||||
const fieldValue = getFieldValue(value);
|
||||
return fieldValue ? [Field({ label: String(label), value: fieldValue })] : [];
|
||||
}),
|
||||
].slice(0, 8);
|
||||
};
|
||||
|
||||
const cleanPreviewLine = (line: string) =>
|
||||
line
|
||||
.replace(/^[-*•]\s*/, "")
|
||||
.replace(/\*\*/g, "")
|
||||
.replace(/__+/g, "")
|
||||
.trim();
|
||||
|
||||
const previewLines = (preview: unknown) =>
|
||||
formatPreview(preview)
|
||||
.replace(
|
||||
/\s*(Plan:|Customer:|Description|Amount|Total|Discounts?:|Payment will|No discounts|No existing)/g,
|
||||
"\n$1",
|
||||
)
|
||||
.split(/\n+/)
|
||||
.map(cleanPreviewLine)
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(line) =>
|
||||
!/^(i('|’)ll|let me|here('|’)s|would you like|shall i|tool:|[\{\}\"])/i.test(
|
||||
line,
|
||||
),
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
const resultLines = (result: unknown) => {
|
||||
if (!result) return [];
|
||||
if (typeof result === "string") return [result];
|
||||
if (typeof result !== "object") return [String(result)];
|
||||
|
||||
const body = result as Record<string, unknown>;
|
||||
const resultBody = getRecord(body.result);
|
||||
const nested = resultBody.message || resultBody.status ? resultBody : getRecord(body.data);
|
||||
const value = (key: string) => body[key] ?? nested[key];
|
||||
const message = value("message");
|
||||
const status = value("status");
|
||||
const id = value("id");
|
||||
const url = value("url");
|
||||
const checkoutUrl = value("checkout_url");
|
||||
|
||||
return [
|
||||
typeof message === "string" ? message : null,
|
||||
typeof status === "string" ? `Status: ${status}` : null,
|
||||
typeof id === "string" ? `ID: ${id}` : null,
|
||||
typeof url === "string" ? `URL: ${url}` : null,
|
||||
typeof checkoutUrl === "string" ? `Checkout URL: ${checkoutUrl}` : null,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.slice(0, 6);
|
||||
};
|
||||
|
||||
const statusLines = ({
|
||||
status,
|
||||
result,
|
||||
}: {
|
||||
status: "approved" | "cancelled" | "failed" | "running";
|
||||
result?: unknown;
|
||||
}) => {
|
||||
const lines = resultLines(result);
|
||||
if (lines.length) return lines;
|
||||
if (status === "running") return ["Applying the approved action now..."];
|
||||
if (status === "cancelled") return ["No changes were made."];
|
||||
return status === "failed" ? ["The action failed."] : [];
|
||||
};
|
||||
|
||||
export const approvalCard = ({
|
||||
env,
|
||||
id,
|
||||
toolName,
|
||||
toolArgs,
|
||||
preview,
|
||||
}: {
|
||||
env?: AppEnv;
|
||||
id: string;
|
||||
toolName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
preview?: unknown;
|
||||
}) =>
|
||||
{
|
||||
const fields = requestFields({ env, toolName, toolArgs });
|
||||
const lines = preview ? previewLines(preview) : [];
|
||||
|
||||
return Card({
|
||||
title: `${toolLabel(toolName)}?`,
|
||||
subtitle: "Review the preview before this runs",
|
||||
children: [
|
||||
...(fields.length ? [Fields(fields)] : []),
|
||||
...(lines.length
|
||||
? [Divider(), CardText(lines.map((line) => `• ${line}`).join("\n"))]
|
||||
: []),
|
||||
Actions([
|
||||
Button({
|
||||
id: "approve_billing_action",
|
||||
label: "Approve",
|
||||
style: "primary",
|
||||
value: id,
|
||||
}),
|
||||
Button({
|
||||
id: "cancel_billing_action",
|
||||
label: "Cancel",
|
||||
style: "danger",
|
||||
value: id,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export const approvalStatusCard = ({
|
||||
env,
|
||||
status,
|
||||
toolName,
|
||||
toolArgs,
|
||||
preview,
|
||||
result,
|
||||
}: {
|
||||
env?: AppEnv;
|
||||
status: "approved" | "cancelled" | "failed" | "running";
|
||||
toolName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
preview?: unknown;
|
||||
result?: unknown;
|
||||
}) => {
|
||||
const fields = requestFields({ env, toolName, toolArgs });
|
||||
const lines = statusLines({ status, result });
|
||||
const title =
|
||||
status === "approved"
|
||||
? `${toolLabel(toolName)} approved`
|
||||
: status === "cancelled"
|
||||
? `${toolLabel(toolName)} cancelled`
|
||||
: status === "running"
|
||||
? `Running ${toolLabel(toolName)}`
|
||||
: `${toolLabel(toolName)} failed`;
|
||||
|
||||
return Card({
|
||||
title,
|
||||
subtitle:
|
||||
status === "running"
|
||||
? "Applying the approved action"
|
||||
: "The approval is closed",
|
||||
children: [
|
||||
...(fields.length ? [Fields(fields)] : []),
|
||||
...(lines.length
|
||||
? [
|
||||
Divider(),
|
||||
CardText(lines.map((line) => `• ${cleanPreviewLine(line)}`).join("\n")),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
};
|
||||
56
apps/leaf/src/ui/progress.ts
Normal file
56
apps/leaf/src/ui/progress.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Channel, Thread } from "chat";
|
||||
import { Plan } from "chat";
|
||||
|
||||
export type ReplyTarget = Thread | Channel;
|
||||
export type LoadingState = Plan | null;
|
||||
|
||||
export const startLoading = async (target: ReplyTarget) => {
|
||||
try {
|
||||
await target.startTyping("Starting Autumn...");
|
||||
const loading = new Plan({ initialMessage: "Starting Autumn..." });
|
||||
await target.post(loading);
|
||||
return loading;
|
||||
} catch (error) {
|
||||
console.warn("[chat] Could not show loading state", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createActionLogger = (loading: LoadingState) => {
|
||||
const seen = new Set<string>();
|
||||
let first = true;
|
||||
|
||||
return async (message: string) => {
|
||||
if (!loading || seen.has(message)) return;
|
||||
seen.add(message);
|
||||
|
||||
try {
|
||||
if (first) {
|
||||
first = false;
|
||||
await loading.reset({ initialMessage: message });
|
||||
return;
|
||||
}
|
||||
await loading.addTask({ title: message });
|
||||
} catch (error) {
|
||||
console.warn("[chat] Could not update loading state", error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const finishLoading = async (
|
||||
target: ReplyTarget,
|
||||
loading: LoadingState,
|
||||
message: string,
|
||||
) => {
|
||||
if (!loading) {
|
||||
await target.post({ markdown: message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loading.complete({ completeMessage: message });
|
||||
} catch (error) {
|
||||
console.warn("[chat] Could not complete loading state", error);
|
||||
await target.post({ markdown: message });
|
||||
}
|
||||
};
|
||||
39
apps/leaf/tests/evals/README.md
Normal file
39
apps/leaf/tests/evals/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Leaf Evals
|
||||
|
||||
Braintrust evals for Leaf and Autumn MCP behavior.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
bun -F @autumn/leaf eval:mcp
|
||||
```
|
||||
|
||||
Eval files set `noSendLogs` when `BRAINTRUST_API_KEY` is absent, but agent
|
||||
model calls still need the normal model provider environment.
|
||||
|
||||
## Pattern
|
||||
|
||||
- Build setup state with `fixtures/*` builders.
|
||||
- Create runtime state with `context/createEvalContext`.
|
||||
- Use a driver factory, usually `createGenericMcpAgentDriver`, to exercise the runtime.
|
||||
- Assert behavior with deterministic scorers before adding LLM judges.
|
||||
- Keep real customer/org names out of fixtures; use setup tags like
|
||||
`invoice-mode-customer-missing-email`.
|
||||
- Keep `trace.event(...)` terminal-only. Braintrust spans should come from the
|
||||
Mastra observability exporter unless a test explicitly needs custom spans.
|
||||
|
||||
## Context
|
||||
|
||||
The eval context is intentionally split by responsibility:
|
||||
|
||||
- `harness/context` owns the mock Autumn API and local MCP server.
|
||||
- `harness/configs` owns defaults and reusable eval configuration objects.
|
||||
- `harness/drivers` owns agent/client variants that talk to the MCP server.
|
||||
- `harness/tracing` owns local terminal visibility for user turns, tool calls,
|
||||
API calls, and approvals.
|
||||
|
||||
Eval files should read like scenarios: choose a fixture setup, create a context,
|
||||
run conversation turns, return scorer output.
|
||||
|
||||
The old MCP evals under `packages/mcp/tests/evals` are intentionally left in
|
||||
place while this structure is proven out.
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
billing,
|
||||
response,
|
||||
tools,
|
||||
} from "../../../fixtures/expectations/index.js";
|
||||
import { withCustomers } from "../../../fixtures/createSetup.js";
|
||||
import { orgSetups } from "../../../fixtures/orgSetups.js";
|
||||
import { approve, initEval, user } from "../../../harness/index.js";
|
||||
import { billingAttachScores } from "../../../utils/scorers.js";
|
||||
|
||||
type EvalMetadata = {
|
||||
domain: "billing";
|
||||
flow: "attach";
|
||||
};
|
||||
|
||||
const experimentName = "attach-custom-price";
|
||||
const customPrice = 49;
|
||||
|
||||
const setup = withCustomers({
|
||||
setup: orgSetups.knowledgePlatform(),
|
||||
customers: ({ customers }) => ({
|
||||
account: customers.base({
|
||||
email: "billing@northstar.example",
|
||||
id: "cus_attach_custom_price",
|
||||
name: "Northstar Labs",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const customer = setup.refs.customers.account;
|
||||
const enterprisePlan = setup.refs.plans.enterprise;
|
||||
|
||||
const expectedAttachRequest = {
|
||||
customer_id: customer.id,
|
||||
customize: {
|
||||
price: {
|
||||
amount: customPrice,
|
||||
interval: "month",
|
||||
},
|
||||
},
|
||||
enable_plan_immediately: true,
|
||||
invoice_mode: {
|
||||
enable_plan_immediately: true,
|
||||
enabled: true,
|
||||
finalize: false,
|
||||
},
|
||||
plan_id: enterprisePlan.id,
|
||||
redirect_mode: "if_required",
|
||||
};
|
||||
|
||||
initEval<EvalMetadata>({
|
||||
experimentName,
|
||||
setup,
|
||||
metadata: {
|
||||
domain: "billing",
|
||||
flow: "attach",
|
||||
},
|
||||
scores: billingAttachScores(),
|
||||
cases: [
|
||||
{
|
||||
name: "custom monthly price with draft invoice",
|
||||
conversation: [
|
||||
user({
|
||||
message:
|
||||
"Please attach the Enterprise plan to Northstar Labs with a custom base price of $49/month.",
|
||||
}),
|
||||
user({ message: "Looks good, attach it." }),
|
||||
approve(),
|
||||
],
|
||||
expect: [
|
||||
tools.called({
|
||||
toolNames: ["listCustomers", "listPlans"],
|
||||
}),
|
||||
billing.previewBeforeWrite({
|
||||
preview: {
|
||||
body: expectedAttachRequest,
|
||||
toolName: "previewAttach",
|
||||
},
|
||||
write: {
|
||||
body: expectedAttachRequest,
|
||||
toolName: "attach",
|
||||
},
|
||||
}),
|
||||
response.mentions({
|
||||
phrases: [
|
||||
"Northstar Labs",
|
||||
"Enterprise",
|
||||
"$49",
|
||||
"invoice",
|
||||
"immediately",
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
230
apps/leaf/tests/evals/fixtures/createSetup.ts
Normal file
230
apps/leaf/tests/evals/fixtures/createSetup.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
|
||||
import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule";
|
||||
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
|
||||
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
|
||||
import {
|
||||
balances as balanceFixtures,
|
||||
customers as customerFixtures,
|
||||
customerList as customerListFixture,
|
||||
schedules as scheduleFixtures,
|
||||
subscriptions as subscriptionFixtures,
|
||||
} from "./customers/index.js";
|
||||
import {
|
||||
basePrice as basePriceFixture,
|
||||
features as featureFixtures,
|
||||
featureList as featureListFixture,
|
||||
items as itemFixtures,
|
||||
itemList as itemListFixture,
|
||||
plan as planFixture,
|
||||
planList as planListFixture,
|
||||
} from "./plans/index.js";
|
||||
import type { EvalSetup, EvalSetupIds, PlanRef, ScheduleRef } from "./types.js";
|
||||
|
||||
const flattenRecordValues = <Value>(record: Record<string, Value | Value[]>) =>
|
||||
Object.values(record).flatMap((value) =>
|
||||
Array.isArray(value) ? value : [value],
|
||||
);
|
||||
|
||||
const refIds = <Value extends { id?: string | null }>(
|
||||
record: Record<string, Value | Value[]>,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.map((item) => item.id) : value.id,
|
||||
]),
|
||||
);
|
||||
|
||||
const setupIds = <
|
||||
Features extends Record<string, ApiFeatureV1>,
|
||||
Plans extends Record<string, PlanRef>,
|
||||
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
|
||||
Schedules extends Record<string, ScheduleRef>,
|
||||
>({
|
||||
customers,
|
||||
features,
|
||||
plans,
|
||||
schedules,
|
||||
}: {
|
||||
customers: Customers;
|
||||
features: Features;
|
||||
plans: Plans;
|
||||
schedules: Schedules;
|
||||
}) =>
|
||||
({
|
||||
customers: refIds(customers),
|
||||
features: refIds(features),
|
||||
plans: refIds(plans),
|
||||
schedules: refIds(schedules),
|
||||
}) as unknown as EvalSetupIds<Features, Plans, Customers, Schedules>;
|
||||
|
||||
/**
|
||||
* Compose a mock Autumn org for evals from keyed feature, plan, and customer refs.
|
||||
* The returned arrays feed the mock API; refs keep setup assertions readable.
|
||||
*/
|
||||
export const createSetup = <
|
||||
Features extends Record<string, ApiFeatureV1>,
|
||||
Plans extends Record<string, PlanRef>,
|
||||
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
|
||||
Schedules extends Record<string, ScheduleRef> = Record<string, never>,
|
||||
>({
|
||||
customers: createCustomers,
|
||||
features: createFeatures,
|
||||
plans: createPlans,
|
||||
schedules: createSchedules,
|
||||
tag,
|
||||
}: {
|
||||
tag: string;
|
||||
features: ({
|
||||
featureList,
|
||||
features,
|
||||
}: {
|
||||
featureList: typeof featureListFixture;
|
||||
features: typeof featureFixtures;
|
||||
}) => Features;
|
||||
plans: ({
|
||||
basePrice,
|
||||
features,
|
||||
itemList,
|
||||
items,
|
||||
plan,
|
||||
planList,
|
||||
}: {
|
||||
basePrice: typeof basePriceFixture;
|
||||
features: Features;
|
||||
itemList: typeof itemListFixture;
|
||||
items: typeof itemFixtures;
|
||||
plan: typeof planFixture;
|
||||
planList: typeof planListFixture;
|
||||
}) => Plans;
|
||||
customers: ({
|
||||
balances,
|
||||
customerList,
|
||||
customers,
|
||||
features,
|
||||
plans,
|
||||
subscriptions,
|
||||
}: {
|
||||
balances: typeof balanceFixtures;
|
||||
customerList: typeof customerListFixture;
|
||||
customers: typeof customerFixtures;
|
||||
features: Features;
|
||||
plans: Plans;
|
||||
subscriptions: typeof subscriptionFixtures;
|
||||
}) => Customers;
|
||||
schedules?: ({
|
||||
customers,
|
||||
plans,
|
||||
schedules,
|
||||
}: {
|
||||
customers: Customers;
|
||||
plans: Plans;
|
||||
schedules: typeof scheduleFixtures;
|
||||
}) => Schedules;
|
||||
}): EvalSetup<Features, Plans, Customers, Schedules> => {
|
||||
const featureRefs = createFeatures({
|
||||
featureList: featureListFixture,
|
||||
features: featureFixtures,
|
||||
});
|
||||
const planRefs = createPlans({
|
||||
basePrice: basePriceFixture,
|
||||
features: featureRefs,
|
||||
itemList: itemListFixture,
|
||||
items: itemFixtures,
|
||||
plan: planFixture,
|
||||
planList: planListFixture,
|
||||
});
|
||||
const customerRefs = createCustomers({
|
||||
balances: balanceFixtures,
|
||||
customerList: customerListFixture,
|
||||
customers: customerFixtures,
|
||||
features: featureRefs,
|
||||
plans: planRefs,
|
||||
subscriptions: subscriptionFixtures,
|
||||
});
|
||||
const scheduleRefs = createSchedules?.({
|
||||
customers: customerRefs,
|
||||
plans: planRefs,
|
||||
schedules: scheduleFixtures,
|
||||
});
|
||||
|
||||
return {
|
||||
tag,
|
||||
ids: setupIds({
|
||||
customers: customerRefs,
|
||||
features: featureRefs,
|
||||
plans: planRefs,
|
||||
schedules: (scheduleRefs ?? {}) as Schedules,
|
||||
}),
|
||||
features: Object.values(featureRefs),
|
||||
plans: flattenRecordValues<ApiPlanV1>(planRefs),
|
||||
customers: flattenRecordValues<BaseApiCustomerV5>(customerRefs),
|
||||
schedules: flattenRecordValues<ApiCustomerSchedule>(
|
||||
(scheduleRefs ?? {}) as Schedules,
|
||||
),
|
||||
refs: {
|
||||
features: featureRefs,
|
||||
plans: planRefs,
|
||||
customers: customerRefs,
|
||||
schedules: (scheduleRefs ?? {}) as Schedules,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Extend an org setup with eval-specific customers while preserving typed refs. */
|
||||
export const withCustomers = <
|
||||
Setup extends EvalSetup,
|
||||
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
|
||||
>({
|
||||
customers: createCustomers,
|
||||
setup,
|
||||
}: {
|
||||
setup: Setup;
|
||||
customers: ({
|
||||
balances,
|
||||
customerList,
|
||||
customers,
|
||||
features,
|
||||
plans,
|
||||
subscriptions,
|
||||
}: {
|
||||
balances: typeof balanceFixtures;
|
||||
customerList: typeof customerListFixture;
|
||||
customers: typeof customerFixtures;
|
||||
features: Setup["refs"]["features"];
|
||||
plans: Setup["refs"]["plans"];
|
||||
subscriptions: typeof subscriptionFixtures;
|
||||
}) => Customers;
|
||||
}): EvalSetup<
|
||||
Setup["refs"]["features"],
|
||||
Setup["refs"]["plans"],
|
||||
Customers,
|
||||
Setup["refs"]["schedules"]
|
||||
> => {
|
||||
const customerRefs = createCustomers({
|
||||
balances: balanceFixtures,
|
||||
customerList: customerListFixture,
|
||||
customers: customerFixtures,
|
||||
features: setup.refs.features,
|
||||
plans: setup.refs.plans,
|
||||
subscriptions: subscriptionFixtures,
|
||||
});
|
||||
|
||||
return {
|
||||
...setup,
|
||||
ids: setupIds({
|
||||
customers: customerRefs,
|
||||
features: setup.refs.features,
|
||||
plans: setup.refs.plans,
|
||||
schedules: setup.refs.schedules,
|
||||
}),
|
||||
customers: [
|
||||
...setup.customers,
|
||||
...flattenRecordValues<BaseApiCustomerV5>(customerRefs),
|
||||
],
|
||||
refs: {
|
||||
...setup.refs,
|
||||
customers: customerRefs,
|
||||
},
|
||||
};
|
||||
};
|
||||
60
apps/leaf/tests/evals/fixtures/customers/base/baseBalance.ts
Normal file
60
apps/leaf/tests/evals/fixtures/customers/base/baseBalance.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1.js";
|
||||
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
|
||||
|
||||
const dateToEpochMs = (date: Date | null) => date?.getTime() ?? null;
|
||||
|
||||
/** Base balance fixture with matching top-level values and one simple breakdown row. */
|
||||
export const baseBalance = ({
|
||||
featureId = "credits",
|
||||
granted = 0,
|
||||
remaining = granted,
|
||||
reset = { interval: ResetInterval.Month },
|
||||
nextResetAt = null,
|
||||
planId = null,
|
||||
usage = granted - remaining,
|
||||
}: {
|
||||
featureId?: string;
|
||||
granted?: number;
|
||||
remaining?: number;
|
||||
usage?: number;
|
||||
nextResetAt?: Date | null;
|
||||
reset?: { interval?: ResetInterval; intervalCount?: number } | null;
|
||||
planId?: string | null;
|
||||
} = {}): ApiBalanceV1 => {
|
||||
const nextResetAtMs = dateToEpochMs(nextResetAt);
|
||||
const resetValue = reset
|
||||
? {
|
||||
interval: reset.interval ?? ResetInterval.Month,
|
||||
interval_count: reset.intervalCount,
|
||||
resets_at: nextResetAtMs,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
object: "balance",
|
||||
feature_id: featureId,
|
||||
granted,
|
||||
remaining,
|
||||
usage,
|
||||
unlimited: false,
|
||||
overage_allowed: false,
|
||||
max_purchase: null,
|
||||
next_reset_at: nextResetAtMs,
|
||||
breakdown: [
|
||||
{
|
||||
object: "balance_breakdown",
|
||||
id: `balance_${featureId}`,
|
||||
plan_id: planId,
|
||||
included_grant: granted,
|
||||
prepaid_grant: 0,
|
||||
remaining,
|
||||
usage,
|
||||
unlimited: false,
|
||||
reset: resetValue,
|
||||
price: null,
|
||||
expires_at: null,
|
||||
overage: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
|
||||
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1.js";
|
||||
import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
|
||||
const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
/** Base customer API fixture; prefer presets unless you need direct shape control. */
|
||||
export const baseCustomer = ({
|
||||
balances = {},
|
||||
createdAt = defaultCreatedAt,
|
||||
email = "billing@example.com",
|
||||
id = "customer_active",
|
||||
name = "Active Customer",
|
||||
subscriptions = [],
|
||||
}: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
createdAt?: Date;
|
||||
subscriptions?: ApiSubscriptionV1[];
|
||||
balances?: Record<string, ApiBalanceV1>;
|
||||
} = {}): BaseApiCustomerV5 => ({
|
||||
balances,
|
||||
billing_controls: {},
|
||||
config: { disable_pooled_balance: false },
|
||||
created_at: createdAt.getTime(),
|
||||
env: AppEnv.Sandbox,
|
||||
id,
|
||||
email,
|
||||
fingerprint: null,
|
||||
flags: {},
|
||||
metadata: {},
|
||||
name,
|
||||
purchases: [],
|
||||
send_email_receipts: false,
|
||||
stripe_id: null,
|
||||
subscriptions,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
|
||||
import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule";
|
||||
|
||||
const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
export const baseSchedule = ({
|
||||
createdAt = defaultCreatedAt,
|
||||
customer,
|
||||
customerId = customer?.id ?? "customer",
|
||||
entityId = null,
|
||||
id = `sched_${customerId}`,
|
||||
phases,
|
||||
}: {
|
||||
createdAt?: Date;
|
||||
customer?: BaseApiCustomerV5;
|
||||
customerId?: string;
|
||||
entityId?: string | null;
|
||||
id?: string;
|
||||
phases: Array<{
|
||||
customerProductIds?: string[];
|
||||
id?: string;
|
||||
startsAt: Date;
|
||||
}>;
|
||||
}): ApiCustomerSchedule => ({
|
||||
id,
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
created_at: createdAt.getTime(),
|
||||
phases: phases.map((phase, index) => ({
|
||||
id: phase.id ?? `${id}_phase_${index + 1}`,
|
||||
created_at: createdAt.getTime(),
|
||||
customer_product_ids: phase.customerProductIds ?? [],
|
||||
starts_at: phase.startsAt.getTime(),
|
||||
})),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js";
|
||||
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
|
||||
|
||||
const defaultStartedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const dateToEpochMs = (date: Date | null) => date?.getTime() ?? null;
|
||||
|
||||
/** Base subscription fixture; pass plan to include the expanded plan response. */
|
||||
export const baseSubscription = ({
|
||||
id,
|
||||
plan,
|
||||
planId = plan?.id ?? "pro",
|
||||
status = "active",
|
||||
startedAt = defaultStartedAt,
|
||||
currentPeriodStart = startedAt,
|
||||
currentPeriodEnd = null,
|
||||
}: {
|
||||
id?: string;
|
||||
plan?: ApiPlanV1;
|
||||
planId?: string;
|
||||
status?: ApiSubscriptionV1["status"];
|
||||
startedAt?: Date;
|
||||
currentPeriodStart?: Date | null;
|
||||
currentPeriodEnd?: Date | null;
|
||||
}): ApiSubscriptionV1 => ({
|
||||
id: id ?? `sub_${planId}`,
|
||||
plan,
|
||||
plan_id: planId,
|
||||
auto_enable: false,
|
||||
add_on: false,
|
||||
status,
|
||||
past_due: false,
|
||||
canceled_at: null,
|
||||
expires_at: null,
|
||||
trial_ends_at: null,
|
||||
started_at: startedAt.getTime(),
|
||||
current_period_start: dateToEpochMs(currentPeriodStart),
|
||||
current_period_end: dateToEpochMs(currentPeriodEnd),
|
||||
quantity: 1,
|
||||
});
|
||||
4
apps/leaf/tests/evals/fixtures/customers/base/index.ts
Normal file
4
apps/leaf/tests/evals/fixtures/customers/base/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { baseBalance } from "./baseBalance.js";
|
||||
export { baseCustomer } from "./baseCustomer.js";
|
||||
export { baseSchedule } from "./baseSchedule.js";
|
||||
export { baseSubscription } from "./baseSubscription.js";
|
||||
13
apps/leaf/tests/evals/fixtures/customers/index.ts
Normal file
13
apps/leaf/tests/evals/fixtures/customers/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
baseBalance,
|
||||
baseCustomer,
|
||||
baseSchedule,
|
||||
baseSubscription,
|
||||
} from "./base/index.js";
|
||||
export {
|
||||
balances,
|
||||
customerList,
|
||||
customers,
|
||||
schedules,
|
||||
subscriptions,
|
||||
} from "./presets/index.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { baseBalance } from "../base/baseBalance.js";
|
||||
|
||||
export const balances = {
|
||||
empty: baseBalance,
|
||||
metered: baseBalance,
|
||||
} as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user